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:
@@ -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(
|
||||
|
||||
+26
-13
@@ -33,16 +33,16 @@ interface SteamResponse {
|
||||
*/
|
||||
export async function fetchMod(id: string, apiKey: string): Promise<TTSMod> {
|
||||
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<TTSMod> {
|
||||
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<ArrayBuffer> {
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user