Merge pull request 'Per-player stream sessions with browser saves' (#2) from stream-sessions into java-streamer

Reviewed-on: branderson/flashpoint-ultimate-server#2
Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
2026-10-11 02:42:25 -07:00
22 changed files with 2604 additions and 14 deletions
+56
View File
@@ -0,0 +1,56 @@
# flashpoint-ultimate-server
Streams Flashpoint games that can't run in a browser, such as Java applets,
from a Linux server to [flashpoint-web](https://github.com/darkraise/flashpoint-web).
Each player gets their own container, which is removed when they disconnect.
Saves are kept in the player's browser.
- `streamer/`: the container image that runs one game and streams it with Selkies
- `session-manager/`: starts and removes streamer containers, proxies their
streams, and syncs saves to the browser
## Setup
The Flashpoint install needs Infinity's Linux software (see
[streamer/README.md](streamer/README.md#requirements)).
```sh
docker build -t flashpoint-streamer streamer
FLASHPOINT_HOST_PATH=/path/to/flashpoint API_TOKEN=$(openssl rand -hex 32) \
docker compose up -d --build
```
Give flashpoint-web the same `API_TOKEN` and the internal API URL
`http://flashpoint-session-manager:8080`, and attach it to the `flashpoint`
network. Then route `/stream/` on flashpoint-web's site to the session manager,
for example with nginx:
```nginx
location /stream/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_buffering off;
proxy_read_timeout 1h;
}
```
Streaming needs HTTPS, since browsers only allow the video decoder Selkies uses
in secure contexts.
## Session manager
| 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` |
| `/stream/<id>/*` | browser | Proxied to the session's Selkies, including WebSockets |
Saves are `{relative path: base64 contents}` for the files under the
container's `/config/saves`. A session is removed if nothing connects within
2 minutes or 30 seconds after its last WebSocket closes. At most `MAX_SESSIONS`
run at once.
+29
View File
@@ -0,0 +1,29 @@
services:
session-manager:
build: session-manager
container_name: flashpoint-session-manager
restart: unless-stopped
ports:
- "${STREAM_PORT:-8081}:8081"
volumes:
# Starts and removes streamer containers
- /var/run/docker.sock:/var/run/docker.sock
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/flashpoint:ro
environment:
- FLASHPOINT_HOST_PATH
- API_TOKEN=${API_TOKEN:?API_TOKEN is required}
- MAX_SESSIONS=${MAX_SESSIONS:-4}
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
networks:
- flashpoint
- streams
networks:
# Shared with flashpoint-web, which calls the internal API on port 8080
flashpoint:
name: flashpoint
# Streamer containers: reachable only from the session manager, no internet access
streams:
name: flashpoint-streams
internal: true
+1
View File
@@ -0,0 +1 @@
node_modules
+1
View File
@@ -0,0 +1 @@
node_modules/
+6
View File
@@ -0,0 +1,6 @@
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src src
CMD ["node", "src/main.ts"]
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "flashpoint-session-manager",
"private": true,
"type": "module",
"scripts": {
"start": "node src/main.ts",
"test": "node --test test/*.test.ts",
"check": "tsc"
},
"engines": {
"node": ">=22.18"
},
"dependencies": {
"dockerode": "^5.0.1",
"http-proxy": "^1.18.1",
"tar-stream": "^3.2.2",
"yauzl": "^3.4.0"
},
"devDependencies": {
"@types/dockerode": "^4.0.2",
"@types/http-proxy": "^1.17.17",
"@types/node": "^26.6.5",
"@types/tar-stream": "^3.1.5",
"@types/ws": "^8.18.2",
"@types/yauzl": "^3.4.0",
"@types/yazl": "^3.3.1",
"typescript": "^7.0.2",
"ws": "^8.22.0",
"yazl": "^3.3.1"
}
}
+125
View File
@@ -0,0 +1,125 @@
import Docker from 'dockerode';
import type { Size } from './games.ts';
import { fromTar, SAVES_DIR, toTar, type SaveFiles } from './saves.ts';
/** Manages the streamer container behind each session. */
export interface Driver {
create(id: string, gameId: string, size: Size): Promise<void>;
start(id: string, saves: SaveFiles): Promise<void>;
getSaves(id: string): Promise<SaveFiles>;
remove(id: string): Promise<void>;
/** Removes containers left behind by a previous run. */
removeAll(): Promise<void>;
/** Base URL of the container's Selkies server. */
url(id: string): string;
}
export interface DockerDriverOptions {
image: string;
network: string;
flashpointHostPath: string;
uid: number;
gid: number;
}
const LABEL = 'flashpoint.stream-session';
const HOME = '/config';
const READY_TIMEOUT_MS = 60_000;
export class DockerDriver implements Driver {
private readonly docker = new Docker();
private readonly options: DockerDriverOptions;
constructor(options: DockerDriverOptions) {
this.options = options;
}
async create(id: string, gameId: string, size: Size): Promise<void> {
const { image, network, flashpointHostPath, uid, gid } = this.options;
await this.docker.createContainer({
name: containerName(id),
Image: image,
Labels: { [LABEL]: id },
Env: [
`GAME_ID=${gameId}`,
`PUID=${uid}`,
`PGID=${gid}`,
`SUBFOLDER=${streamPath(id)}`,
`SELKIES_MANUAL_WIDTH=${size.width}`,
`SELKIES_MANUAL_HEIGHT=${size.height}`,
'SELKIES_UI_SHOW_SIDEBAR=false',
'HARDEN_DESKTOP=true',
'RESTART_APP=true',
'NO_DECOR=true',
],
HostConfig: {
Binds: [`${flashpointHostPath}:/flashpoint:ro`],
NetworkMode: network,
ShmSize: 1024 ** 3,
AutoRemove: true,
},
});
}
async start(id: string, saves: SaveFiles): Promise<void> {
const container = this.docker.getContainer(containerName(id));
if (Object.keys(saves).length > 0) {
await container.putArchive(await toTar(saves, this.options.uid, this.options.gid), { path: HOME });
}
await container.start();
await waitForHttp(this.url(id) + streamPath(id), READY_TIMEOUT_MS);
}
async getSaves(id: string): Promise<SaveFiles> {
try {
const archive = await this.docker.getContainer(containerName(id)).getArchive({ path: `${HOME}/${SAVES_DIR}` });
return await fromTar(archive);
} catch (err) {
if ((err as { statusCode?: number }).statusCode === 404) {
return {};
}
throw err;
}
}
async remove(id: string): Promise<void> {
await this.docker
.getContainer(containerName(id))
.remove({ force: true })
.catch((err: { statusCode?: number }) => {
if (err.statusCode !== 404 && err.statusCode !== 409) {
throw err;
}
});
}
async removeAll(): Promise<void> {
const containers = await this.docker.listContainers({ all: true, filters: { label: [LABEL] } });
await Promise.all(containers.map((c) => this.remove(c.Labels[LABEL])));
}
url(id: string): string {
return `http://${containerName(id)}:3000`;
}
}
/** URL path prefix a session is served under, through the manager and inside its container. */
export function streamPath(id: string): string {
return `/stream/${id}/`;
}
function containerName(id: string): string {
return `fp-stream-${id}`;
}
async function waitForHttp(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ok = await fetch(url).then((res) => res.ok, () => false);
if (ok) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`timed out waiting for ${url}`);
}
+99
View File
@@ -0,0 +1,99 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { buffer } from 'node:stream/consumers';
import { DatabaseSync } from 'node:sqlite';
import yauzl from 'yauzl';
/** Launch applications fp-launch can stream, lowercased. */
const STREAMABLE_APPS = new Set(['fpsoftware\\startjava.bat', 'fpsoftware\\startjavainbrowser.bat']);
const DEFAULT_SIZE: Size = { width: 800, height: 600 };
export interface Size {
width: number;
height: number;
}
interface LaunchRow {
app: string;
cmd: string;
dateAdded: string | null;
}
/** Looks up streamable games in a Flashpoint install. */
export class GameCatalog {
private readonly root: string;
private readonly db: DatabaseSync;
constructor(root: string) {
this.root = root;
this.db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite'), { readOnly: true });
}
/** Returns the display size for a streamable game, or null if it can't be streamed. */
async streamSize(gameId: string): Promise<Size | null> {
const row = this.db
.prepare(
`select coalesce(nullif(d.applicationPath, ''), g.applicationPath) as app,
coalesce(nullif(d.launchCommand, ''), g.launchCommand) as cmd,
d.dateAdded as dateAdded
from game g left join game_data d on d.id = g.activeDataId
where g.id = ?`,
)
.get(gameId) as unknown as LaunchRow | undefined;
if (!row || !STREAMABLE_APPS.has(row.app.toLowerCase())) {
return null;
}
const page = await this.readLaunchPage(gameId, row).catch(() => null);
return (page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE;
}
private async readLaunchPage(gameId: string, row: LaunchRow): Promise<Buffer | null> {
const url = new URL(row.cmd);
const page = url.pathname.endsWith('/') ? `${url.pathname}index.html` : url.pathname;
const file = url.hostname + decodeURIComponent(page);
if (row.dateAdded) {
const zip = path.join(this.root, 'Data', 'Games', `${gameId}-${new Date(row.dateAdded).getTime()}.zip`);
return readZipEntry(zip, `content/${file}`);
}
return readFile(path.join(this.root, 'Legacy', 'htdocs', file));
}
}
/** Reads the width and height of the first applet on a page. */
export function appletSize(html: string): Size | null {
const tag = html.match(/<applet\b[^>]*>/i)?.[0];
if (!tag) {
return null;
}
const attr = (name: string) => Number(tag.match(new RegExp(`\\b${name}\\s*=\\s*["']?(\\d+)(?![\\d%])`, 'i'))?.[1]);
const width = attr('width');
const height = attr('height');
return width > 0 && height > 0 ? { width, height } : null;
}
/** Reads one entry from a zip, matching its name case-insensitively. */
function readZipEntry(zipPath: string, name: string): Promise<Buffer | null> {
const wanted = name.toLowerCase();
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zip) => {
if (err) {
return reject(err);
}
zip.on('error', reject);
zip.on('end', () => resolve(null));
zip.on('entry', (entry: yauzl.Entry) => {
if (entry.fileName.toLowerCase() !== wanted) {
return zip.readEntry();
}
zip.openReadStream(entry, (err, stream) => {
if (err) {
return reject(err);
}
buffer(stream).then(resolve, reject).finally(() => zip.close());
});
});
zip.readEntry();
});
});
}
+34
View File
@@ -0,0 +1,34 @@
import { DockerDriver } from './docker.ts';
import { GameCatalog } from './games.ts';
import { createApiServer, createStreamServer } from './server.ts';
import { SessionManager } from './sessions.ts';
function env(name: string, fallback?: string): string {
const value = process.env[name] || fallback;
if (value === undefined) {
throw new Error(`${name} is required`);
}
return value;
}
const driver = new DockerDriver({
image: env('STREAMER_IMAGE', 'flashpoint-streamer'),
network: env('STREAMER_NETWORK', 'flashpoint-streams'),
flashpointHostPath: env('FLASHPOINT_HOST_PATH'),
uid: Number(env('PUID', '1000')),
gid: Number(env('PGID', '1000')),
});
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, 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');
process.once('SIGTERM', async () => {
api.close();
stream.close();
await sessions.teardownAll();
process.exit(0);
});
+72
View File
@@ -0,0 +1,72 @@
import { buffer } from 'node:stream/consumers';
import tar from 'tar-stream';
/** Save files by path relative to the saves directory, contents base64-encoded. */
export type SaveFiles = Record<string, string>;
/** Name of the saves directory under the streamer's home, and of its entry in archives. */
export const SAVES_DIR = 'saves';
const MAX_BYTES = 10 * 1024 * 1024;
/** Validates untrusted save files from a browser, rejecting paths outside the saves directory. */
export function parseSaveFiles(value: unknown): SaveFiles {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('files must be an object');
}
let total = 0;
for (const [path, data] of Object.entries(value)) {
const parts = path.split('/');
if (path.startsWith('/') || parts.some((p) => p === '' || p === '.' || p === '..')) {
throw new Error(`invalid save path: ${path}`);
}
if (typeof data !== 'string') {
throw new Error(`invalid save data: ${path}`);
}
total += data.length;
}
if (total > MAX_BYTES) {
throw new Error('saves too large');
}
return value as SaveFiles;
}
/** Packs save files into a tar whose entries sit under the saves directory. */
export async function toTar(files: SaveFiles, uid: number, gid: number): Promise<Buffer> {
const pack = tar.pack();
const dirs = new Set<string>([SAVES_DIR]);
for (const path of Object.keys(files)) {
const parts = path.split('/');
for (let i = 1; i < parts.length; i++) {
dirs.add(`${SAVES_DIR}/${parts.slice(0, i).join('/')}`);
}
}
for (const dir of dirs) {
pack.entry({ name: dir, type: 'directory', mode: 0o755, uid, gid });
}
for (const [path, data] of Object.entries(files)) {
pack.entry({ name: `${SAVES_DIR}/${path}`, mode: 0o644, uid, gid }, Buffer.from(data, 'base64'));
}
pack.finalize();
const chunks: Buffer[] = [];
for await (const chunk of pack) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks);
}
/** Reads the regular files from a tar of the saves directory. */
export async function fromTar(stream: NodeJS.ReadableStream): Promise<SaveFiles> {
const files: SaveFiles = {};
const extract = tar.extract();
stream.pipe(extract);
for await (const entry of extract) {
const { name, type } = entry.header;
if (type === 'file' && name.startsWith(`${SAVES_DIR}/`)) {
files[name.slice(SAVES_DIR.length + 1)] = (await buffer(entry)).toString('base64');
} else {
entry.resume();
}
}
return files;
}
+145
View File
@@ -0,0 +1,145 @@
import http from 'node:http';
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';
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: 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 (!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');
}
const { id, size } = await sessions.create(gameId);
sendJson(res, 201, { id, path: streamPath(id), ...size });
});
});
}
/** Public server: starts sessions, streams their saves, and proxies Selkies. */
export function createStreamServer(sessions: SessionManager, driver: Driver): http.Server {
const proxy = httpProxy.createProxyServer({ ws: true });
proxy.on('error', (err, _req, res) => {
if (res instanceof http.ServerResponse && !res.headersSent) {
res.writeHead(502).end();
} else {
res.destroy();
}
});
const server = http.createServer((req, res) => {
handle(res, async () => {
const [, id, rest] = req.url?.match(STREAM_URL) ?? [];
if (!id || !sessions.has(id)) {
throw new SessionError(404, 'no such session');
}
if (rest === '/_fp/start' && req.method === 'POST') {
const { files } = await readJson(req);
let saves;
try {
saves = parseSaveFiles(files ?? {});
} catch (err) {
throw new SessionError(400, (err as Error).message);
}
await sessions.start(id, saves);
res.writeHead(204).end();
} else if (rest === '/_fp/saves' && req.method === 'GET') {
streamSaves(sessions, id, req, res);
} else {
proxy.web(req, res, { target: driver.url(id) });
}
});
});
server.on('upgrade', (req, socket, head) => {
const [, id] = req.url?.match(STREAM_URL) ?? [];
if (!id || !sessions.has(id)) {
socket.destroy();
return;
}
sessions.socketOpened(id);
socket.on('close', () => sessions.socketClosed(id));
proxy.ws(req, socket, head, { target: driver.url(id) });
});
return server;
}
function streamSaves(sessions: SessionManager, id: string, req: http.IncomingMessage, res: http.ServerResponse): void {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
const heartbeat = setInterval(() => res.write(':\n\n'), HEARTBEAT_MS);
const stop = sessions.watchSaves(id, (files) => {
if (files) {
res.write(`data: ${JSON.stringify({ files })}\n\n`);
} else {
res.end('event: end\ndata:\n\n');
}
});
req.on('close', () => {
clearInterval(heartbeat);
stop();
});
}
async function handle(res: http.ServerResponse, fn: () => Promise<void>): Promise<void> {
try {
await fn();
} catch (err) {
const status = err instanceof SessionError ? err.status : 500;
if (status === 500) {
console.error(err);
}
if (!res.headersSent) {
sendJson(res, status, { error: err instanceof SessionError ? err.message : 'internal error' });
}
}
}
async function readJson(req: http.IncomingMessage): Promise<Record<string, unknown>> {
if (Number(req.headers['content-length']) > MAX_BODY_BYTES) {
throw new SessionError(413, 'body too large');
}
const body = await text(req);
try {
const value = body ? JSON.parse(body) : {};
if (typeof value === 'object' && value !== null) {
return value;
}
} catch {
// Fall through to the error below.
}
throw new SessionError(400, 'invalid JSON body');
}
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body));
}
function tokenMatches(header: string | undefined, token: string): boolean {
const given = Buffer.from(header ?? '');
const expected = Buffer.from(`Bearer ${token}`);
return given.length === expected.length && timingSafeEqual(given, expected);
}
+185
View File
@@ -0,0 +1,185 @@
import { randomBytes } from 'node:crypto';
import type { Driver } from './docker.ts';
import type { GameCatalog, Size } from './games.ts';
import type { SaveFiles } from './saves.ts';
export interface SessionTimings {
/** How long a new session may wait for its first connection. */
connectTimeoutMs: number;
/** How long a session survives with no connections, to allow reconnecting. */
idleGraceMs: number;
/** How often saves are read from a running session. */
savesPollMs: number;
}
export const DEFAULT_TIMINGS: SessionTimings = {
connectTimeoutMs: 120_000,
idleGraceMs: 30_000,
savesPollMs: 2_000,
};
export class SessionError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
type SavesListener = (files: SaveFiles | null) => void;
interface Session {
id: string;
state: 'created' | 'starting' | 'running';
sockets: number;
teardownTimer?: NodeJS.Timeout;
pollTimer?: NodeJS.Timeout;
saves: string;
listeners: Set<SavesListener>;
}
/** Tracks streamer sessions and tears each down once its player is gone. */
export class SessionManager {
private readonly sessions = new Map<string, Session>();
private readonly driver: Driver;
private readonly catalog: Pick<GameCatalog, 'streamSize'>;
private readonly maxSessions: number;
private readonly timings: SessionTimings;
constructor(
driver: Driver,
catalog: Pick<GameCatalog, 'streamSize'>,
maxSessions: number,
timings: SessionTimings = DEFAULT_TIMINGS,
) {
this.driver = driver;
this.catalog = catalog;
this.maxSessions = maxSessions;
this.timings = timings;
}
async create(gameId: string): Promise<{ id: string; size: Size }> {
if (this.sessions.size >= this.maxSessions) {
throw new SessionError(503, 'too many sessions');
}
const size = await this.catalog.streamSize(gameId);
if (!size) {
throw new SessionError(422, 'game is not streamable');
}
const id = randomBytes(16).toString('hex');
const session: Session = { id, state: 'created', sockets: 0, saves: '{}', listeners: new Set() };
this.sessions.set(id, session);
try {
await this.driver.create(id, gameId, size);
} catch (err) {
this.sessions.delete(id);
throw err;
}
this.scheduleTeardown(session, this.timings.connectTimeoutMs);
return { id, size };
}
has(id: string): boolean {
return this.sessions.has(id);
}
/** Starts a session's container with the player's saves. */
async start(id: string, saves: SaveFiles): Promise<void> {
const session = this.get(id);
if (session.state !== 'created') {
throw new SessionError(409, 'session already started');
}
session.state = 'starting';
session.saves = canonical(saves);
try {
await this.driver.start(id, saves);
} catch (err) {
await this.teardown(id);
throw err;
}
session.state = 'running';
this.startPolling(session);
}
socketOpened(id: string): void {
const session = this.sessions.get(id);
if (session) {
session.sockets++;
clearTimeout(session.teardownTimer);
}
}
socketClosed(id: string): void {
const session = this.sessions.get(id);
if (session && --session.sockets === 0) {
this.scheduleTeardown(session, this.timings.idleGraceMs);
}
}
/** Calls the listener with the current saves and whenever they change, then with null at teardown. */
watchSaves(id: string, listener: SavesListener): () => void {
const session = this.get(id);
session.listeners.add(listener);
listener(JSON.parse(session.saves));
return () => session.listeners.delete(listener);
}
async teardown(id: string): Promise<void> {
const session = this.sessions.get(id);
if (!session) {
return;
}
this.sessions.delete(id);
clearTimeout(session.teardownTimer);
clearInterval(session.pollTimer);
if (session.state === 'running') {
await this.pollSaves(session);
}
for (const listener of session.listeners) {
listener(null);
}
await this.driver.remove(id);
}
async teardownAll(): Promise<void> {
await Promise.all([...this.sessions.keys()].map((id) => this.teardown(id)));
}
private get(id: string): Session {
const session = this.sessions.get(id);
if (!session) {
throw new SessionError(404, 'no such session');
}
return session;
}
private scheduleTeardown(session: Session, delayMs: number): void {
clearTimeout(session.teardownTimer);
session.teardownTimer = setTimeout(() => {
this.teardown(session.id).catch((err) => console.error(`Failed to tear down ${session.id}:`, err));
}, delayMs);
}
private startPolling(session: Session): void {
session.pollTimer = setInterval(() => {
this.pollSaves(session).catch((err) => console.error(`Failed to read saves for ${session.id}:`, err));
}, this.timings.savesPollMs);
}
private async pollSaves(session: Session): Promise<void> {
const files = await this.driver.getSaves(session.id);
const json = canonical(files);
if (json === session.saves) {
return;
}
session.saves = json;
for (const listener of session.listeners) {
listener(files);
}
}
}
function canonical(files: SaveFiles): string {
return JSON.stringify(files, Object.keys(files).sort());
}
+57
View File
@@ -0,0 +1,57 @@
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { WebSocketServer } from 'ws';
import type { Driver } from '../src/docker.ts';
import type { Size } from '../src/games.ts';
import type { SaveFiles } from '../src/saves.ts';
/** Driver whose "containers" share one local HTTP and WebSocket server. */
export class FakeDriver implements Driver {
readonly containers = new Map<string, { gameId: string; size: Size; saves: SaveFiles; started: boolean }>();
readonly removed: string[] = [];
private server?: http.Server;
async listen(): Promise<void> {
this.server = http.createServer((req, res) => res.end(`upstream ${req.url}`));
new WebSocketServer({ server: this.server }).on('connection', (ws) => ws.on('message', (m) => ws.send(`echo ${m}`)));
await new Promise<void>((resolve) => this.server!.listen(0, resolve));
}
close(): void {
this.server?.closeAllConnections();
this.server?.close();
}
async create(id: string, gameId: string, size: Size): Promise<void> {
this.containers.set(id, { gameId, size, saves: {}, started: false });
}
async start(id: string, saves: SaveFiles): Promise<void> {
Object.assign(this.containers.get(id)!, { saves: { ...saves }, started: true });
}
async getSaves(id: string): Promise<SaveFiles> {
return { ...this.containers.get(id)!.saves };
}
async remove(id: string): Promise<void> {
this.containers.delete(id);
this.removed.push(id);
}
async removeAll(): Promise<void> {}
url(): string {
return `http://127.0.0.1:${(this.server!.address() as AddressInfo).port}`;
}
}
export const catalog = {
async streamSize(gameId: string): Promise<Size | null> {
return gameId === 'java' || gameId === '247a4c93-c252-49c8-8bc9-fb43d5c039df' ? { width: 472, height: 354 } : null;
},
};
export const fastTimings = { connectTimeoutMs: 300, idleGraceMs: 100, savesPollMs: 20 };
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, writeFileSync, createWriteStream } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { finished } from 'node:stream/promises';
import { test } from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import yazl from 'yazl';
import { appletSize, GameCatalog } from '../src/games.ts';
test('appletSize reads the first applet tag', () => {
assert.deepEqual(appletSize('<APPLET code="a.class" WIDTH=472 height="354" mayscript>'), { width: 472, height: 354 });
assert.deepEqual(appletSize(`<applet width='640' height='480'><applet width=1 height=1>`), { width: 640, height: 480 });
assert.equal(appletSize('<applet width="100%" height="400">'), null);
assert.equal(appletSize('<embed src="game.swf" width="550" height="400">'), null);
});
test('GameCatalog sizes streamable games from their GameZIP or htdocs', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'fp-'));
mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true });
mkdirSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game'), { recursive: true });
writeFileSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game', 'index.html'), '<applet width=300 height=200>');
const zip = new yazl.ZipFile();
zip.addBuffer(Buffer.from('<applet code="x" width="472" height="354">'), 'content/www.popcap.com/Insaniquarium/Java/index.html');
zip.end();
await finished(zip.outputStream.pipe(createWriteStream(path.join(root, 'Data', 'Games', 'zip-1690495544502.zip'))));
const db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite'));
db.exec(`
create table game (id, applicationPath, launchCommand, activeDataId);
create table game_data (id integer primary key, applicationPath, launchCommand, dateAdded);
insert into game values ('zip', 'FPSoftware\\startJavaInBrowser.bat', 'http://www.popcap.com/Insaniquarium/Java/INDEX.html', 1);
insert into game_data values (1, '', '', '2023-07-27T22:05:44.502567Z');
insert into game values ('legacy', 'FPSoftware\\startJava.bat', 'http://example.com/my%20game/', null);
insert into game values ('missing', 'FPSoftware\\startJava.bat', 'http://example.com/none.html', null);
insert into game values ('flash', 'FPSoftware\\Flash\\flashplayer_32_sa.exe', 'http://example.com/a.swf', null);
`);
db.close();
const catalog = new GameCatalog(root);
assert.deepEqual(await catalog.streamSize('zip'), { width: 472, height: 354 });
assert.deepEqual(await catalog.streamSize('legacy'), { width: 300, height: 200 });
assert.deepEqual(await catalog.streamSize('missing'), { width: 800, height: 600 });
assert.equal(await catalog.streamSize('flash'), null);
assert.equal(await catalog.streamSize('unknown'), null);
});
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { test } from 'node:test';
import { fromTar, parseSaveFiles, toTar } from '../src/saves.ts';
test('parseSaveFiles rejects paths outside the saves directory', () => {
for (const path of ['/etc/passwd', '../x', 'a/../../x', 'a//b', './x', '']) {
assert.throws(() => parseSaveFiles({ [path]: '' }), /invalid save path/);
}
assert.throws(() => parseSaveFiles({ a: 1 }), /invalid save data/);
assert.throws(() => parseSaveFiles([]), /must be an object/);
assert.deepEqual(parseSaveFiles({ 'a/b.sol': 'AA==' }), { 'a/b.sol': 'AA==' });
});
test('save files survive a tar round trip', async () => {
const files = {
'cookies.txt': Buffer.from('INSANIQUARIUM=abc\n').toString('base64'),
'nested/dir/data.bin': Buffer.from([0, 255, 128, 10]).toString('base64'),
};
const tar = await toTar(files, 1000, 1000);
assert.deepEqual(await fromTar(Readable.from([tar])), files);
});
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
import { after, before, test } from 'node:test';
import WebSocket from 'ws';
import { createApiServer, createStreamServer } from '../src/server.ts';
import { SessionManager } from '../src/sessions.ts';
import { catalog, FakeDriver, fastTimings, sleep } from './fakes.ts';
const driver = new FakeDriver();
const sessions = new SessionManager(driver, catalog, 4, fastTimings);
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;
before(async () => {
await driver.listen();
await new Promise<void>((resolve) => api.listen(0, resolve));
await new Promise<void>((resolve) => stream.listen(0, resolve));
apiUrl = `http://127.0.0.1:${(api.address() as AddressInfo).port}`;
streamUrl = `http://127.0.0.1:${(stream.address() as AddressInfo).port}`;
});
after(async () => {
await sessions.teardownAll();
api.close();
stream.closeAllConnections();
stream.close();
driver.close();
});
function createSession(gameId: string, token = 'secret') {
return fetch(`${apiUrl}/sessions`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ gameId }),
});
}
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);
const res = await createSession('java');
assert.equal(res.status, 201);
const body = await res.json();
assert.match(body.path, /^\/stream\/[0-9a-f]{32}\/$/);
assert.equal(body.width, 472);
await sessions.teardown(body.id);
});
test('a session starts with saves, proxies HTTP and WebSocket, streams saves, and ends on disconnect', async () => {
const { id, path } = await (await createSession('java')).json();
assert.equal((await fetch(`${streamUrl}/stream/${'0'.repeat(32)}/`)).status, 404);
assert.equal(
(await fetch(`${streamUrl}${path}_fp/start`, { method: 'POST', body: JSON.stringify({ files: { '../x': '' } }) })).status,
400,
);
const start = await fetch(`${streamUrl}${path}_fp/start`, {
method: 'POST',
body: JSON.stringify({ files: { 'cookies.txt': 'YQ==' } }),
});
assert.equal(start.status, 204);
assert.deepEqual(driver.containers.get(id)!.saves, { 'cookies.txt': 'YQ==' });
assert.equal(await (await fetch(`${streamUrl}${path}index.html`)).text(), `upstream ${path}index.html`);
const ws = new WebSocket(`${streamUrl.replace('http', 'ws')}${path}websocket`);
const reply = await new Promise((resolve, reject) => {
ws.on('open', () => ws.send('hi'));
ws.on('message', (m) => resolve(m.toString()));
ws.on('error', reject);
});
assert.equal(reply, 'echo hi');
const events = await fetch(`${streamUrl}${path}_fp/saves`);
const reader = events.body!.pipeThrough(new TextDecoderStream()).getReader();
assert.equal((await reader.read()).value, `data: ${JSON.stringify({ files: { 'cookies.txt': 'YQ==' } })}\n\n`);
driver.containers.get(id)!.saves['cookies.txt'] = 'Yg==';
assert.equal((await reader.read()).value, `data: ${JSON.stringify({ files: { 'cookies.txt': 'Yg==' } })}\n\n`);
await sleep(400);
assert.equal(sessions.has(id), true, 'kept alive while the WebSocket is open');
ws.close();
assert.equal((await reader.read()).value, 'event: end\ndata:\n\n');
assert.equal(sessions.has(id), false);
assert.ok(driver.removed.includes(id));
});
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { SessionError, SessionManager } from '../src/sessions.ts';
import { catalog, FakeDriver, fastTimings, sleep } from './fakes.ts';
test('rejects unstreamable games and enforces the session cap', async () => {
const sessions = new SessionManager(new FakeDriver(), catalog, 1, fastTimings);
await assert.rejects(sessions.create('flash'), (e: SessionError) => e.status === 422);
const { id, size } = await sessions.create('java');
assert.deepEqual(size, { width: 472, height: 354 });
await assert.rejects(sessions.create('java'), (e: SessionError) => e.status === 503);
await sessions.teardown(id);
await sessions.create('java');
await sessions.teardownAll();
});
test('removes a session that is never connected to', async () => {
const driver = new FakeDriver();
const sessions = new SessionManager(driver, catalog, 4, fastTimings);
const { id } = await sessions.create('java');
await sessions.start(id, {});
await sleep(400);
assert.equal(sessions.has(id), false);
assert.deepEqual(driver.removed, [id]);
});
test('keeps a session while connected and survives a quick reconnect', async () => {
const driver = new FakeDriver();
const sessions = new SessionManager(driver, catalog, 4, fastTimings);
const { id } = await sessions.create('java');
await sessions.start(id, {});
sessions.socketOpened(id);
await sleep(400);
assert.equal(sessions.has(id), true);
sessions.socketClosed(id);
await sleep(50);
sessions.socketOpened(id);
await sleep(200);
assert.equal(sessions.has(id), true);
sessions.socketClosed(id);
await sleep(200);
assert.equal(sessions.has(id), false);
});
test('starts with the given saves, pushes changes, and ends with a final push', async () => {
const driver = new FakeDriver();
const sessions = new SessionManager(driver, catalog, 4, fastTimings);
const { id } = await sessions.create('java');
await sessions.start(id, { 'cookies.txt': 'YQ==' });
await assert.rejects(sessions.start(id, {}), (e: SessionError) => e.status === 409);
assert.deepEqual(driver.containers.get(id)!.saves, { 'cookies.txt': 'YQ==' });
const seen: unknown[] = [];
sessions.watchSaves(id, (files) => seen.push(files));
driver.containers.get(id)!.saves['cookies.txt'] = 'Yg==';
await sleep(60);
driver.containers.get(id)!.saves['cookies.txt'] = 'Yw==';
await sessions.teardown(id);
assert.deepEqual(seen, [{ 'cookies.txt': 'YQ==' }, { 'cookies.txt': 'Yg==' }, { 'cookies.txt': 'Yw==' }, null]);
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src", "test"]
}
+1 -1
View File
@@ -37,6 +37,6 @@ Selkies restarts the game when it exits (`RESTART_APP`).
`appletviewer` lacks two things applets got from the browser's Java plugin, so
the image adds them: a `netscape.javascript.JSObject` stand-in whose window has
a working `document.cookie`, and a policy allowing HTTP requests through the
a `document.cookie` that persists to `/config/saves/cookies.txt`, and a policy allowing HTTP requests through the
game server's proxy. The same jar (`applet-support/`) is loaded as a Java agent
that removes appletviewer's menu bar and status line.
@@ -1,25 +1,37 @@
package netscape.javascript;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** A document whose cookie member behaves like document.cookie in a browser. */
/**
* A document whose cookie member behaves like document.cookie in a browser.
* Cookies persist to ~/saves/cookies.txt, one name=value pair per line.
*/
class Document extends JSObject {
private final File file = AccessController.doPrivileged(
(PrivilegedAction<File>) () -> new File(System.getProperty("user.home"), "saves/cookies.txt"));
private final Map<String, String> cookies = new LinkedHashMap<String, String>();
Document() {
for (String line : AccessController.doPrivileged((PrivilegedAction<List<String>>) this::read)) {
put(line);
}
}
@Override
public Object getMember(String name) throws JSException {
if (!name.equals("cookie")) {
return super.getMember(name);
}
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> cookie : cookies.entrySet()) {
if (sb.length() > 0) {
sb.append("; ");
}
sb.append(cookie.getKey()).append('=').append(cookie.getValue());
}
return sb.toString();
return String.join("; ", lines());
}
@Override
@@ -28,10 +40,44 @@ class Document extends JSObject {
super.setMember(name, value);
return;
}
String pair = String.valueOf(value).split(";", 2)[0];
if (put(String.valueOf(value).split(";", 2)[0])) {
AccessController.doPrivileged((PrivilegedAction<Void>) this::write);
}
}
private boolean put(String pair) {
int eq = pair.indexOf('=');
if (eq > 0) {
if (eq <= 0) {
return false;
}
cookies.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
return true;
}
private List<String> lines() {
List<String> lines = new ArrayList<String>();
for (Map.Entry<String, String> cookie : cookies.entrySet()) {
lines.add(cookie.getKey() + "=" + cookie.getValue());
}
return lines;
}
private List<String> read() {
try {
return file.isFile() ? Files.readAllLines(file.toPath(), StandardCharsets.UTF_8) : new ArrayList<String>();
} catch (IOException e) {
System.err.println("Failed to read " + file + ": " + e);
return new ArrayList<String>();
}
}
private Void write() {
try {
file.getParentFile().mkdirs();
Files.write(file.toPath(), lines(), StandardCharsets.UTF_8);
} catch (IOException e) {
System.err.println("Failed to write " + file + ": " + e);
}
return null;
}
}
@@ -1,3 +1,9 @@
grant {
permission java.net.URLPermission "http:*", "*:*";
};
grant codeBase "file:/opt/flashpoint/applet-support.jar" {
permission java.util.PropertyPermission "user.home", "read";
permission java.io.FilePermission "${user.home}${/}saves", "read,write";
permission java.io.FilePermission "${user.home}${/}saves${/}-", "read,write";
};