diff --git a/.env.example b/.env.example index b87ce14..43d993d 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,10 @@ ENABLE_CACHE_PREWARM=true # --------------------------------------------------------------------------- # HOME_RECENT_HOURS=24 # ENABLE_CGI=false +# Streamed play for games that can't run in a browser, such as Java applets. +# Off unless both are set. See docs/09-deployment/environment-variables.md. +# STREAMING_API_URL=http://flashpoint-session-manager:8080 +# STREAMING_API_TOKEN= # Serve the built frontend from the backend (on by default in production) # SERVE_FRONTEND=true diff --git a/backend/src/config.ts b/backend/src/config.ts index d121c0f..bd7213f 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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', diff --git a/backend/src/routes/games.test.ts b/backend/src/routes/games.test.ts index 86bf034..2247d55 100644 --- a/backend/src/routes/games.test.ts +++ b/backend/src/routes/games.test.ts @@ -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(); + }); +}); diff --git a/backend/src/routes/games.ts b/backend/src/routes/games.ts index 3d73969..d332b8f 100644 --- a/backend/src/routes/games.ts +++ b/backend/src/routes/games.ts @@ -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; diff --git a/backend/src/services/StreamingService.test.ts b/backend/src/services/StreamingService.test.ts new file mode 100644 index 0000000..8c9cc63 --- /dev/null +++ b/backend/src/services/StreamingService.test.ts @@ -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, + }); + }); +}); diff --git a/backend/src/services/StreamingService.ts b/backend/src/services/StreamingService.ts new file mode 100644 index 0000000..e602d7c --- /dev/null +++ b/backend/src/services/StreamingService.ts @@ -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 { + 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 { + 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 { + return fetch(`${config.streamingApiUrl}${pathname}`, { + ...init, + headers: { + Authorization: `Bearer ${config.streamingApiToken}`, + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(timeoutMs), + }); + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 614be78..b6b6f3f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -64,6 +64,9 @@ services: # Application - HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24} - ENABLE_CGI=${ENABLE_CGI:-false} + # Streamed play for games that can't run in a browser (off unless both are set) + - STREAMING_API_URL=${STREAMING_API_URL:-} + - STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-} # OpenTelemetry - OTEL_ENABLED=${OTEL_ENABLED:-false} - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-} diff --git a/docker-compose.yml b/docker-compose.yml index c867324..3b91070 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,9 @@ services: # Application - HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24} - ENABLE_CGI=${ENABLE_CGI:-false} + # Streamed play for games that can't run in a browser (off unless both are set) + - STREAMING_API_URL=${STREAMING_API_URL:-} + - STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-} # OpenTelemetry - OTEL_ENABLED=${OTEL_ENABLED:-false} - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-} diff --git a/docs/09-deployment/environment-variables.md b/docs/09-deployment/environment-variables.md index 63cbd9f..e49d654 100644 --- a/docs/09-deployment/environment-variables.md +++ b/docs/09-deployment/environment-variables.md @@ -116,6 +116,21 @@ and API share one origin. | `FRONTEND_DIST_PATH` | `/../frontend/dist` | Location of the built frontend (rarely needed) | | `RUFFLE_DATA_PATH` | `/app/data/ruffle` in production | Where Ruffle is installed. Must sit on a mounted volume, or an in-app update is lost when the container is recreated | +**Streaming:** + +Games that can't run in a browser, such as Java applets, can be streamed from a +server-side container by a separate session manager (flashpoint-ultimate-server). +The backend asks it which games are streamable and starts sessions for players. +Browsers reach each stream at `/stream//` on this site, so the reverse proxy +in front of flashpoint-web must route `/stream/` to the session manager, +including WebSocket upgrades. Saves for streamed games are kept in each +browser's IndexedDB. + +| Variable | Default | Description | +| --------------------- | ------- | ---------------------------------------------------------------------- | +| `STREAMING_API_URL` | - | Session manager's internal API, e.g. `http://flashpoint-session-manager:8080` | +| `STREAMING_API_TOKEN` | - | Token the session manager expects. Streaming is off unless both are set | + **OpenTelemetry:** | Variable | Default | Description | diff --git a/frontend/src/components/player/GamePlayer.tsx b/frontend/src/components/player/GamePlayer.tsx index f9a294f..9f49898 100644 --- a/frontend/src/components/player/GamePlayer.tsx +++ b/frontend/src/components/player/GamePlayer.tsx @@ -3,15 +3,20 @@ import { logger } from '@/lib/logger'; import { AlertCircle, Maximize2, Minimize2, Loader2, ArrowLeft } from 'lucide-react'; import { LazyImage } from '@/components/common/LazyImage'; import { RufflePlayer } from './RufflePlayer'; +import { StreamPlayer } from './StreamPlayer'; import { PlayerErrorBoundary } from './PlayerErrorBoundary'; import { PlatformIcon } from '@/components/ui/platform-icon'; export interface GamePlayerProps { + /** Required to stream games that can't run in a browser */ + gameId?: string; title: string; platform: string; contentUrl?: string; launchCommand?: string; canPlayInBrowser: boolean; + /** Can't run in a browser but can be streamed from the server */ + streamable?: boolean; allowFullscreen?: boolean; initialFullscreen?: boolean; onFullscreenChange?: (isFullscreen: boolean) => void; @@ -34,11 +39,13 @@ export interface GamePlayerProps { * Note: Use the default export (wrapped with PlayerErrorBoundary) instead of this component directly. */ function GamePlayerInternal({ + gameId, title, platform, contentUrl, launchCommand, canPlayInBrowser, + streamable = false, allowFullscreen = true, initialFullscreen = false, onFullscreenChange, @@ -98,7 +105,9 @@ function GamePlayerInternal({ } }, [contentUrl, platform]); - if (!canPlayInBrowser) { + const isStreamed = !canPlayInBrowser && streamable && Boolean(gameId); + + if (!canPlayInBrowser && !isStreamed) { const isWebPlatform = platform === 'Flash' || platform === 'HTML5'; let reason: string; @@ -255,8 +264,10 @@ function GamePlayerInternal({ } } > - {/* Flash SWF - use Ruffle emulator */} - {flashContentType === 'swf' && contentUrl ? ( + {/* Other platforms - stream from the server */} + {isStreamed && gameId ? ( + + ) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? ( vi.fn()); +vi.mock('@/lib/api/games', () => ({ gamesApi: { createStreamSession } })); + +const loadSaves = vi.hoisted(() => vi.fn()); +const storeSaves = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/streamSaves', () => ({ loadSaves, storeSaves })); + +class FakeEventSource { + static instances: FakeEventSource[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + listeners: Record void> = {}; + closed = false; + + constructor(public url: string) { + FakeEventSource.instances.push(this); + } + + addEventListener(type: string, listener: () => void): void { + this.listeners[type] = listener; + } + + close(): void { + this.closed = true; + } +} + +const GAME_ID = '247a4c93-c252-49c8-8bc9-fb43d5c039df'; +const fetchMock = vi.fn(); + +describe('StreamPlayer', () => { + beforeEach(() => { + vi.clearAllMocks(); + FakeEventSource.instances = []; + vi.stubGlobal('EventSource', FakeEventSource); + vi.stubGlobal('fetch', fetchMock); + createStreamSession.mockResolvedValue({ path: '/stream/abc/', width: 472, height: 354 }); + loadSaves.mockResolvedValue({ 'cookies.txt': 'YQ==' }); + storeSaves.mockResolvedValue(undefined); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('starts the session with stored saves, shows the stream, and stores pushed saves', async () => { + const { unmount } = render(); + expect(screen.getByText('Starting Insaniquarium!...')).toBeInTheDocument(); + + const iframe = await screen.findByTitle('Insaniquarium!'); + expect(iframe.getAttribute('src')).toBe('/stream/abc/'); + expect(fetchMock).toHaveBeenCalledWith('/stream/abc/_fp/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: { 'cookies.txt': 'YQ==' } }), + }); + + const events = FakeEventSource.instances[0]; + expect(events.url).toBe('/stream/abc/_fp/saves'); + events.onmessage?.( + new MessageEvent('message', { data: JSON.stringify({ files: { 'cookies.txt': 'Yg==' } }) }) + ); + expect(storeSaves).toHaveBeenCalledWith(GAME_ID, { 'cookies.txt': 'Yg==' }); + + unmount(); + expect(events.closed).toBe(true); + }); + + it('shows an error when the session cannot start', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 502 })); + + render(); + + await waitFor(() => expect(screen.getByText('Failed to Start Game')).toBeInTheDocument()); + expect(screen.getByText('The game failed to start (502)')).toBeInTheDocument(); + expect(FakeEventSource.instances).toHaveLength(0); + }); +}); diff --git a/frontend/src/components/player/StreamPlayer.tsx b/frontend/src/components/player/StreamPlayer.tsx new file mode 100644 index 0000000..52d71c3 --- /dev/null +++ b/frontend/src/components/player/StreamPlayer.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { gamesApi } from '@/lib/api/games'; +import { logger } from '@/lib/logger'; +import { loadSaves, storeSaves } from '@/lib/streamSaves'; +import { getErrorMessage } from '@/types/api-error'; + +export interface StreamPlayerProps { + gameId: string; + title: string; +} + +type StreamState = + | { status: 'starting' } + | { status: 'running'; path: string } + | { status: 'error'; message: string }; + +/** + * Plays a game that can't run in a browser by streaming it from a server-side + * container. Saves are kept in this browser and synced with the container. + */ +export function StreamPlayer({ gameId, title }: StreamPlayerProps) { + const [state, setState] = useState({ status: 'starting' }); + + useEffect(() => { + let cancelled = false; + let events: EventSource | undefined; + + (async () => { + const session = await gamesApi.createStreamSession(gameId); + const files = await loadSaves(gameId); + const response = await fetch(`${session.path}_fp/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files }), + }); + if (!response.ok) { + throw new Error(`The game failed to start (${response.status})`); + } + if (cancelled) return; + + events = new EventSource(`${session.path}_fp/saves`); + events.onmessage = (event) => { + storeSaves(gameId, JSON.parse(event.data).files).catch((error) => + logger.error('[StreamPlayer] Failed to store saves:', error) + ); + }; + events.addEventListener('end', () => events?.close()); + setState({ status: 'running', path: session.path }); + })().catch((error) => { + logger.error('[StreamPlayer] Failed to start stream:', error); + if (!cancelled) setState({ status: 'error', message: getErrorMessage(error) }); + }); + + return () => { + cancelled = true; + events?.close(); + }; + }, [gameId]); + + if (state.status === 'error') { + return ( +
+
+ +

Failed to Start Game

+

{state.message}

+
+
+ ); + } + + if (state.status === 'starting') { + return ( +
+
+ +

Starting {title}...

+
+
+ ); + } + + return ( +