feat: F5.3a the simulation rooms on server F

One room per project for everyone in it, held by a few worker_threads,
each room with a replica of the document that Hocuspocus' direct
connection keeps open: changes go in, what the room writes comes back.
/sim?project=... signs in as /sync does and says refusals and partings
as the socket's closing; only a write grant changes the room. The state
names who started the simulation. After the last person the room stays
60 s, then stops, writes back and ends. Freezing stops a running
simulation and refuses to start one, deleting ends the room without
writing back, revoking lets the person go from the room too; a failed
thread ends its rooms, stopping the service stops them all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:29:11 +00:00
co-authored by Claude Opus 5.5
parent 5ded84cb33
commit 8cfc518f0c
17 changed files with 1033 additions and 43 deletions
+75 -10
View File
@@ -1,24 +1,37 @@
// Server F's front door (plan §8.2): one HTTP server — /healthz for the
// proxy, /__auth for the editor's frame (ticket in, cookie out), /svc/* for
// the dashboard (with the service key), the WebSocket /sync for the shared
// documents; anything else is not found. A WebSocket from a page of another
// the dashboard (with the service key), the WebSockets /sync for the shared
// documents and /sim?project=… for the simulation rooms (the session's grant
// decides there as at /sync; a refusal closes the socket with its reason);
// anything else is not found. A WebSocket from a page of another
// origin is refused (a page elsewhere under the same domain would bring the
// person's cookie along: SameSite only tells sites apart). Closed, it first
// takes no one new (a client lets go and comes back at once, which would
// keep its document open), then lets everyone go, waits until every
// document is stored and unloaded, and stops.
// keep its document open), stops every room (what it left written back),
// then lets everyone go, waits until every document is stored and unloaded,
// and stops. A /sim socket that answers no ping for PING_MILLISECONDS is
// let go (the proxy keeps an idle socket an hour; a tablet that went away
// says nothing).
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import type { AddressInfo, Socket } from 'node:net';
import { WebSocketServer } from 'ws';
import { SIM_REFUSED_CODE, type SyncRefusal } from '@mdg-filius/protocol';
import { WebSocketServer, type WebSocket } from 'ws';
import { exchangeTicket } from './auth.js';
import { errorText, type Log } from './log.js';
import { handleService } from './service.js';
import { createSync, type Sync, type SyncOptions } from './sync.js';
import { holdMessages, SimRooms } from './simRooms.js';
import { cookieValue, createSync, SESSION_COOKIE, type Sync, type SyncOptions } from './sync.js';
import { isUuid } from './ticket.js';
export const PING_MILLISECONDS = 30_000;
/** A message to a room at most. */
export const SIM_MESSAGE_BYTES = 1024 * 1024;
export interface FiliusServer {
readonly port: number;
readonly sync: Sync;
readonly sims: SimRooms;
close(): Promise<void>;
}
@@ -31,6 +44,11 @@ export interface ServerOptions extends SyncOptions {
/** The origins the editor is served from (FILIUS_ORIGINS): a WebSocket from another is refused. */
readonly origins: readonly string[];
readonly secureCookie: boolean;
/** The bundled room thread (dist/simWorker.mjs). */
readonly workerUrl: URL;
readonly simThreads?: number;
readonly lingerMilliseconds?: number;
readonly pingMilliseconds?: number;
}
function refuse(socket: Socket, status: string): void {
@@ -45,12 +63,48 @@ function json(response: ServerResponse, status: number, value: object): void {
export async function startServer(options: ServerOptions): Promise<FiliusServer> {
const sync = createSync(options);
const { hocuspocus } = sync;
const sims = new SimRooms({
hocuspocus,
log: options.log,
workerUrl: options.workerUrl,
...(options.simThreads === undefined ? {} : { threads: options.simThreads }),
...(options.lingerMilliseconds === undefined ? {} : { lingerMilliseconds: options.lingerMilliseconds }),
});
const sockets = new WebSocketServer({ noServer: true });
const simSockets = new WebSocketServer({ noServer: true, maxPayload: SIM_MESSAGE_BYTES });
const alive = new WeakSet<WebSocket>();
const pings = setInterval(() => {
for (const socket of simSockets.clients) {
if (!alive.delete(socket)) socket.terminate();
else socket.ping();
}
}, options.pingMilliseconds ?? PING_MILLISECONDS);
let closing = false;
const admit = async (socket: WebSocket, request: IncomingMessage) => {
alive.add(socket);
socket.on('pong', () => alive.add(socket));
const held = holdMessages(socket);
const refuse = (reason: SyncRefusal, user?: string) => {
options.log.info('sim.refused', { reason, ...(user === undefined ? {} : { user }) });
held.release();
socket.close(SIM_REFUSED_CODE, reason);
};
const project = new URL(request.url ?? '/', 'http://filius').searchParams.get('project');
if (!isUuid(project)) return refuse('notAProject');
const key = cookieValue(request.headers.cookie, SESSION_COOKIE);
const access = key === undefined ? ({ kind: 'notSignedIn' } as const) : await options.sessions.access(key, project);
if (access.kind !== 'granted') return refuse(access.kind, access.kind === 'notSignedIn' ? undefined : access.person.login);
try {
await sims.join(socket, project, { user: access.person.user, login: access.person.login, name: access.person.name, write: access.level === 'write' }, access.frozen, held);
} catch (error) {
options.log.warn('sim.openFailed', { project, error: errorText(error) });
refuse('unknownProject', access.person.login);
}
};
const handle = async (request: IncomingMessage, response: ServerResponse, path: string) => {
if (request.method === 'GET' && path === '/healthz') json(response, 200, { status: 'ok', documents: hocuspocus.getDocumentsCount(), connections: hocuspocus.getConnectionsCount() });
if (request.method === 'GET' && path === '/healthz') json(response, 200, { status: 'ok', documents: hocuspocus.getDocumentsCount(), connections: hocuspocus.getConnectionsCount(), rooms: sims.stats().rooms });
else if (request.method === 'GET' && path === '/__auth') await exchangeTicket(request, response, options);
else if (path.startsWith('/svc/')) await handleService(request, response, path, { ...options, sync });
else if (path.startsWith('/svc/')) await handleService(request, response, path, { ...options, sync, sims });
else json(response, 404, { error: 'notFound' });
};
const http: Server = createServer((request, response) => {
@@ -63,7 +117,7 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
});
http.on('upgrade', (request, socket, head) => {
const path = new URL(request.url ?? '/', 'http://filius').pathname;
if (path !== '/sync') {
if (path !== '/sync' && path !== '/sim') {
refuse(socket as Socket, '404 Not Found');
return;
}
@@ -77,7 +131,14 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
refuse(socket as Socket, '503 Service Unavailable');
return;
}
sockets.handleUpgrade(request, socket, head, (ws) => hocuspocus.handleConnection(ws, request));
if (path === '/sim')
simSockets.handleUpgrade(request, socket, head, (ws) =>
admit(ws, request).catch((error: unknown) => {
options.log.error('sim.admitFailed', { error: errorText(error) });
ws.close(1011, 'internal');
}),
);
else sockets.handleUpgrade(request, socket, head, (ws) => hocuspocus.handleConnection(ws, request));
});
await new Promise<void>((resolve) => http.listen(options.port ?? 0, options.host ?? '0.0.0.0', resolve));
const port = (http.address() as AddressInfo).port;
@@ -85,8 +146,12 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
return {
port,
sync,
sims,
async close() {
closing = true;
clearInterval(pings);
await sims.close();
simSockets.close();
hocuspocus.closeConnections();
// Hocuspocus' close is a message on the socket, after which a client signs in again on it: the sockets go too.
for (const socket of sockets.clients) socket.close(1001, 'service stopping');