feat: scaffold workspace with search, fetch, and extract packages

This commit is contained in:
2026-08-08 10:43:00 +08:00
parent 405602a943
commit 1de559c321
30 changed files with 1381 additions and 0 deletions
+26
View File
@@ -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"
}
}
+25
View File
@@ -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;
}
+27
View File
@@ -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}`);
},
);
+7
View File
@@ -0,0 +1,7 @@
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.json({ status: 'ok' }));
export default app;
+57
View File
@@ -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;
+106
View File
@@ -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;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src"]
}