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.
This commit is contained in:
@@ -61,8 +61,12 @@ parser is the first place to look.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `STEAM_API_KEY` is only needed for `/items/*` (resolving `file_url`). Search
|
- `STEAM_API_KEY` is optional. Search works without it. The mod page works
|
||||||
works without it.
|
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).
|
- 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,
|
- `packages/extract` has zero runtime dependencies and runs in browser or Node,
|
||||||
ready for a future frontend.
|
ready for a future frontend.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 type { TTSMod } from '@tts/shared';
|
||||||
import items from './items.js';
|
import items from './items.js';
|
||||||
|
|
||||||
@@ -16,12 +16,34 @@ describe('items route', () => {
|
|||||||
expect(await res.json()).toEqual({ error: 'Item ID must be a number' });
|
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 });
|
const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 });
|
||||||
expect(res.status).toBe(500);
|
expect(res.status).toBe(500);
|
||||||
expect(await res.json()).toEqual({ error: 'STEAM_API_KEY is not configured' });
|
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 () => {
|
it('returns the parsed mod on success', async () => {
|
||||||
const mod: TTSMod = {
|
const mod: TTSMod = {
|
||||||
GameMode: 'Tabletop',
|
GameMode: 'Tabletop',
|
||||||
@@ -75,6 +97,27 @@ describe('items file route', () => {
|
|||||||
expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer);
|
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 () => {
|
it('maps SteamApiError to 502', async () => {
|
||||||
vi.spyOn(await import('@tts/tts'), 'fetchModFile').mockRejectedValue(
|
vi.spyOn(await import('@tts/tts'), 'fetchModFile').mockRejectedValue(
|
||||||
new SteamApiError('Steam API responded 500'),
|
new SteamApiError('Steam API responded 500'),
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { itemIdSchema, type TTSMod } from '@tts/shared';
|
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';
|
import type { Bindings } from '../env.js';
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Bindings }>();
|
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);
|
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 {
|
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<TTSMod>(mod);
|
return c.json<TTSMod>(mod);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TtsError) {
|
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);
|
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 {
|
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, {
|
return new Response(data, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
|
|||||||
+10
-4
@@ -18,11 +18,17 @@ export function searchWorkshop(q: string, page = 1): Promise<SearchResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch a full parsed TTS save. */
|
/** Fetch a full parsed TTS save. */
|
||||||
export function fetchMod(id: string): Promise<TTSMod> {
|
export function fetchMod(id: string, fileUrl?: string): Promise<TTSMod> {
|
||||||
return getJson<TTSMod>(`/items/${id}`);
|
const params = new URLSearchParams();
|
||||||
|
if (fileUrl) params.set('fileUrl', fileUrl);
|
||||||
|
const qs = params.toString();
|
||||||
|
return getJson<TTSMod>(`/items/${id}${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a URL for the raw save file download. */
|
/** Build a URL for the raw save file download. */
|
||||||
export function modFileUrl(id: string): string {
|
export function modFileUrl(id: string, fileUrl?: string): string {
|
||||||
return `${BASE}/items/${id}/file`;
|
const params = new URLSearchParams();
|
||||||
|
if (fileUrl) params.set('fileUrl', fileUrl);
|
||||||
|
const qs = params.toString();
|
||||||
|
return `/items/${id}/file${qs ? `?${qs}` : ''}`;
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,17 @@ import { useEffect } from 'react';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { collectRefs, flattenObjects } from '@tts/extract';
|
import { collectRefs, flattenObjects } from '@tts/extract';
|
||||||
import { useModStore } from '../stores/modStore';
|
import { useModStore } from '../stores/modStore';
|
||||||
|
import { useSearchStore } from '../stores/searchStore';
|
||||||
import { modFileUrl } from '../api';
|
import { modFileUrl } from '../api';
|
||||||
|
|
||||||
export default function ModPage() {
|
export default function ModPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { mod, loading, error, load } = useModStore();
|
const { mod, loading, error, load } = useModStore();
|
||||||
|
const item = useSearchStore((s) => s.items.find((i) => i.id === id));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) load(id);
|
if (id) load(id, item?.fileUrl);
|
||||||
}, [id, load]);
|
}, [id, item?.fileUrl, load]);
|
||||||
|
|
||||||
if (loading) return <p className="text-sm text-zinc-400">Loading mod…</p>;
|
if (loading) return <p className="text-sm text-zinc-400">Loading mod…</p>;
|
||||||
if (error) return <p className="text-sm text-red-400">{error}</p>;
|
if (error) return <p className="text-sm text-red-400">{error}</p>;
|
||||||
@@ -28,7 +30,7 @@ export default function ModPage() {
|
|||||||
asset refs
|
asset refs
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={modFileUrl(id!)}
|
href={modFileUrl(id!, item?.fileUrl)}
|
||||||
className="mt-3 inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
|
className="mt-3 inline-block rounded-lg bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-zinc-300"
|
||||||
>
|
>
|
||||||
Download save file
|
Download save file
|
||||||
|
|||||||
@@ -4,26 +4,28 @@ import { fetchMod } from '../api';
|
|||||||
|
|
||||||
interface ModState {
|
interface ModState {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
|
fileUrl?: string;
|
||||||
mod: TTSMod | null;
|
mod: TTSMod | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
load: (id: string) => Promise<void>;
|
load: (id: string, fileUrl?: string) => Promise<void>;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useModStore = create<ModState>((set) => ({
|
export const useModStore = create<ModState>((set) => ({
|
||||||
id: null,
|
id: null,
|
||||||
|
fileUrl: undefined,
|
||||||
mod: null,
|
mod: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
load: async (id) => {
|
load: async (id, fileUrl) => {
|
||||||
set({ loading: true, error: null, id });
|
set({ loading: true, error: null, id, fileUrl });
|
||||||
try {
|
try {
|
||||||
const mod = await fetchMod(id);
|
const mod = await fetchMod(id, fileUrl);
|
||||||
set({ mod, loading: false });
|
set({ mod, loading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ loading: false, error: String(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 }),
|
||||||
}));
|
}));
|
||||||
@@ -124,6 +124,10 @@ Low-level fetcher, extracted from the existing scraper.
|
|||||||
- `fetchMod(id: string): Promise<TTSMod>` — Steam API call to
|
- `fetchMod(id: string): Promise<TTSMod>` — Steam API call to
|
||||||
`ISteamRemoteStorage/GetPublishedFileDetails/v1` to get `file_url`, then
|
`ISteamRemoteStorage/GetPublishedFileDetails/v1` to get `file_url`, then
|
||||||
download + BSON-deserialize into `TTSMod`.
|
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<string>` — derive filename from the
|
- `getFileName(url: string): Promise<string>` — derive filename from the
|
||||||
`content-disposition` header.
|
`content-disposition` header.
|
||||||
- `errors.ts`
|
- `errors.ts`
|
||||||
@@ -171,8 +175,11 @@ Hono server exposing search + fetch.
|
|||||||
`cheerio`, extract `{ id, title, author, previewImageUrl }` from the result
|
`cheerio`, extract `{ id, title, author, previewImageUrl }` from the result
|
||||||
grid. Supports `pagenum` pagination. No API key required.
|
grid. Supports `pagenum` pagination. No API key required.
|
||||||
- `routes/items.ts`
|
- `routes/items.ts`
|
||||||
- `GET /items/:id` — full parsed `TTSMod`.
|
- `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
|
||||||
- `GET /items/:id/file` — raw save bytes, filename from `getFileName`.
|
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`
|
- `routes/health.ts`
|
||||||
- `GET /health` — liveness.
|
- `GET /health` — liveness.
|
||||||
- `env.ts` — zod validation of `STEAM_API_KEY`, `PORT`.
|
- `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 | `/health` | Liveness | — |
|
||||||
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
|
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
|
||||||
| GET | `/items/:id` | Full parsed `TTSMod` | key |
|
| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* |
|
||||||
| GET | `/items/:id/file` | Raw save bytes, filename from header | 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
|
## 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 → Steam API (needs key) → file_url
|
||||||
|
↓
|
||||||
|
└─ /items/:id?fileUrl=... → download directly (no key)
|
||||||
↓
|
↓
|
||||||
download BSON → parse → TTSMod
|
download BSON → parse → TTSMod
|
||||||
↓
|
↓
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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';
|
import { ItemNotFoundError, NoFileError, SteamApiError, TtsError } from './errors.js';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
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', () => {
|
describe('getFileName', () => {
|
||||||
it('parses a quoted content-disposition filename', () => {
|
it('parses a quoted content-disposition filename', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
+26
-13
@@ -33,16 +33,16 @@ interface SteamResponse {
|
|||||||
*/
|
*/
|
||||||
export async function fetchMod(id: string, apiKey: string): Promise<TTSMod> {
|
export async function fetchMod(id: string, apiKey: string): Promise<TTSMod> {
|
||||||
const fileUrl = await getFileUrl(id, apiKey);
|
const fileUrl = await getFileUrl(id, apiKey);
|
||||||
|
return fetchModFromUrl(fileUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const saveRes = await fetch(fileUrl);
|
/**
|
||||||
if (!saveRes.ok) {
|
* Download a TTS save from a direct URL and BSON-deserialize it.
|
||||||
throw new SteamApiError(
|
*
|
||||||
`Failed to download save file (${saveRes.status})`,
|
* @param fileUrl Direct URL to the save file (e.g. from a search result).
|
||||||
502,
|
*/
|
||||||
);
|
export async function fetchModFromUrl(fileUrl: string): Promise<TTSMod> {
|
||||||
}
|
const buffer = await downloadSave(fileUrl);
|
||||||
|
|
||||||
const buffer = await saveRes.arrayBuffer();
|
|
||||||
return deserialize(new Uint8Array(buffer)) as TTSMod;
|
return deserialize(new Uint8Array(buffer)) as TTSMod;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,23 @@ export async function fetchModFile(
|
|||||||
apiKey: string,
|
apiKey: string,
|
||||||
): Promise<{ data: ArrayBuffer; filename: string }> {
|
): Promise<{ data: ArrayBuffer; filename: string }> {
|
||||||
const fileUrl = await getFileUrl(id, apiKey);
|
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<ArrayBuffer> {
|
||||||
const res = await fetch(fileUrl);
|
const res = await fetch(fileUrl);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new SteamApiError(
|
throw new SteamApiError(
|
||||||
@@ -78,10 +94,7 @@ export async function fetchModFile(
|
|||||||
502,
|
502,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return res.arrayBuffer();
|
||||||
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. */
|
/** Resolve the `file_url` for a Workshop item via the Steam API. */
|
||||||
|
|||||||
Reference in New Issue
Block a user