Let flashpoint-web ask whether a game can be streamed

This commit is contained in:
2026-10-11 00:39:05 -07:00
parent f5e9452191
commit aadd6fe776
5 changed files with 29 additions and 13 deletions
+1
View File
@@ -44,6 +44,7 @@ in secure contexts.
| Endpoint | Caller | Purpose | | Endpoint | Caller | Purpose |
|---|---|---| |---|---|---|
| `GET :8080/games/<id>` | flashpoint-web backend, `Authorization: Bearer <API_TOKEN>` | Returns the game's `{width, height}`, or 422 if it can't be streamed |
| `POST :8080/sessions {gameId}` | flashpoint-web backend, `Authorization: Bearer <API_TOKEN>` | Creates a session; returns `{id, path, width, height}`, or 422 if the game can't be streamed | | `POST :8080/sessions {gameId}` | flashpoint-web backend, `Authorization: Bearer <API_TOKEN>` | Creates a session; returns `{id, path, width, height}`, or 422 if the game can't be streamed |
| `POST /stream/<id>/_fp/start {files}` | browser | Starts the container with the player's saves | | `POST /stream/<id>/_fp/start {files}` | browser | Starts the container with the player's saves |
| `GET /stream/<id>/_fp/saves` | browser | Server-sent events carrying `{files}` whenever saves change, then `end` | | `GET /stream/<id>/_fp/saves` | browser | Server-sent events carrying `{files}` whenever saves change, then `end` |
+3 -6
View File
@@ -18,14 +18,11 @@ const driver = new DockerDriver({
uid: Number(env('PUID', '1000')), uid: Number(env('PUID', '1000')),
gid: Number(env('PGID', '1000')), gid: Number(env('PGID', '1000')),
}); });
const sessions = new SessionManager( const catalog = new GameCatalog(env('FLASHPOINT_PATH', '/flashpoint'));
driver, const sessions = new SessionManager(driver, catalog, Number(env('MAX_SESSIONS', '4')));
new GameCatalog(env('FLASHPOINT_PATH', '/flashpoint')),
Number(env('MAX_SESSIONS', '4')),
);
await driver.removeAll(); await driver.removeAll();
const api = createApiServer(sessions, env('API_TOKEN')).listen(Number(env('API_PORT', '8080'))); const api = createApiServer(sessions, catalog, env('API_TOKEN')).listen(Number(env('API_PORT', '8080')));
const stream = createStreamServer(sessions, driver).listen(Number(env('STREAM_PORT', '8081'))); const stream = createStreamServer(sessions, driver).listen(Number(env('STREAM_PORT', '8081')));
console.log('Session manager started'); console.log('Session manager started');
+14 -5
View File
@@ -3,6 +3,7 @@ import { text } from 'node:stream/consumers';
import { timingSafeEqual } from 'node:crypto'; import { timingSafeEqual } from 'node:crypto';
import httpProxy from 'http-proxy'; import httpProxy from 'http-proxy';
import { streamPath, type Driver } from './docker.ts'; import { streamPath, type Driver } from './docker.ts';
import type { GameCatalog } from './games.ts';
import { parseSaveFiles } from './saves.ts'; import { parseSaveFiles } from './saves.ts';
import { SessionError, type SessionManager } from './sessions.ts'; import { SessionError, type SessionManager } from './sessions.ts';
@@ -10,16 +11,24 @@ const STREAM_URL = /^\/stream\/([0-9a-f]{32})(\/.*)$/;
const HEARTBEAT_MS = 25_000; const HEARTBEAT_MS = 25_000;
const MAX_BODY_BYTES = 16 * 1024 * 1024; const MAX_BODY_BYTES = 16 * 1024 * 1024;
/** Internal API for flashpoint-web's backend: creates sessions. */ /** Internal API for flashpoint-web's backend: reports streamable games and creates sessions. */
export function createApiServer(sessions: SessionManager, token: string): http.Server { export function createApiServer(sessions: SessionManager, catalog: Pick<GameCatalog, 'streamSize'>, token: string): http.Server {
return http.createServer((req, res) => { return http.createServer((req, res) => {
handle(res, async () => { handle(res, async () => {
if (req.method !== 'POST' || req.url !== '/sessions') {
throw new SessionError(404, 'not found');
}
if (!tokenMatches(req.headers.authorization, token)) { if (!tokenMatches(req.headers.authorization, token)) {
throw new SessionError(401, 'unauthorized'); throw new SessionError(401, 'unauthorized');
} }
const [, lookupId] = req.url?.match(/^\/games\/([0-9a-f-]{36})$/) ?? [];
if (req.method === 'GET' && lookupId) {
const size = await catalog.streamSize(lookupId);
if (!size) {
throw new SessionError(422, 'game is not streamable');
}
return sendJson(res, 200, size);
}
if (req.method !== 'POST' || req.url !== '/sessions') {
throw new SessionError(404, 'not found');
}
const { gameId } = await readJson(req); const { gameId } = await readJson(req);
if (typeof gameId !== 'string') { if (typeof gameId !== 'string') {
throw new SessionError(400, 'gameId is required'); throw new SessionError(400, 'gameId is required');
+1 -1
View File
@@ -48,7 +48,7 @@ export class FakeDriver implements Driver {
export const catalog = { export const catalog = {
async streamSize(gameId: string): Promise<Size | null> { async streamSize(gameId: string): Promise<Size | null> {
return gameId === 'java' ? { width: 472, height: 354 } : null; return gameId === 'java' || gameId === '247a4c93-c252-49c8-8bc9-fb43d5c039df' ? { width: 472, height: 354 } : null;
}, },
}; };
+10 -1
View File
@@ -8,8 +8,9 @@ import { catalog, FakeDriver, fastTimings, sleep } from './fakes.ts';
const driver = new FakeDriver(); const driver = new FakeDriver();
const sessions = new SessionManager(driver, catalog, 4, fastTimings); const sessions = new SessionManager(driver, catalog, 4, fastTimings);
const api = createApiServer(sessions, 'secret'); const api = createApiServer(sessions, catalog, 'secret');
const stream = createStreamServer(sessions, driver); const stream = createStreamServer(sessions, driver);
const JAVA_ID = '247a4c93-c252-49c8-8bc9-fb43d5c039df';
let apiUrl: string; let apiUrl: string;
let streamUrl: string; let streamUrl: string;
@@ -37,6 +38,14 @@ function createSession(gameId: string, token = 'secret') {
}); });
} }
test('the API reports whether a game is streamable', async () => {
const get = (gameId: string, token = 'secret') =>
fetch(`${apiUrl}/games/${gameId}`, { headers: { Authorization: `Bearer ${token}` } });
assert.equal((await get(JAVA_ID, 'wrong')).status, 401);
assert.deepEqual(await (await get(JAVA_ID)).json(), { width: 472, height: 354 });
assert.equal((await get('00000000-0000-0000-0000-000000000000')).status, 422);
});
test('the API requires the token and reports unstreamable games', async () => { test('the API requires the token and reports unstreamable games', async () => {
assert.equal((await createSession('java', 'wrong')).status, 401); assert.equal((await createSession('java', 'wrong')).status, 401);
assert.equal((await createSession('flash')).status, 422); assert.equal((await createSession('flash')).status, 422);