Merge pull request 'Stream games that can't run in a browser' (#1) from stream-sessions into main
Reviewed-on: branderson/flashpoint-web#1 Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
@@ -105,6 +105,10 @@ ENABLE_CACHE_PREWARM=true
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# HOME_RECENT_HOURS=24
|
# HOME_RECENT_HOURS=24
|
||||||
# ENABLE_CGI=false
|
# 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 the built frontend from the backend (on by default in production)
|
||||||
# SERVE_FRONTEND=true
|
# SERVE_FRONTEND=true
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ export const config = {
|
|||||||
|
|
||||||
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24,
|
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
|
// When enabled, copies flashpoint.sqlite to local storage for faster network access
|
||||||
enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true',
|
enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true',
|
||||||
localDbPath: '/app/data/flashpoint.sqlite',
|
localDbPath: '/app/data/flashpoint.sqlite',
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ vi.mock('../services/GameDataService', () => ({
|
|||||||
gameDataService: { mountGameZip },
|
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 gamesRouter from './games';
|
||||||
import { errorHandler } from '../middleware/errorHandler';
|
import { errorHandler } from '../middleware/errorHandler';
|
||||||
import { AuthService } from '../services/AuthService';
|
import { AuthService } from '../services/AuthService';
|
||||||
@@ -100,3 +106,71 @@ describe('GET /api/games/:id/launch', () => {
|
|||||||
expect(mountGameZip).toHaveBeenCalledOnce();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import { GameService } from '../services/GameService';
|
import { GameService } from '../services/GameService';
|
||||||
import { GameSearchCache } from '../services/GameSearchCache';
|
import { GameSearchCache } from '../services/GameSearchCache';
|
||||||
import { gameDataService } from '../services/GameDataService';
|
import { gameDataService } from '../services/GameDataService';
|
||||||
|
import { StreamingService } from '../services/StreamingService';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
import { asyncHandler } from '../middleware/asyncHandler';
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { sharedAccessAuth, validateSharedGameAccess } from '../middleware/auth';
|
import { sharedAccessAuth, validateSharedGameAccess } from '../middleware/auth';
|
||||||
@@ -235,7 +236,10 @@ router.get(
|
|||||||
|
|
||||||
res.locals.game = game;
|
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 hasContent = launchCommand.trim().length > 0;
|
||||||
|
|
||||||
const isWebPlayablePlatform = game.platformName === 'Flash' || game.platformName === 'HTML5';
|
const canPlayInBrowser = isWebPlayablePlatform(game.platformName) && hasContent;
|
||||||
const canPlayInBrowser = isWebPlayablePlatform && hasContent;
|
const streamable = !canPlayInBrowser && (await StreamingService.isStreamable(game.id));
|
||||||
|
|
||||||
res.locals.platform = game.platformName;
|
res.locals.platform = game.platformName;
|
||||||
res.locals.canPlayInBrowser = canPlayInBrowser;
|
res.locals.canPlayInBrowser = canPlayInBrowser;
|
||||||
@@ -327,9 +331,25 @@ router.get(
|
|||||||
applicationPath: game.applicationPath,
|
applicationPath: game.applicationPath,
|
||||||
playMode: game.playMode,
|
playMode: game.playMode,
|
||||||
canPlayInBrowser,
|
canPlayInBrowser,
|
||||||
|
streamable,
|
||||||
downloading,
|
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;
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,9 @@ services:
|
|||||||
# Application
|
# Application
|
||||||
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
|
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
|
||||||
- ENABLE_CGI=${ENABLE_CGI:-false}
|
- 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
|
# OpenTelemetry
|
||||||
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ services:
|
|||||||
# Application
|
# Application
|
||||||
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
|
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
|
||||||
- ENABLE_CGI=${ENABLE_CGI:-false}
|
- 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
|
# OpenTelemetry
|
||||||
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||||
|
|||||||
@@ -116,6 +116,21 @@ and API share one origin.
|
|||||||
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
|
| `FRONTEND_DIST_PATH` | `<backend>/../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 |
|
| `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/<id>/` 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:**
|
**OpenTelemetry:**
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import { logger } from '@/lib/logger';
|
|||||||
import { AlertCircle, Maximize2, Minimize2, Loader2, ArrowLeft } from 'lucide-react';
|
import { AlertCircle, Maximize2, Minimize2, Loader2, ArrowLeft } from 'lucide-react';
|
||||||
import { LazyImage } from '@/components/common/LazyImage';
|
import { LazyImage } from '@/components/common/LazyImage';
|
||||||
import { RufflePlayer } from './RufflePlayer';
|
import { RufflePlayer } from './RufflePlayer';
|
||||||
|
import { StreamPlayer } from './StreamPlayer';
|
||||||
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
||||||
import { PlatformIcon } from '@/components/ui/platform-icon';
|
import { PlatformIcon } from '@/components/ui/platform-icon';
|
||||||
|
|
||||||
export interface GamePlayerProps {
|
export interface GamePlayerProps {
|
||||||
|
/** Required to stream games that can't run in a browser */
|
||||||
|
gameId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
contentUrl?: string;
|
contentUrl?: string;
|
||||||
launchCommand?: string;
|
launchCommand?: string;
|
||||||
canPlayInBrowser: boolean;
|
canPlayInBrowser: boolean;
|
||||||
|
/** Can't run in a browser but can be streamed from the server */
|
||||||
|
streamable?: boolean;
|
||||||
allowFullscreen?: boolean;
|
allowFullscreen?: boolean;
|
||||||
initialFullscreen?: boolean;
|
initialFullscreen?: boolean;
|
||||||
onFullscreenChange?: (isFullscreen: boolean) => void;
|
onFullscreenChange?: (isFullscreen: boolean) => void;
|
||||||
@@ -34,11 +39,13 @@ export interface GamePlayerProps {
|
|||||||
* Note: Use the default export (wrapped with PlayerErrorBoundary) instead of this component directly.
|
* Note: Use the default export (wrapped with PlayerErrorBoundary) instead of this component directly.
|
||||||
*/
|
*/
|
||||||
function GamePlayerInternal({
|
function GamePlayerInternal({
|
||||||
|
gameId,
|
||||||
title,
|
title,
|
||||||
platform,
|
platform,
|
||||||
contentUrl,
|
contentUrl,
|
||||||
launchCommand,
|
launchCommand,
|
||||||
canPlayInBrowser,
|
canPlayInBrowser,
|
||||||
|
streamable = false,
|
||||||
allowFullscreen = true,
|
allowFullscreen = true,
|
||||||
initialFullscreen = false,
|
initialFullscreen = false,
|
||||||
onFullscreenChange,
|
onFullscreenChange,
|
||||||
@@ -98,7 +105,9 @@ function GamePlayerInternal({
|
|||||||
}
|
}
|
||||||
}, [contentUrl, platform]);
|
}, [contentUrl, platform]);
|
||||||
|
|
||||||
if (!canPlayInBrowser) {
|
const isStreamed = !canPlayInBrowser && streamable && Boolean(gameId);
|
||||||
|
|
||||||
|
if (!canPlayInBrowser && !isStreamed) {
|
||||||
const isWebPlatform = platform === 'Flash' || platform === 'HTML5';
|
const isWebPlatform = platform === 'Flash' || platform === 'HTML5';
|
||||||
|
|
||||||
let reason: string;
|
let reason: string;
|
||||||
@@ -255,8 +264,10 @@ function GamePlayerInternal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Flash SWF - use Ruffle emulator */}
|
{/* Other platforms - stream from the server */}
|
||||||
{flashContentType === 'swf' && contentUrl ? (
|
{isStreamed && gameId ? (
|
||||||
|
<StreamPlayer gameId={gameId} title={title} />
|
||||||
|
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
|
||||||
<RufflePlayer
|
<RufflePlayer
|
||||||
swfUrl={contentUrl}
|
swfUrl={contentUrl}
|
||||||
width="100%"
|
width="100%"
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { StreamPlayer } from './StreamPlayer';
|
||||||
|
|
||||||
|
const createStreamSession = vi.hoisted(() => 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<string, () => 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(<StreamPlayer gameId={GAME_ID} title="Insaniquarium!" />);
|
||||||
|
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(<StreamPlayer gameId={GAME_ID} title="Insaniquarium!" />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<StreamState>({ 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 (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center max-w-md p-6">
|
||||||
|
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-bold mb-2">Failed to Start Game</h3>
|
||||||
|
<p className="text-muted-foreground">{state.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === 'starting') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 size={48} className="animate-spin text-muted-foreground mx-auto mb-4" />
|
||||||
|
<p>Starting {title}...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={state.path}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
title={title}
|
||||||
|
allow="autoplay; fullscreen; clipboard-read; clipboard-write; gamepad"
|
||||||
|
onLoad={(event) => event.currentTarget.focus()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@ export { RufflePlayer } from './RufflePlayer';
|
|||||||
export type { RufflePlayerProps } from './RufflePlayer';
|
export type { RufflePlayerProps } from './RufflePlayer';
|
||||||
export { GamePlayer } from './GamePlayer';
|
export { GamePlayer } from './GamePlayer';
|
||||||
export type { GamePlayerProps } from './GamePlayer';
|
export type { GamePlayerProps } from './GamePlayer';
|
||||||
|
export { StreamPlayer } from './StreamPlayer';
|
||||||
|
export type { StreamPlayerProps } from './StreamPlayer';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
GameFilters,
|
GameFilters,
|
||||||
FilterOptions,
|
FilterOptions,
|
||||||
GameLaunchData,
|
GameLaunchData,
|
||||||
|
StreamSession,
|
||||||
} from '@/types/game';
|
} from '@/types/game';
|
||||||
|
|
||||||
export const gamesApi = {
|
export const gamesApi = {
|
||||||
@@ -87,6 +88,11 @@ export const gamesApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createStreamSession: async (id: string): Promise<StreamSession> => {
|
||||||
|
const { data } = await apiClient.post<StreamSession>(`/games/${id}/stream-session`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
downloadGame: async (
|
downloadGame: async (
|
||||||
id: string,
|
id: string,
|
||||||
gameDataId?: number
|
gameDataId?: number
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/** Save files from a streamed game, by path relative to its saves directory, base64-encoded. */
|
||||||
|
export type SaveFiles = Record<string, string>;
|
||||||
|
|
||||||
|
const DB_NAME = 'flashpoint-stream-saves';
|
||||||
|
const STORE = 'saves';
|
||||||
|
|
||||||
|
function openDb(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, 1);
|
||||||
|
request.onupgradeneeded = () => request.result.createObjectStore(STORE);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStore<T>(
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
fn: (store: IDBObjectStore) => IDBRequest<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const db = await openDb();
|
||||||
|
try {
|
||||||
|
return await new Promise<T>((resolve, reject) => {
|
||||||
|
const request = fn(db.transaction(STORE, mode).objectStore(STORE));
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves this browser holds for a streamed game, kept per game ID. */
|
||||||
|
export async function loadSaves(gameId: string): Promise<SaveFiles> {
|
||||||
|
return (await withStore<SaveFiles | undefined>('readonly', (store) => store.get(gameId))) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeSaves(gameId: string, files: SaveFiles): Promise<void> {
|
||||||
|
await withStore('readwrite', (store) => store.put(files, gameId));
|
||||||
|
}
|
||||||
@@ -35,6 +35,14 @@ export interface Game {
|
|||||||
activeDataOnDisk?: number; // Whether the active data is downloaded (0 = no, 1 = yes)
|
activeDataOnDisk?: number; // Whether the active data is downloaded (0 = no, 1 = yes)
|
||||||
logoPath?: string; // Infinity edition only
|
logoPath?: string; // Infinity edition only
|
||||||
screenshotPath?: string; // Infinity edition only
|
screenshotPath?: string; // Infinity edition only
|
||||||
|
streamable?: boolean; // Can't run in a browser but can be streamed from the server
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamSession {
|
||||||
|
/** Same-origin path the stream is served under, ending in a slash */
|
||||||
|
path: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
@@ -97,6 +105,8 @@ export interface GameLaunchData {
|
|||||||
applicationPath?: string;
|
applicationPath?: string;
|
||||||
playMode?: string;
|
playMode?: string;
|
||||||
canPlayInBrowser: boolean;
|
canPlayInBrowser: boolean;
|
||||||
|
/** True when the game can't run in a browser but can be streamed from the server */
|
||||||
|
streamable?: boolean;
|
||||||
/** True when the game ZIP is being downloaded in the background */
|
/** True when the game ZIP is being downloaded in the background */
|
||||||
downloading?: boolean;
|
downloading?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ export function GameDetailView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center space-y-2 flex-shrink-0">
|
<div className="text-center space-y-2 flex-shrink-0">
|
||||||
{game.platformName === 'Flash' || game.platformName === 'HTML5' ? (
|
{game.platformName === 'Flash' || game.platformName === 'HTML5' || game.streamable ? (
|
||||||
<>
|
<>
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -67,11 +67,13 @@ export function GamePlayerView() {
|
|||||||
|
|
||||||
const playerProps = useMemo(
|
const playerProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
gameId: game?.id,
|
||||||
title: game?.title ?? '',
|
title: game?.title ?? '',
|
||||||
platform: launchData?.platform ?? '',
|
platform: launchData?.platform ?? '',
|
||||||
contentUrl: launchData?.contentUrl,
|
contentUrl: launchData?.contentUrl,
|
||||||
launchCommand: launchData?.launchCommand,
|
launchCommand: launchData?.launchCommand,
|
||||||
canPlayInBrowser: launchData?.canPlayInBrowser ?? false,
|
canPlayInBrowser: launchData?.canPlayInBrowser ?? false,
|
||||||
|
streamable: launchData?.streamable ?? false,
|
||||||
logoUrl: game ? getGameLogoUrl(game.id) : undefined,
|
logoUrl: game ? getGameLogoUrl(game.id) : undefined,
|
||||||
developer: game?.developer,
|
developer: game?.developer,
|
||||||
onBack: handleBack,
|
onBack: handleBack,
|
||||||
@@ -84,6 +86,7 @@ export function GamePlayerView() {
|
|||||||
launchData?.contentUrl,
|
launchData?.contentUrl,
|
||||||
launchData?.launchCommand,
|
launchData?.launchCommand,
|
||||||
launchData?.canPlayInBrowser,
|
launchData?.canPlayInBrowser,
|
||||||
|
launchData?.streamable,
|
||||||
handleBack,
|
handleBack,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user