feat(streaming): report and start streamed sessions for games that can't run in a browser

Games on other platforms (Java applets first) can be played through a
session manager that streams them from a server-side container. When
STREAMING_API_URL and STREAMING_API_TOKEN are set, game details and launch
data report whether a game is streamable, and POST /games/:id/stream-session
starts a session.
This commit is contained in:
2026-10-11 01:01:27 -07:00
parent c12255c3c6
commit f59621dc82
5 changed files with 266 additions and 3 deletions
+5
View File
@@ -169,6 +169,11 @@ export const config = {
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24,
// Session manager that streams games which can't run in a browser, such as
// Java applets (flashpoint-ultimate-server). Streaming is off unless both are set.
streamingApiUrl: process.env.STREAMING_API_URL || null,
streamingApiToken: process.env.STREAMING_API_TOKEN || null,
// When enabled, copies flashpoint.sqlite to local storage for faster network access
enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true',
localDbPath: '/app/data/flashpoint.sqlite',
+74
View File
@@ -32,6 +32,12 @@ vi.mock('../services/GameDataService', () => ({
gameDataService: { mountGameZip },
}));
const isStreamable = vi.hoisted(() => vi.fn());
const createSession = vi.hoisted(() => vi.fn());
vi.mock('../services/StreamingService', () => ({
StreamingService: { isStreamable, createSession },
}));
import gamesRouter from './games';
import { errorHandler } from '../middleware/errorHandler';
import { AuthService } from '../services/AuthService';
@@ -100,3 +106,71 @@ describe('GET /api/games/:id/launch', () => {
expect(mountGameZip).toHaveBeenCalledOnce();
});
});
describe('streaming games that cannot run in a browser', () => {
beforeEach(() => {
vi.clearAllMocks();
getGameById.mockResolvedValue({
id: GAME_ID,
title: 'Java Game',
platformName: 'Java',
launchCommand: 'http://example.com/index.html',
});
mountGameZip.mockResolvedValue({ mounted: true, downloading: false });
isStreamable.mockResolvedValue(true);
createSession.mockResolvedValue({ path: '/stream/abc/', width: 472, height: 354 });
});
it('reports a streamable game on its details and launch data', async () => {
signInAs(['games.read', 'games.play']);
const details = await request(createApp())
.get(`/api/games/${GAME_ID}`)
.set('Authorization', 'Bearer token');
const launch = await request(createApp())
.get(`/api/games/${GAME_ID}/launch`)
.set('Authorization', 'Bearer token');
expect(details.body).toMatchObject({ id: GAME_ID, streamable: true });
expect(launch.body).toMatchObject({ canPlayInBrowser: false, streamable: true });
});
it('does not ask the session manager about browser-playable games', async () => {
signInAs(['games.read', 'games.play']);
getGameById.mockResolvedValue({
id: GAME_ID,
platformName: 'Flash',
launchCommand: 'http://a/b.swf',
});
const details = await request(createApp())
.get(`/api/games/${GAME_ID}`)
.set('Authorization', 'Bearer token');
expect(details.body.streamable).toBe(false);
expect(isStreamable).not.toHaveBeenCalled();
});
it('creates a session for users with games.play', async () => {
signInAs(['games.read', 'games.play']);
const response = await request(createApp())
.post(`/api/games/${GAME_ID}/stream-session`)
.set('Authorization', 'Bearer token');
expect(response.status).toBe(201);
expect(response.body).toEqual({ path: '/stream/abc/', width: 472, height: 354 });
expect(createSession).toHaveBeenCalledWith(GAME_ID);
});
it('rejects session creation without games.play', async () => {
signInAs(['games.read']);
const response = await request(createApp())
.post(`/api/games/${GAME_ID}/stream-session`)
.set('Authorization', 'Bearer token');
expect(response.status).toBe(403);
expect(createSession).not.toHaveBeenCalled();
});
});
+23 -3
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import { GameService } from '../services/GameService';
import { GameSearchCache } from '../services/GameSearchCache';
import { gameDataService } from '../services/GameDataService';
import { StreamingService } from '../services/StreamingService';
import { AppError } from '../middleware/errorHandler';
import { asyncHandler } from '../middleware/asyncHandler';
import { sharedAccessAuth, validateSharedGameAccess } from '../middleware/auth';
@@ -235,7 +236,10 @@ router.get(
res.locals.game = game;
res.json(game);
const streamable =
!isWebPlayablePlatform(game.platformName) && (await StreamingService.isStreamable(game.id));
res.json({ ...game, streamable });
})
);
@@ -311,8 +315,8 @@ router.get(
const hasContent = launchCommand.trim().length > 0;
const isWebPlayablePlatform = game.platformName === 'Flash' || game.platformName === 'HTML5';
const canPlayInBrowser = isWebPlayablePlatform && hasContent;
const canPlayInBrowser = isWebPlayablePlatform(game.platformName) && hasContent;
const streamable = !canPlayInBrowser && (await StreamingService.isStreamable(game.id));
res.locals.platform = game.platformName;
res.locals.canPlayInBrowser = canPlayInBrowser;
@@ -327,9 +331,25 @@ router.get(
applicationPath: game.applicationPath,
playMode: game.playMode,
canPlayInBrowser,
streamable,
downloading,
});
})
);
router.post(
'/:id/stream-session',
requirePermission('games.play'),
validateSharedGameAccess('id'),
logActivity('games.stream.start', 'games'),
asyncHandler(async (req, res) => {
const id = gameIdSchema.parse(req.params.id);
res.status(201).json(await StreamingService.createSession(id));
})
);
function isWebPlayablePlatform(platformName?: string): boolean {
return platformName === 'Flash' || platformName === 'HTML5';
}
export default router;
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
const config = vi.hoisted(() => ({
streamingApiUrl: 'http://manager:8080' as string | null,
streamingApiToken: 'secret' as string | null,
}));
vi.mock('../config', () => ({ config }));
import { StreamingService } from './StreamingService';
const GAME_ID = '11111111-2222-4333-8444-555555555555';
const fetchMock = vi.fn();
function respond(status: number, body: unknown = {}): void {
fetchMock.mockResolvedValue(new Response(JSON.stringify(body), { status }));
}
describe('StreamingService', () => {
beforeEach(() => {
config.streamingApiUrl = 'http://manager:8080';
config.streamingApiToken = 'secret';
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('is disabled unless both the URL and token are set', async () => {
config.streamingApiToken = null;
expect(StreamingService.isEnabled()).toBe(false);
expect(await StreamingService.isStreamable(GAME_ID)).toBe(false);
await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({
statusCode: 404,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('asks the session manager whether a game is streamable', async () => {
respond(200, { width: 472, height: 354 });
expect(await StreamingService.isStreamable(GAME_ID)).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
`http://manager:8080/games/${GAME_ID}`,
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer secret' }),
})
);
respond(422);
expect(await StreamingService.isStreamable(GAME_ID)).toBe(false);
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
expect(await StreamingService.isStreamable(GAME_ID)).toBe(false);
});
it('creates sessions and maps session manager errors', async () => {
respond(201, { id: 'abc', path: '/stream/abc/', width: 472, height: 354 });
expect(await StreamingService.createSession(GAME_ID)).toEqual({
path: '/stream/abc/',
width: 472,
height: 354,
});
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ gameId: GAME_ID });
respond(422);
await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({
statusCode: 422,
});
respond(503);
await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({
statusCode: 503,
});
respond(500);
await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({
statusCode: 502,
});
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({
statusCode: 502,
});
});
});
+76
View File
@@ -0,0 +1,76 @@
import { config } from '../config';
import { AppError } from '../middleware/errorHandler';
import { logger } from '../utils/logger';
export interface StreamSession {
/** Same-origin path the stream is served under, ending in a slash */
path: string;
width: number;
height: number;
}
const LOOKUP_TIMEOUT_MS = 2000;
const CREATE_TIMEOUT_MS = 15000;
/** Client for the session manager that streams games which can't run in a browser. */
export class StreamingService {
static isEnabled(): boolean {
return Boolean(config.streamingApiUrl && config.streamingApiToken);
}
/** Whether the session manager can stream a game. False when streaming is off or unreachable. */
static async isStreamable(gameId: string): Promise<boolean> {
if (!this.isEnabled()) return false;
try {
const res = await this.request(`/games/${gameId}`, { method: 'GET' }, LOOKUP_TIMEOUT_MS);
return res.ok;
} catch (error) {
logger.warn('[Streaming] Session manager unreachable:', error);
return false;
}
}
static async createSession(gameId: string): Promise<StreamSession> {
if (!this.isEnabled()) {
throw new AppError(404, 'Streaming is not enabled');
}
let res: Response;
try {
res = await this.request(
'/sessions',
{ method: 'POST', body: JSON.stringify({ gameId }) },
CREATE_TIMEOUT_MS
);
} catch (error) {
logger.error('[Streaming] Session manager unreachable:', error);
throw new AppError(502, 'Failed to start a streaming session');
}
if (res.status === 422) {
throw new AppError(422, 'This game cannot be streamed');
}
if (res.status === 503) {
throw new AppError(503, 'All streaming slots are in use. Try again later.');
}
if (!res.ok) {
logger.error(`[Streaming] Session manager returned ${res.status}`);
throw new AppError(502, 'Failed to start a streaming session');
}
const { path, width, height } = (await res.json()) as StreamSession;
return { path, width, height };
}
private static request(
pathname: string,
init: RequestInit,
timeoutMs: number
): Promise<Response> {
return fetch(`${config.streamingApiUrl}${pathname}`, {
...init,
headers: {
Authorization: `Bearer ${config.streamingApiToken}`,
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(timeoutMs),
});
}
}