From 1de559c321ec1e822eaab18b49b068ce2f299369 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 8 Aug 2026 10:43:00 +0800 Subject: [PATCH] feat: scaffold workspace with search, fetch, and extract packages --- .env.example | 6 + .gitignore | 5 + README.md | 61 +++++ apps/proxy/package.json | 26 ++ apps/proxy/src/env.ts | 25 ++ apps/proxy/src/index.ts | 27 ++ apps/proxy/src/routes/health.ts | 7 + apps/proxy/src/routes/items.ts | 57 +++++ apps/proxy/src/routes/search.ts | 106 ++++++++ apps/proxy/tsconfig.json | 10 + package.json | 16 ++ packages/extract/package.json | 25 ++ packages/extract/src/download.ts | 81 ++++++ packages/extract/src/index.ts | 4 + packages/extract/src/objects.ts | 32 +++ packages/extract/src/refs.ts | 60 +++++ packages/extract/src/traverse.ts | 34 +++ packages/extract/tsconfig.json | 9 + packages/shared/package.json | 25 ++ packages/shared/src/index.ts | 2 + packages/shared/src/schemas.ts | 54 ++++ packages/shared/src/types.ts | 84 +++++++ packages/shared/tsconfig.json | 8 + packages/tts/package.json | 26 ++ packages/tts/src/errors.ts | 35 +++ packages/tts/src/index.ts | 113 +++++++++ packages/tts/tsconfig.json | 9 + pnpm-lock.yaml | 411 +++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.base.json | 20 ++ 30 files changed, 1381 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/proxy/package.json create mode 100644 apps/proxy/src/env.ts create mode 100644 apps/proxy/src/index.ts create mode 100644 apps/proxy/src/routes/health.ts create mode 100644 apps/proxy/src/routes/items.ts create mode 100644 apps/proxy/src/routes/search.ts create mode 100644 apps/proxy/tsconfig.json create mode 100644 package.json create mode 100644 packages/extract/package.json create mode 100644 packages/extract/src/download.ts create mode 100644 packages/extract/src/index.ts create mode 100644 packages/extract/src/objects.ts create mode 100644 packages/extract/src/refs.ts create mode 100644 packages/extract/src/traverse.ts create mode 100644 packages/extract/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/schemas.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/tts/package.json create mode 100644 packages/tts/src/errors.ts create mode 100644 packages/tts/src/index.ts create mode 100644 packages/tts/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..563b9be --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Steam Web API key (required for fetching item details / file_url). +# Get one at https://steamcommunity.com/dev/apikey +STEAM_API_KEY= + +# Port for the proxy server. +PORT=3000 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0663b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.env +.env.local \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..995aaa9 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# TTS Workshop + +Search the Tabletop Simulator Steam Workshop, fetch full TTS save files, and +analyze their contents. A lightweight, client-only pnpm monorepo. + +## Packages + +| Package | Role | Runtime | +| ------------------- | ------------------------------------------------------ | ---------- | +| `apps/proxy` | Hono HTTP server: Workshop search + save fetch | Node | +| `packages/tts` | Fetch save from Steam, BSON-parse to `TTSMod` | Node | +| `packages/extract` | Analyze a `TTSMod`: objects, asset refs, downloads | Isomorphic | +| `packages/shared` | Shared types + zod schemas | Isomorphic | + +See [`docs/architecture.md`](docs/architecture.md) for the architecture and +[`docs/implementation-plan.md`](docs/implementation-plan.md) for the plan. + +## Setup + +```sh +pnpm install +cp .env.example .env # then set STEAM_API_KEY +pnpm dev # runs the proxy at http://localhost:3000 +``` + +Get a Steam Web API key at https://steamcommunity.com/dev/apikey (free). + +## API + +| Method | Path | Description | +| ------ | ----------------- | -------------------------------------------- | +| GET | `/health` | Liveness | +| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) | +| GET | `/items/:id` | Full parsed `TTSMod` (BSON save) | +| GET | `/items/:id/file` | Raw save bytes, filename from header | + +## Commands + +```sh +pnpm dev # run the proxy (tsx watch) +pnpm build # compile all packages +pnpm typecheck # typecheck all packages +pnpm lint # lint all packages +``` + +## How search works + +Steam has no official search API. The proxy fetches the Workshop browse page +(`steamcommunity.com/workshop/browse/?appid=286160`) and parses the embedded +`window.SSR.renderContext` JSON (a React Query cache containing a +`workshop_browse` entry with the results). This is more robust than scraping +the DOM, but Steam can still change the page structure — if search breaks, that +parser is the first place to look. + +## Notes + +- `STEAM_API_KEY` is only needed for `/items/*` (resolving `file_url`). Search + works without it. +- Each request fetches fresh; there is no caching by design (client-only tool). +- `packages/extract` has zero runtime dependencies and runs in browser or Node, + ready for a future frontend. diff --git a/apps/proxy/package.json b/apps/proxy/package.json new file mode 100644 index 0000000..ea74655 --- /dev/null +++ b/apps/proxy/package.json @@ -0,0 +1,26 @@ +{ + "name": "@tts/proxy", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "echo \"no lint configured\"" + }, + "dependencies": { + "@hono/node-server": "^1.13.7", + "@tts/shared": "workspace:*", + "@tts/tts": "workspace:*", + "hono": "^4.6.14", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} \ No newline at end of file diff --git a/apps/proxy/src/env.ts b/apps/proxy/src/env.ts new file mode 100644 index 0000000..9c8ef32 --- /dev/null +++ b/apps/proxy/src/env.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + STEAM_API_KEY: z.string().min(1, 'STEAM_API_KEY is required'), + PORT: z.coerce.number().int().min(1).max(65535).default(3000), +}); + +export type Env = z.infer; + +/** Hono bindings type so `c.env` is typed in route handlers. */ +export interface Bindings { + STEAM_API_KEY: string; + PORT: number; +} + +export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { + const parsed = envSchema.safeParse(source); + if (!parsed.success) { + const issues = parsed.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('; '); + throw new Error(`Invalid environment: ${issues}`); + } + return parsed.data; +} \ No newline at end of file diff --git a/apps/proxy/src/index.ts b/apps/proxy/src/index.ts new file mode 100644 index 0000000..852b5b6 --- /dev/null +++ b/apps/proxy/src/index.ts @@ -0,0 +1,27 @@ +import { serve } from '@hono/node-server'; +import { cors } from 'hono/cors'; +import { Hono } from 'hono'; +import { loadEnv, type Bindings } from './env.js'; +import health from './routes/health.js'; +import items from './routes/items.js'; +import search from './routes/search.js'; + +const env = loadEnv(); + +const app = new Hono<{ Bindings: Bindings }>(); + +app.use('*', cors()); +app.route('/health', health); +app.route('/search', search); +app.route('/items', items); + +serve( + { + fetch: app.fetch, + port: env.PORT, + overrideGlobalObjects: true, + }, + (info) => { + console.log(`TTS proxy listening on http://localhost:${info.port}`); + }, +); \ No newline at end of file diff --git a/apps/proxy/src/routes/health.ts b/apps/proxy/src/routes/health.ts new file mode 100644 index 0000000..aec006b --- /dev/null +++ b/apps/proxy/src/routes/health.ts @@ -0,0 +1,7 @@ +import { Hono } from 'hono'; + +const app = new Hono(); + +app.get('/', (c) => c.json({ status: 'ok' })); + +export default app; \ No newline at end of file diff --git a/apps/proxy/src/routes/items.ts b/apps/proxy/src/routes/items.ts new file mode 100644 index 0000000..68c7900 --- /dev/null +++ b/apps/proxy/src/routes/items.ts @@ -0,0 +1,57 @@ +import { Hono } from 'hono'; +import { itemIdSchema, type TTSMod } from '@tts/shared'; +import { fetchMod, fetchModFile, TtsError } from '@tts/tts'; +import type { Bindings } from '../env.js'; + +const app = new Hono<{ Bindings: Bindings }>(); + +app.get('/:id', async (c) => { + const id = c.req.param('id'); + if (!itemIdSchema.safeParse(id).success) { + return c.json({ error: 'Item ID must be a number' }, 400); + } + + const apiKey = c.env.STEAM_API_KEY; + if (!apiKey) { + return c.json({ error: 'STEAM_API_KEY is not configured' }, 500); + } + + try { + const mod = await fetchMod(id, apiKey); + return c.json(mod); + } catch (err) { + if (err instanceof TtsError) { + return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502); + } + return c.json({ error: String(err) }, 500); + } +}); + +app.get('/:id/file', async (c) => { + const id = c.req.param('id'); + if (!itemIdSchema.safeParse(id).success) { + return c.json({ error: 'Item ID must be a number' }, 400); + } + + const apiKey = c.env.STEAM_API_KEY; + if (!apiKey) { + return c.json({ error: 'STEAM_API_KEY is not configured' }, 500); + } + + try { + const { data, filename } = await fetchModFile(id, apiKey); + return new Response(data, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } catch (err) { + if (err instanceof TtsError) { + return c.json({ error: err.message }, err.status as 400 | 404 | 500 | 502); + } + return c.json({ error: String(err) }, 500); + } +}); + +export default app; \ No newline at end of file diff --git a/apps/proxy/src/routes/search.ts b/apps/proxy/src/routes/search.ts new file mode 100644 index 0000000..a950fda --- /dev/null +++ b/apps/proxy/src/routes/search.ts @@ -0,0 +1,106 @@ +import { Hono } from 'hono'; +import { searchQuerySchema, type SearchResult } from '@tts/shared'; + +const BROWSE_URL = 'https://steamcommunity.com/workshop/browse/'; +const TTS_APP_ID = '286160'; + +const app = new Hono(); + +interface BrowseResult { + publishedfileid: string; + title: string; + preview_url: string; + file_url?: string; + tags?: { tag: string }[]; + time_created?: number; + time_updated?: number; +} + +interface BrowseData { + current_page: number; + total_pages: number; + total_count: number; + results: BrowseResult[]; + next_cursor: string; +} + +/** + * Steam embeds the Workshop browse results as a JSON blob inside + * `window.SSR.renderContext`. The renderContext is a JS-escaped JSON string + * whose `queryData` field is itself a JSON string containing React Query + * cache entries. We locate the `workshop_browse` query and read its results. + */ +function parseBrowseResults(html: string): BrowseData | null { + const scriptMatch = /]*>([\s\S]*?)<\/script>/g; + const ASSIGNMENT = 'window.SSR.renderContext=JSON.parse('; + let m: RegExpExecArray | null; + while ((m = scriptMatch.exec(html))) { + const script = m[1] ?? ''; + const start = script.indexOf(ASSIGNMENT); + if (start === -1) continue; + + const openQuote = start + ASSIGNMENT.length + 1; + const lastTerm = script.lastIndexOf('");'); + if (lastTerm === -1 || lastTerm <= openQuote) continue; + + const raw = script.slice(openQuote, lastTerm); + const jsonText = JSON.parse('"' + raw + '"'); + const renderContext = JSON.parse(jsonText); + const queryData = JSON.parse(renderContext.queryData); + const browse = queryData.queries.find((q: { queryKey: unknown[] }) => + JSON.stringify(q.queryKey).includes('workshop_browse'), + ); + if (browse?.state?.data?.results) { + return browse.state.data as BrowseData; + } + } + return null; +} + +app.get('/', async (c) => { + const parsed = searchQuerySchema.safeParse(c.req.query()); + if (!parsed.success) { + return c.json({ error: parsed.error.issues[0]?.message }, 400); + } + const { q, page } = parsed.data; + + const url = new URL(BROWSE_URL); + url.searchParams.set('appid', TTS_APP_ID); + url.searchParams.set('searchtext', q); + url.searchParams.set('pagenum', String(page)); + + let html: string; + try { + const res = await fetch(url); + if (!res.ok) { + return c.json({ error: `Steam browse page responded ${res.status}` }, 502); + } + html = await res.text(); + } catch (err) { + return c.json({ error: `Failed to fetch browse page: ${String(err)}` }, 502); + } + + const data = parseBrowseResults(html); + if (!data) { + return c.json({ error: 'Could not parse Workshop browse results' }, 502); + } + + const items: SearchResult['items'] = data.results.map((r) => ({ + id: r.publishedfileid, + title: r.title, + author: '', + previewImageUrl: r.preview_url, + fileUrl: r.file_url, + tags: r.tags?.map((t) => t.tag), + timeCreated: r.time_created, + timeUpdated: r.time_updated, + })); + + return c.json({ + items, + page: data.current_page, + hasMore: data.current_page < data.total_pages, + }); +}); + +export default app; \ No newline at end of file diff --git a/apps/proxy/tsconfig.json b/apps/proxy/tsconfig.json new file mode 100644 index 0000000..fa4e87c --- /dev/null +++ b/apps/proxy/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + "include": ["src"] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..99f7db3 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "tts-workshop", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "pnpm --filter @tts/proxy dev", + "build": "pnpm -r build", + "lint": "pnpm -r lint", + "typecheck": "pnpm -r typecheck" + }, + "packageManager": "pnpm@10.33.0", + "devDependencies": { + "typescript": "^5.7.2" + } +} \ No newline at end of file diff --git a/packages/extract/package.json b/packages/extract/package.json new file mode 100644 index 0000000..61fe790 --- /dev/null +++ b/packages/extract/package.json @@ -0,0 +1,25 @@ +{ + "name": "@tts/extract", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "echo \"no lint configured\"" + }, + "dependencies": { + "@tts/shared": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} \ No newline at end of file diff --git a/packages/extract/src/download.ts b/packages/extract/src/download.ts new file mode 100644 index 0000000..42b9e8b --- /dev/null +++ b/packages/extract/src/download.ts @@ -0,0 +1,81 @@ +import type { AssetRef } from '@tts/shared'; + +/** Guess a MIME type from a URL's file extension. */ +export function guessMimeType(url: string): string { + const ext = url.split('?')[0]!.split('.').pop()!.toLowerCase(); + switch (ext) { + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'gif': + return 'image/gif'; + case 'webp': + return 'image/webp'; + case 'pdf': + return 'application/pdf'; + default: + return 'application/octet-stream'; + } +} + +/** Download a single asset as a `Blob`. */ +export async function downloadAsset(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to download asset (${res.status}): ${url}`); + } + return res.blob(); +} + +export interface DownloadProgress { + done: number; + total: number; + url: string; +} + +export interface DownloadOptions { + concurrency?: number; + onProgress?: (p: DownloadProgress) => void; +} + +export interface DownloadedAsset { + ref: AssetRef; + blob: Blob; +} + +/** + * Download multiple assets with limited concurrency and a progress callback. + */ +export async function downloadAll( + refs: AssetRef[], + options: DownloadOptions = {}, +): Promise { + const concurrency = options.concurrency ?? 4; + const results: DownloadedAsset[] = []; + let done = 0; + + const worker = async (ref: AssetRef): Promise => { + const blob = await downloadAsset(ref.url); + results.push({ ref, blob }); + done += 1; + options.onProgress?.({ done, total: refs.length, url: ref.url }); + }; + + const queue = [...refs]; + const workers: Promise[] = []; + for (let i = 0; i < Math.min(concurrency, queue.length); i++) { + workers.push( + (async () => { + while (queue.length > 0) { + const ref = queue.shift()!; + await worker(ref); + } + })(), + ); + } + + await Promise.all(workers); + return results; +} \ No newline at end of file diff --git a/packages/extract/src/index.ts b/packages/extract/src/index.ts new file mode 100644 index 0000000..daa065e --- /dev/null +++ b/packages/extract/src/index.ts @@ -0,0 +1,4 @@ +export * from './traverse.js'; +export * from './objects.js'; +export * from './refs.js'; +export * from './download.js'; \ No newline at end of file diff --git a/packages/extract/src/objects.ts b/packages/extract/src/objects.ts new file mode 100644 index 0000000..b8a52cb --- /dev/null +++ b/packages/extract/src/objects.ts @@ -0,0 +1,32 @@ +import type { TTSMod, TTSObject } from '@tts/shared'; +import { markParent, traverseMod } from './traverse.js'; + +/** + * Flatten all objects in a save into an array. + * Ensures parent links are set before returning. + */ +export function flattenObjects(mod: TTSMod): TTSObject[] { + for (const state of mod.ObjectStates) { + markParent(state); + } + return [...traverseMod(mod)]; +} + +/** + * Filter objects by a predicate (name, GUID, type, etc.). + */ +export function filterObjects( + mod: TTSMod, + predicate: (o: TTSObject) => boolean, +): TTSObject[] { + return flattenObjects(mod).filter(predicate); +} + +/** + * Find a single object by GUID. + */ +export function findObject(mod: TTSMod, guid: string): TTSObject | undefined { + return flattenObjects(mod).find((o) => o.GUID === guid); +} + +export { markParent, traverseMod } from './traverse.js'; \ No newline at end of file diff --git a/packages/extract/src/refs.ts b/packages/extract/src/refs.ts new file mode 100644 index 0000000..ab4e34d --- /dev/null +++ b/packages/extract/src/refs.ts @@ -0,0 +1,60 @@ +import type { AssetRef, TTSMod, TTSObject } from '@tts/shared'; +import { markParent, traverseMod } from './traverse.js'; + +/** + * Extract every external asset reference from a single object. + */ +export function extractRefs(o: TTSObject): AssetRef[] { + const refs: AssetRef[] = []; + const ownerGuid = o.GUID; + + if (o.CustomPDF?.PDFUrl) { + refs.push({ kind: 'pdf', url: o.CustomPDF.PDFUrl, ownerGuid }); + } + + if (o.CustomDeck) { + for (const deck of Object.values(o.CustomDeck)) { + if (deck.FaceURL) { + refs.push({ kind: 'deckFace', url: deck.FaceURL, ownerGuid }); + } + if (deck.BackURL) { + refs.push({ kind: 'deckBack', url: deck.BackURL, ownerGuid }); + } + } + } + + if (o.CustomImage?.ImageURL) { + refs.push({ kind: 'image', url: o.CustomImage.ImageURL, ownerGuid }); + } + if (o.CustomImage?.ImageSecondaryURL) { + refs.push({ + kind: 'imageSecondary', + url: o.CustomImage.ImageSecondaryURL, + ownerGuid, + }); + } + + return refs; +} + +/** + * Collect all asset references across a whole save, deduped by URL. + */ +export function collectRefs(mod: TTSMod): AssetRef[] { + for (const state of mod.ObjectStates) { + markParent(state); + } + const seen = new Set(); + const refs: AssetRef[] = []; + for (const o of traverseMod(mod)) { + for (const ref of extractRefs(o)) { + if (!seen.has(ref.url)) { + seen.add(ref.url); + refs.push(ref); + } + } + } + return refs; +} + +export { markParent, traverseMod } from './traverse.js'; \ No newline at end of file diff --git a/packages/extract/src/traverse.ts b/packages/extract/src/traverse.ts new file mode 100644 index 0000000..9db14cc --- /dev/null +++ b/packages/extract/src/traverse.ts @@ -0,0 +1,34 @@ +import type { TTSMod, TTSObject } from '@tts/shared'; + +/** + * Recursively set each object's `Parent` to its container. + * Mutates the tree in place. + */ +export function markParent(o: TTSObject): void { + if (o.ContainedObjects) { + for (const each of o.ContainedObjects) { + each.Parent = o; + markParent(each); + } + } +} + +/** + * Yield every object in a save, descending into Bags and Custom_Model_Bags. + * Accepts either a full `TTSMod` or a single `TTSObject`. + */ +export function* traverseMod( + mod: TTSMod | TTSObject, +): Iterable { + if ('ObjectStates' in mod) { + for (const one of mod.ObjectStates) { + yield* traverseMod(one); + } + } else if (mod.Name === 'Bag' || mod.Name === 'Custom_Model_Bag') { + for (const one of mod.ContainedObjects ?? []) { + yield* traverseMod(one); + } + } else { + yield mod; + } +} \ No newline at end of file diff --git a/packages/extract/tsconfig.json b/packages/extract/tsconfig.json new file mode 100644 index 0000000..7830064 --- /dev/null +++ b/packages/extract/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} \ No newline at end of file diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..290c09a --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,25 @@ +{ + "name": "@tts/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "echo \"no lint configured\"" + }, + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..93d1f8d --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './schemas.js'; \ No newline at end of file diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts new file mode 100644 index 0000000..09e8bed --- /dev/null +++ b/packages/shared/src/schemas.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; + +/** Validate a Workshop item ID (a non-empty run of digits). */ +export const itemIdSchema = z + .string() + .regex(/^\d+$/, 'Item ID must be a number') + .min(1); + +/** Query params for `GET /search`. */ +export const searchQuerySchema = z.object({ + q: z.string().trim().min(1, 'Search query is required'), + page: z.coerce.number().int().min(1).default(1), +}); + +export const assetKindSchema = z.enum([ + 'pdf', + 'deckFace', + 'deckBack', + 'image', + 'imageSecondary', +]); + +export const assetRefSchema = z.object({ + kind: assetKindSchema, + url: z.string().url(), + ownerGuid: z.string(), +}); + +export const extractedObjectSchema = z.object({ + guid: z.string(), + name: z.string(), + type: z.string(), + parentGuid: z.string().optional(), + childrenGuids: z.array(z.string()), + refs: z.array(assetRefSchema), +}); + +export const workshopItemSchema = z.object({ + id: z.string(), + title: z.string(), + author: z.string(), + previewImageUrl: z.string(), + fileUrl: z.string().optional(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + timeCreated: z.number().optional(), + timeUpdated: z.number().optional(), +}); + +export const searchResultSchema = z.object({ + items: z.array(workshopItemSchema), + page: z.number(), + hasMore: z.boolean(), +}); \ No newline at end of file diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..4b23f16 --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,84 @@ +/** + * A single object inside a Tabletop Simulator save file. + * Mirrors the structure produced by BSON-deserializing a TTS save. + */ +export interface TTSObject { + Name: string; + GUID: string; + Description: string; + /** Set by `markParent` during traversal; not present in the raw save. */ + Parent?: TTSObject; + + CustomPDF?: { + PDFUrl: string; + }; + + ContainedObjects?: TTSObject[]; + + CardID?: number; + CustomDeck?: { + [key: number]: { + BackURL: string; + FaceURL: string; + UniqueBack: boolean; + NumHeight: number; + NumWidth: number; + }; + }; + CustomImage?: { + ImageURL: string; + ImageSecondaryURL: string; + }; +} + +/** A full Tabletop Simulator save file. */ +export interface TTSMod { + GameMode: string; + Date: string; + ObjectStates: TTSObject[]; +} + +/** Metadata for a Workshop item, from the Steam Web API. */ +export interface WorkshopItem { + id: string; + title: string; + author: string; + previewImageUrl: string; + fileUrl?: string; + description?: string; + tags?: string[]; + timeCreated?: number; + timeUpdated?: number; +} + +/** A page of Workshop search results. */ +export interface SearchResult { + items: WorkshopItem[]; + page: number; + hasMore: boolean; +} + +/** A reference to an external asset embedded in a TTS object. */ +export type AssetKind = + | 'pdf' + | 'deckFace' + | 'deckBack' + | 'image' + | 'imageSecondary'; + +export interface AssetRef { + kind: AssetKind; + url: string; + /** GUID of the object that owns this reference. */ + ownerGuid: string; +} + +/** A flattened, lightweight view of an object for inspection/rendering. */ +export interface ExtractedObject { + guid: string; + name: string; + type: string; + parentGuid?: string; + childrenGuids: string[]; + refs: AssetRef[]; +} \ No newline at end of file diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..551a396 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} \ No newline at end of file diff --git a/packages/tts/package.json b/packages/tts/package.json new file mode 100644 index 0000000..cb4923c --- /dev/null +++ b/packages/tts/package.json @@ -0,0 +1,26 @@ +{ + "name": "@tts/tts", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "echo \"no lint configured\"" + }, + "dependencies": { + "@tts/shared": "workspace:*", + "bson": "^6.10.1" + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} \ No newline at end of file diff --git a/packages/tts/src/errors.ts b/packages/tts/src/errors.ts new file mode 100644 index 0000000..dcfe985 --- /dev/null +++ b/packages/tts/src/errors.ts @@ -0,0 +1,35 @@ +/** Errors thrown by the TTS fetcher. */ + +export class TtsError extends Error { + constructor( + message: string, + readonly status: number = 500, + ) { + super(message); + this.name = 'TtsError'; + } +} + +/** The Steam API returned no details for the requested item. */ +export class ItemNotFoundError extends TtsError { + constructor(id: string) { + super(`No Workshop item found for id ${id}`, 404); + this.name = 'ItemNotFoundError'; + } +} + +/** The item exists but has no downloadable `file_url`. */ +export class NoFileError extends TtsError { + constructor(id: string) { + super(`Workshop item ${id} has no downloadable save file`, 404); + this.name = 'NoFileError'; + } +} + +/** The Steam API rejected the request (bad key, rate limit, etc.). */ +export class SteamApiError extends TtsError { + constructor(message: string, status = 502) { + super(message, status); + this.name = 'SteamApiError'; + } +} \ No newline at end of file diff --git a/packages/tts/src/index.ts b/packages/tts/src/index.ts new file mode 100644 index 0000000..11b8f4e --- /dev/null +++ b/packages/tts/src/index.ts @@ -0,0 +1,113 @@ +import { deserialize } from 'bson'; +import type { TTSMod } from '@tts/shared'; +import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js'; + +const STEAM_API_URL = + 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/'; + +interface SteamPublishedFileDetails { + publishedfileid: string; + title: string; + creator: string; + preview_url: string; + file_url?: string; + description?: string; + tags?: { tag: string }[]; + time_created?: number; + time_updated?: number; +} + +interface SteamResponse { + response: { + result: number; + resultcount: number; + publishedfiledetails?: SteamPublishedFileDetails[]; + }; +} + +/** + * Fetch a full TTS save for a Workshop item and BSON-deserialize it. + * + * @param id Workshop item ID (digits only). + * @param apiKey Steam Web API key. + */ +export async function fetchMod(id: string, apiKey: string): Promise { + const fileUrl = await getFileUrl(id, apiKey); + + const saveRes = await fetch(fileUrl); + if (!saveRes.ok) { + throw new SteamApiError( + `Failed to download save file (${saveRes.status})`, + 502, + ); + } + + const buffer = await saveRes.arrayBuffer(); + return deserialize(new Uint8Array(buffer)) as TTSMod; +} + +/** + * Derive a filename from a `content-disposition` header. + * Parses `filename="..."`; falls back to the URL path, then a default. + */ +export function getFileName(url: string, disposition: string | null): string { + if (disposition) { + const match = disposition.match(/filename\*?=(?:"([^"]*)"|([^;\s]*))/i); + const name = match?.[1] ?? match?.[2]; + if (name) { + return name; + } + } + return new URL(url).pathname.split('/').pop() || 'save.json'; +} + +/** + * Fetch the raw save file bytes for a Workshop item. + * Returns the bytes plus a derived filename. + */ +export async function fetchModFile( + id: string, + apiKey: string, +): Promise<{ data: ArrayBuffer; filename: string }> { + const fileUrl = await getFileUrl(id, apiKey); + + const res = await fetch(fileUrl); + if (!res.ok) { + throw new SteamApiError( + `Failed to download save file (${res.status})`, + 502, + ); + } + + const data = await res.arrayBuffer(); + const filename = getFileName(fileUrl, res.headers.get('content-disposition')); + return { data, filename }; +} + +/** Resolve the `file_url` for a Workshop item via the Steam API. */ +async function getFileUrl(id: string, apiKey: string): Promise { + const params = new URLSearchParams(); + params.append('key', apiKey); + params.append('itemcount', '1'); + params.append('publishedfileids[0]', id); + + const res = await fetch(STEAM_API_URL, { + method: 'POST', + body: params, + }); + if (!res.ok) { + throw new SteamApiError(`Steam API responded ${res.status}`, 502); + } + + const json = (await res.json()) as SteamResponse; + const details = json.response.publishedfiledetails?.[0]; + if (!details) { + throw new ItemNotFoundError(id); + } + if (!details.file_url) { + throw new NoFileError(id); + } + return details.file_url; +} + +export * from './errors.js'; \ No newline at end of file diff --git a/packages/tts/tsconfig.json b/packages/tts/tsconfig.json new file mode 100644 index 0000000..7830064 --- /dev/null +++ b/packages/tts/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7a0e816 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,411 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.9.3 + + apps/proxy: + dependencies: + '@hono/node-server': + specifier: ^1.13.7 + version: 1.19.17(hono@4.13.1) + '@tts/shared': + specifier: workspace:* + version: link:../../packages/shared + '@tts/tts': + specifier: workspace:* + version: link:../../packages/tts + hono: + specifier: ^4.6.14 + version: 4.13.1 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.20.1 + tsx: + specifier: ^4.19.2 + version: 4.23.11 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + + packages/extract: + dependencies: + '@tts/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.9.3 + + packages/shared: + dependencies: + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.9.3 + + packages/tts: + dependencies: + '@tts/shared': + specifier: workspace:* + version: link:../shared + bson: + specifier: ^6.10.1 + version: 6.10.4 + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + bson@6.10.4: + resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} + engines: {node: '>=16.20.1'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hono@4.13.1: + resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==} + engines: {node: '>=16.9.0'} + + tsx@4.23.11: + resolution: {integrity: sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@hono/node-server@1.19.17(hono@4.13.1)': + dependencies: + hono: 4.13.1 + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + bson@6.10.4: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + fsevents@2.3.3: + optional: true + + hono@4.13.1: {} + + tsx@4.23.11: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..c53e539 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'apps/*' + - 'packages/*' \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..cf745c4 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "declaration": true, + "sourceMap": true + } +} \ No newline at end of file