// 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), /blobs for the large binary files // of the projects' computers, 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), // and the editor's page and bundle (editorFiles.ts) for anything else; what // is none of these 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), 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 { SIM_REFUSED_CODE, type SyncRefusal } from '@mdg-filius/protocol'; import { WebSocketServer, type WebSocket } from 'ws'; import { exchangeTicket } from './auth.js'; import { handleBlobs } from './blobRoutes.js'; import type { BlobStore } from './blobs.js'; import type { EditorFiles } from './editorFiles.js'; import { errorText, type Log } from './log.js'; import { handleService } from './service.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; } export interface ServerOptions extends SyncOptions { readonly port?: number; readonly host?: string; readonly log: Log; readonly ticketSecret: string; readonly serviceKey: string; /** 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 blobs: BlobStore; /** The editor's page and bundle; without them only F's own routes answer (development, tests). */ readonly editor?: EditorFiles; readonly simThreads?: number; readonly lingerMilliseconds?: number; readonly pingMilliseconds?: number; } function refuse(socket: Socket, status: string): void { socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); } function json(response: ServerResponse, status: number, value: object): void { response.writeHead(status, { 'content-type': 'application/json' }); response.end(JSON.stringify(value)); } export async function startServer(options: ServerOptions): Promise { const sync = createSync(options); const { hocuspocus } = sync; const sims = new SimRooms({ hocuspocus, blobs: options.blobs, 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(); 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(), 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, sims }); else if (path === '/blobs' || path.startsWith('/blobs/')) await handleBlobs(request, response, new URL(request.url ?? '/', 'http://filius'), options); else if (options.editor === undefined || !(await options.editor(request, response, path))) json(response, 404, { error: 'notFound' }); }; const http: Server = createServer((request, response) => { const path = new URL(request.url ?? '/', 'http://filius').pathname; handle(request, response, path).catch((error: unknown) => { options.log.error('http.failed', { path, error: errorText(error) }); if (!response.headersSent) json(response, 500, { error: 'internal' }); else response.end(); }); }); http.on('upgrade', (request, socket, head) => { const path = new URL(request.url ?? '/', 'http://filius').pathname; if (path !== '/sync' && path !== '/sim') { refuse(socket as Socket, '404 Not Found'); return; } const origin = request.headers.origin; if (origin !== undefined && !options.origins.includes(origin)) { options.log.warn('sync.foreignOrigin', { origin }); refuse(socket as Socket, '403 Forbidden'); return; } if (closing) { refuse(socket as Socket, '503 Service Unavailable'); return; } 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((resolve) => http.listen(options.port ?? 0, options.host ?? '0.0.0.0', resolve)); const port = (http.address() as AddressInfo).port; options.log.info('server.listening', { port }); 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'); // Each document is stored and unloaded once its last person has gone. for (let waited = 0; hocuspocus.getDocumentsCount() > 0 && waited < 10_000; waited += 50) await new Promise((resolve) => setTimeout(resolve, 50)); sockets.close(); await new Promise((resolve) => http.close(() => resolve())); options.log.info('server.closed', { documents: hocuspocus.getDocumentsCount() }); }, }; }