diff --git a/streaming/README.md b/streaming/README.md index eeff25e..1dfab01 100644 --- a/streaming/README.md +++ b/streaming/README.md @@ -44,6 +44,7 @@ in secure contexts. | Endpoint | Caller | Purpose | |---|---|---| +| `GET :8080/games/` | flashpoint-web backend, `Authorization: Bearer ` | Returns the game's `{width, height}`, or 422 if it can't be streamed | | `POST :8080/sessions {gameId}` | flashpoint-web backend, `Authorization: Bearer ` | Creates a session; returns `{id, path, width, height}`, or 422 if the game can't be streamed | | `POST /stream//_fp/start {files}` | browser | Starts the container with the player's saves | | `GET /stream//_fp/saves` | browser | Server-sent events carrying `{files}` whenever saves change, then `end` | diff --git a/streaming/session-manager/src/main.ts b/streaming/session-manager/src/main.ts index 06f6fa7..4113ec5 100644 --- a/streaming/session-manager/src/main.ts +++ b/streaming/session-manager/src/main.ts @@ -18,14 +18,11 @@ const driver = new DockerDriver({ uid: Number(env('PUID', '1000')), gid: Number(env('PGID', '1000')), }); -const sessions = new SessionManager( - driver, - new GameCatalog(env('FLASHPOINT_PATH', '/flashpoint')), - Number(env('MAX_SESSIONS', '4')), -); +const catalog = new GameCatalog(env('FLASHPOINT_PATH', '/flashpoint')); +const sessions = new SessionManager(driver, catalog, Number(env('MAX_SESSIONS', '4'))); 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'))); console.log('Session manager started'); diff --git a/streaming/session-manager/src/server.ts b/streaming/session-manager/src/server.ts index 15378ea..f78eddc 100644 --- a/streaming/session-manager/src/server.ts +++ b/streaming/session-manager/src/server.ts @@ -3,6 +3,7 @@ import { text } from 'node:stream/consumers'; import { timingSafeEqual } from 'node:crypto'; import httpProxy from 'http-proxy'; import { streamPath, type Driver } from './docker.ts'; +import type { GameCatalog } from './games.ts'; import { parseSaveFiles } from './saves.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 MAX_BODY_BYTES = 16 * 1024 * 1024; -/** Internal API for flashpoint-web's backend: creates sessions. */ -export function createApiServer(sessions: SessionManager, token: string): http.Server { +/** Internal API for flashpoint-web's backend: reports streamable games and creates sessions. */ +export function createApiServer(sessions: SessionManager, catalog: Pick, token: string): http.Server { return http.createServer((req, res) => { handle(res, async () => { - if (req.method !== 'POST' || req.url !== '/sessions') { - throw new SessionError(404, 'not found'); - } if (!tokenMatches(req.headers.authorization, token)) { 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); if (typeof gameId !== 'string') { throw new SessionError(400, 'gameId is required'); diff --git a/streaming/session-manager/test/fakes.ts b/streaming/session-manager/test/fakes.ts index 734c35e..d845633 100644 --- a/streaming/session-manager/test/fakes.ts +++ b/streaming/session-manager/test/fakes.ts @@ -48,7 +48,7 @@ export class FakeDriver implements Driver { export const catalog = { async streamSize(gameId: string): Promise { - return gameId === 'java' ? { width: 472, height: 354 } : null; + return gameId === 'java' || gameId === '247a4c93-c252-49c8-8bc9-fb43d5c039df' ? { width: 472, height: 354 } : null; }, }; diff --git a/streaming/session-manager/test/server.test.ts b/streaming/session-manager/test/server.test.ts index 30304fc..90c68af 100644 --- a/streaming/session-manager/test/server.test.ts +++ b/streaming/session-manager/test/server.test.ts @@ -8,8 +8,9 @@ import { catalog, FakeDriver, fastTimings, sleep } from './fakes.ts'; const driver = new FakeDriver(); 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 JAVA_ID = '247a4c93-c252-49c8-8bc9-fb43d5c039df'; let apiUrl: 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 () => { assert.equal((await createSession('java', 'wrong')).status, 401); assert.equal((await createSession('flash')).status, 422);