Let flashpoint-web ask whether a game can be streamed
This commit is contained in:
@@ -44,6 +44,7 @@ in secure contexts.
|
||||
|
||||
| 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 /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` |
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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<GameCatalog, 'streamSize'>, 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');
|
||||
|
||||
@@ -48,7 +48,7 @@ export class FakeDriver implements Driver {
|
||||
|
||||
export const catalog = {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user