Add session manager for per-player streams
Creates a streamer container per player on flashpoint-web's request, sized to the game's applet, proxies its Selkies stream under /stream/<id>/, and removes it when the player disconnects. Saves under /config/saves are seeded from the browser at start and pushed back over server-sent events.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# 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 |
|
||||
|---|---|---|
|
||||
| `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.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -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"]
|
||||
+1492
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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": {
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"dockerode": "^5.0.1",
|
||||
"http-proxy": "^1.18.1",
|
||||
"tar-stream": "^3.2.2",
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { buffer } from 'node:stream/consumers';
|
||||
import Database from 'better-sqlite3';
|
||||
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: Database.Database;
|
||||
|
||||
constructor(root: string) {
|
||||
this.root = root;
|
||||
this.db = new Database(path.join(root, 'Data', 'flashpoint.sqlite'), { readonly: true, fileMustExist: 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 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 sessions = new SessionManager(
|
||||
driver,
|
||||
new GameCatalog(env('FLASHPOINT_PATH', '/flashpoint')),
|
||||
Number(env('MAX_SESSIONS', '4')),
|
||||
);
|
||||
|
||||
await driver.removeAll();
|
||||
const api = createApiServer(sessions, 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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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 { 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: creates sessions. */
|
||||
export function createApiServer(sessions: SessionManager, token: string): http.Server {
|
||||
return http.createServer((req, res) => {
|
||||
handle(res, async () => {
|
||||
if (req.method !== 'POST' || req.url !== '/sessions') {
|
||||
throw new SessionError(404, 'not found');
|
||||
}
|
||||
if (!tokenMatches(req.headers.authorization, token)) {
|
||||
throw new SessionError(401, 'unauthorized');
|
||||
}
|
||||
const { 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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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' ? { 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 Database from 'better-sqlite3';
|
||||
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 Database(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,87 @@
|
||||
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, 'secret');
|
||||
const stream = createStreamServer(sessions, driver);
|
||||
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 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]);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2024",
|
||||
"module": "nodenext",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user