feat: scaffold workspace with search, fetch, and extract packages
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof envSchema>;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get('/', (c) => c.json({ status: 'ok' }));
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -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<TTSMod>(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;
|
||||||
@@ -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 = /<script[^>]*>([\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<SearchResult>({
|
||||||
|
items,
|
||||||
|
page: data.current_page,
|
||||||
|
hasMore: data.current_page < data.total_pages,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Blob> {
|
||||||
|
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<DownloadedAsset[]> {
|
||||||
|
const concurrency = options.concurrency ?? 4;
|
||||||
|
const results: DownloadedAsset[] = [];
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
const worker = async (ref: AssetRef): Promise<void> => {
|
||||||
|
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<void>[] = [];
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './traverse.js';
|
||||||
|
export * from './objects.js';
|
||||||
|
export * from './refs.js';
|
||||||
|
export * from './download.js';
|
||||||
@@ -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';
|
||||||
@@ -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<string>();
|
||||||
|
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';
|
||||||
@@ -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<TTSObject> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types.js';
|
||||||
|
export * from './schemas.js';
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TTSMod> {
|
||||||
|
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<string> {
|
||||||
|
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';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+411
@@ -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: {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- 'apps/*'
|
||||||
|
- 'packages/*'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user