F hands out the editor's page and bundle (editor/ beside main.mjs, or FILIUS_EDITOR_DIR): GET and HEAD only, one wall against leaving the folder, F's own routes first, assets/ cached a year, the rest asked for again; the build lays .br and .gz beside the files and F sends the better one the browser takes (bundle 695 KB to 212 KB). deploy/Dockerfile: two stages, Node 24, no node_modules, not root. deploy/docker-compose.yml with an env file per environment: the file sets the stack's name, volume, image tag and port (test 8081, production 8080). Measured with both stacks side by side behind nginx and Chromium. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
8.4 KiB
TypeScript
176 lines
8.4 KiB
TypeScript
// 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<void>;
|
|
}
|
|
|
|
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<FiliusServer> {
|
|
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<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(), 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<void>((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<void>((resolve) => http.close(() => resolve()));
|
|
options.log.info('server.closed', { documents: hocuspocus.getDocumentsCount() });
|
|
},
|
|
};
|
|
}
|