From f7a6eb6ee1f49f76daaeb8e4ca1d0bd60dc4fe4b Mon Sep 17 00:00:00 2001
From: hypercross
Date: Sat, 8 Aug 2026 11:32:57 +0800
Subject: [PATCH] feat: allow loading mods without a Steam API key
Add fetchModFromUrl/fetchModFileFromUrl and accept a fileUrl query param on /items routes so the frontend can download saves directly from a search result's file_url, with no key required.
---
README.md | 8 +++--
apps/proxy/src/routes/items.test.ts | 47 +++++++++++++++++++++++++++--
apps/proxy/src/routes/items.ts | 30 ++++++++++--------
apps/web/src/api.ts | 14 ++++++---
apps/web/src/pages/ModPage.tsx | 8 +++--
apps/web/src/stores/modStore.ts | 12 +++++---
docs/implementation-plan.md | 22 +++++++++++---
packages/tts/src/index.test.ts | 38 ++++++++++++++++++++++-
packages/tts/src/index.ts | 39 ++++++++++++++++--------
9 files changed, 170 insertions(+), 48 deletions(-)
diff --git a/README.md b/README.md
index f6fbd53..9eb4ff5 100644
--- a/README.md
+++ b/README.md
@@ -61,8 +61,12 @@ parser is the first place to look.
## Notes
-- `STEAM_API_KEY` is only needed for `/items/*` (resolving `file_url`). Search
- works without it.
+- `STEAM_API_KEY` is optional. Search works without it. The mod page works
+ without it too when you arrive from a search result: the frontend passes the
+ item's `file_url` (already present in search results) to `/items/:id` via a
+ `fileUrl` query param, so the proxy downloads the save directly instead of
+ calling the Steam API. Without a key *and* without a `fileUrl` (e.g. a
+ deep-linked mod page), `/items/*` returns 500.
- 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/src/routes/items.test.ts b/apps/proxy/src/routes/items.test.ts
index e072208..3e8a763 100644
--- a/apps/proxy/src/routes/items.test.ts
+++ b/apps/proxy/src/routes/items.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { ItemNotFoundError, SteamApiError } from '@tts/tts';
+import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts';
import type { TTSMod } from '@tts/shared';
import items from './items.js';
@@ -16,12 +16,34 @@ describe('items route', () => {
expect(await res.json()).toEqual({ error: 'Item ID must be a number' });
});
- it('returns 500 when the API key is missing', async () => {
+ it('returns 500 when the API key is missing and no fileUrl is given', async () => {
+ vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue(
+ new TtsError('STEAM_API_KEY is not configured', 500),
+ );
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: 'STEAM_API_KEY is not configured' });
});
+ it('loads from a fileUrl without an API key', async () => {
+ const mod: TTSMod = {
+ GameMode: 'Tabletop',
+ Date: '2024-01-01',
+ ObjectStates: [],
+ };
+ const fetchModFromUrl = vi
+ .spyOn(await import('@tts/tts'), 'fetchModFromUrl')
+ .mockResolvedValue(mod);
+ const res = await items.request(
+ '/123?fileUrl=https%3A%2F%2Fexample.com%2Fsave.json',
+ {},
+ { STEAM_API_KEY: '', PORT: 3000 },
+ );
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual(mod);
+ expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json');
+ });
+
it('returns the parsed mod on success', async () => {
const mod: TTSMod = {
GameMode: 'Tabletop',
@@ -75,6 +97,27 @@ describe('items file route', () => {
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
});
+ it('serves the raw file from a fileUrl without an API key', async () => {
+ const fetchModFileFromUrl = vi
+ .spyOn(await import('@tts/tts'), 'fetchModFileFromUrl')
+ .mockResolvedValue({
+ data: new Uint8Array([1, 2, 3]).buffer,
+ filename: 'mod.json',
+ });
+ const res = await items.request(
+ '/123/file?fileUrl=https%3A%2F%2Fexample.com%2Fsave.json',
+ {},
+ { STEAM_API_KEY: '', PORT: 3000 },
+ );
+ expect(res.status).toBe(200);
+ expect(res.headers.get('content-disposition')).toBe(
+ 'attachment; filename="mod.json"',
+ );
+ expect(fetchModFileFromUrl).toHaveBeenCalledWith(
+ 'https://example.com/save.json',
+ );
+ });
+
it('maps SteamApiError to 502', async () => {
vi.spyOn(await import('@tts/tts'), 'fetchModFile').mockRejectedValue(
new SteamApiError('Steam API responded 500'),
diff --git a/apps/proxy/src/routes/items.ts b/apps/proxy/src/routes/items.ts
index 68c7900..cb2ca93 100644
--- a/apps/proxy/src/routes/items.ts
+++ b/apps/proxy/src/routes/items.ts
@@ -1,6 +1,12 @@
import { Hono } from 'hono';
import { itemIdSchema, type TTSMod } from '@tts/shared';
-import { fetchMod, fetchModFile, TtsError } from '@tts/tts';
+import {
+ fetchMod,
+ fetchModFile,
+ fetchModFileFromUrl,
+ fetchModFromUrl,
+ TtsError,
+} from '@tts/tts';
import type { Bindings } from '../env.js';
const app = new Hono<{ Bindings: Bindings }>();
@@ -11,13 +17,13 @@ app.get('/:id', async (c) => {
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);
+ const fileUrl = c.req.query('fileUrl');
+ // With no Steam API key, the caller must supply the save URL (e.g. from a
+ // search result). Otherwise resolve it via the Steam API.
+ const mod: TTSMod = fileUrl
+ ? await fetchModFromUrl(fileUrl)
+ : await fetchMod(id, c.env.STEAM_API_KEY ?? '');
return c.json(mod);
} catch (err) {
if (err instanceof TtsError) {
@@ -33,13 +39,11 @@ app.get('/:id/file', async (c) => {
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);
+ const fileUrl = c.req.query('fileUrl');
+ const { data, filename } = fileUrl
+ ? await fetchModFileFromUrl(fileUrl)
+ : await fetchModFile(id, c.env.STEAM_API_KEY ?? '');
return new Response(data, {
headers: {
'Content-Type': 'application/octet-stream',
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts
index 7963c76..0e93955 100644
--- a/apps/web/src/api.ts
+++ b/apps/web/src/api.ts
@@ -18,11 +18,17 @@ export function searchWorkshop(q: string, page = 1): Promise {
}
/** Fetch a full parsed TTS save. */
-export function fetchMod(id: string): Promise {
- return getJson(`/items/${id}`);
+export function fetchMod(id: string, fileUrl?: string): Promise {
+ const params = new URLSearchParams();
+ if (fileUrl) params.set('fileUrl', fileUrl);
+ const qs = params.toString();
+ return getJson(`/items/${id}${qs ? `?${qs}` : ''}`);
}
/** Build a URL for the raw save file download. */
-export function modFileUrl(id: string): string {
- return `${BASE}/items/${id}/file`;
+export function modFileUrl(id: string, fileUrl?: string): string {
+ const params = new URLSearchParams();
+ if (fileUrl) params.set('fileUrl', fileUrl);
+ const qs = params.toString();
+ return `/items/${id}/file${qs ? `?${qs}` : ''}`;
}
\ No newline at end of file
diff --git a/apps/web/src/pages/ModPage.tsx b/apps/web/src/pages/ModPage.tsx
index fc87b4d..9e74fd5 100644
--- a/apps/web/src/pages/ModPage.tsx
+++ b/apps/web/src/pages/ModPage.tsx
@@ -2,15 +2,17 @@ import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { collectRefs, flattenObjects } from '@tts/extract';
import { useModStore } from '../stores/modStore';
+import { useSearchStore } from '../stores/searchStore';
import { modFileUrl } from '../api';
export default function ModPage() {
const { id } = useParams<{ id: string }>();
const { mod, loading, error, load } = useModStore();
+ const item = useSearchStore((s) => s.items.find((i) => i.id === id));
useEffect(() => {
- if (id) load(id);
- }, [id, load]);
+ if (id) load(id, item?.fileUrl);
+ }, [id, item?.fileUrl, load]);
if (loading) return Loading mod…
;
if (error) return {error}
;
@@ -28,7 +30,7 @@ export default function ModPage() {
asset refs
Download save file
diff --git a/apps/web/src/stores/modStore.ts b/apps/web/src/stores/modStore.ts
index 1fdabd2..17285ea 100644
--- a/apps/web/src/stores/modStore.ts
+++ b/apps/web/src/stores/modStore.ts
@@ -4,26 +4,28 @@ import { fetchMod } from '../api';
interface ModState {
id: string | null;
+ fileUrl?: string;
mod: TTSMod | null;
loading: boolean;
error: string | null;
- load: (id: string) => Promise;
+ load: (id: string, fileUrl?: string) => Promise;
clear: () => void;
}
export const useModStore = create((set) => ({
id: null,
+ fileUrl: undefined,
mod: null,
loading: false,
error: null,
- load: async (id) => {
- set({ loading: true, error: null, id });
+ load: async (id, fileUrl) => {
+ set({ loading: true, error: null, id, fileUrl });
try {
- const mod = await fetchMod(id);
+ const mod = await fetchMod(id, fileUrl);
set({ mod, loading: false });
} catch (err) {
set({ loading: false, error: String(err) });
}
},
- clear: () => set({ id: null, mod: null, error: null, loading: false }),
+ clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }),
}));
\ No newline at end of file
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
index 00235ad..43dd890 100644
--- a/docs/implementation-plan.md
+++ b/docs/implementation-plan.md
@@ -124,6 +124,10 @@ Low-level fetcher, extracted from the existing scraper.
- `fetchMod(id: string): Promise` — Steam API call to
`ISteamRemoteStorage/GetPublishedFileDetails/v1` to get `file_url`, then
download + BSON-deserialize into `TTSMod`.
+ - `fetchModFromUrl(fileUrl)` — download + BSON-deserialize from a direct URL
+ (no Steam API call).
+ - `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save
+ bytes + derived filename, with and without the Steam API.
- `getFileName(url: string): Promise` — derive filename from the
`content-disposition` header.
- `errors.ts`
@@ -171,8 +175,11 @@ Hono server exposing search + fetch.
`cheerio`, extract `{ id, title, author, previewImageUrl }` from the result
grid. Supports `pagenum` pagination. No API key required.
- `routes/items.ts`
- - `GET /items/:id` — full parsed `TTSMod`.
- - `GET /items/:id/file` — raw save bytes, filename from `getFileName`.
+ - `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
+ query param to download the save directly (no Steam API key needed);
+ otherwise resolves via the Steam API.
+ - `GET /items/:id/file` — raw save bytes, filename from `getFileName`. Also
+ accepts `fileUrl`.
- `routes/health.ts`
- `GET /health` — liveness.
- `env.ts` — zod validation of `STEAM_API_KEY`, `PORT`.
@@ -204,15 +211,20 @@ proxy API and `packages/extract` directly for analysis.
| ------ | ------------------- | -------------------------------------------- | ---- |
| GET | `/health` | Liveness | — |
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
-| GET | `/items/:id` | Full parsed `TTSMod` | key |
-| GET | `/items/:id/file` | Raw save bytes, filename from header | key |
+| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* |
+| GET | `/items/:id/file` | Raw save bytes, filename from header | key* |
+
+\* `STEAM_API_KEY` is optional; `/items/*` works without it when a `fileUrl`
+query param is supplied.
## Data flow
```
-/search?q=wingspan → scrape browse page → list of {id, title, ...}
+/search?q=wingspan → scrape browse page → list of {id, title, fileUrl, ...}
↓
/items/:id → Steam API (needs key) → file_url
+ ↓
+ └─ /items/:id?fileUrl=... → download directly (no key)
↓
download BSON → parse → TTSMod
↓
diff --git a/packages/tts/src/index.test.ts b/packages/tts/src/index.test.ts
index 2b5163e..02fe049 100644
--- a/packages/tts/src/index.test.ts
+++ b/packages/tts/src/index.test.ts
@@ -1,11 +1,47 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { getFileName } from './index.js';
+import { fetchModFileFromUrl, fetchModFromUrl, getFileName } from './index.js';
import { ItemNotFoundError, NoFileError, SteamApiError, TtsError } from './errors.js';
afterEach(() => {
vi.unstubAllGlobals();
});
+// A minimal valid BSON document: { GameMode: 'Tabletop' }.
+const bsonBytes = new Uint8Array([
+ 28, 0, 0, 0, 2, 71, 97, 109, 101, 77, 111, 100, 101, 0, 9, 0, 0, 0, 84, 97,
+ 98, 108, 101, 116, 111, 112, 0, 0,
+]);
+
+function bsonResponse(): Response {
+ return new Response(bsonBytes);
+}
+
+describe('fetchModFromUrl', () => {
+ it('downloads and deserializes a save from a URL', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bsonResponse()));
+ const mod = await fetchModFromUrl('https://example.com/save.json');
+ expect(mod.GameMode).toBe('Tabletop');
+ });
+
+ it('throws on a failed download', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 })));
+ await expect(fetchModFromUrl('https://example.com/save.json')).rejects.toThrow(
+ 'Failed to download save file (404)',
+ );
+ });
+});
+
+describe('fetchModFileFromUrl', () => {
+ it('returns bytes and a filename derived from the URL', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bsonResponse()));
+ const { data, filename } = await fetchModFileFromUrl(
+ 'https://example.com/files/mod.json',
+ );
+ expect(new Uint8Array(data)).toEqual(bsonBytes);
+ expect(filename).toBe('mod.json');
+ });
+});
+
describe('getFileName', () => {
it('parses a quoted content-disposition filename', () => {
expect(
diff --git a/packages/tts/src/index.ts b/packages/tts/src/index.ts
index 11b8f4e..fb6b194 100644
--- a/packages/tts/src/index.ts
+++ b/packages/tts/src/index.ts
@@ -33,16 +33,16 @@ interface SteamResponse {
*/
export async function fetchMod(id: string, apiKey: string): Promise {
const fileUrl = await getFileUrl(id, apiKey);
+ return fetchModFromUrl(fileUrl);
+}
- const saveRes = await fetch(fileUrl);
- if (!saveRes.ok) {
- throw new SteamApiError(
- `Failed to download save file (${saveRes.status})`,
- 502,
- );
- }
-
- const buffer = await saveRes.arrayBuffer();
+/**
+ * Download a TTS save from a direct URL and BSON-deserialize it.
+ *
+ * @param fileUrl Direct URL to the save file (e.g. from a search result).
+ */
+export async function fetchModFromUrl(fileUrl: string): Promise {
+ const buffer = await downloadSave(fileUrl);
return deserialize(new Uint8Array(buffer)) as TTSMod;
}
@@ -70,7 +70,23 @@ export async function fetchModFile(
apiKey: string,
): Promise<{ data: ArrayBuffer; filename: string }> {
const fileUrl = await getFileUrl(id, apiKey);
+ return fetchModFileFromUrl(fileUrl);
+}
+/**
+ * Fetch the raw save file bytes from a direct URL.
+ * Returns the bytes plus a derived filename.
+ */
+export async function fetchModFileFromUrl(
+ fileUrl: string,
+): Promise<{ data: ArrayBuffer; filename: string }> {
+ const data = await downloadSave(fileUrl);
+ const filename = getFileName(fileUrl, null);
+ return { data, filename };
+}
+
+/** Download a save file's bytes from a URL, throwing on failure. */
+async function downloadSave(fileUrl: string): Promise {
const res = await fetch(fileUrl);
if (!res.ok) {
throw new SteamApiError(
@@ -78,10 +94,7 @@ export async function fetchModFile(
502,
);
}
-
- const data = await res.arrayBuffer();
- const filename = getFileName(fileUrl, res.headers.get('content-disposition'));
- return { data, filename };
+ return res.arrayBuffer();
}
/** Resolve the `file_url` for a Workshop item via the Steam API. */