feat: F5.1 server F keeps the shared documents

A new package, packages/server: Hocuspocus 3.4.4 on /sync, a project a
document, refused with a reason when the store has not got it; every update
stored in Postgres as it comes and compacted after 500 updates, a minute of
quiet and when the last person leaves (FOR UPDATE on the row written when
the project is made); the janitor runs only here, at loading and 250 ms
after a change that is not its own; readers change nothing; /healthz; JSON
log. On closing it takes no one new and closes the sockets too (Hocuspocus'
close is a message after which a client signs in again on the same socket).
The tests use real clients against a throwaway Postgres in Docker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 13:30:43 +00:00
co-authored by Claude Opus 5.5
parent bd2e725791
commit 88db6eff7e
17 changed files with 1245 additions and 3 deletions
+74
View File
@@ -0,0 +1,74 @@
// Server F's front door (plan §8.2): one HTTP server — /healthz for the
// proxy, the WebSocket /sync for the shared documents; anything else is not
// found. 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.
import { createServer, type Server } from 'node:http';
import type { AddressInfo, Socket } from 'node:net';
import type { Hocuspocus } from '@hocuspocus/server';
import { WebSocketServer } from 'ws';
import type { Log } from './log.js';
import { createSync, type SyncOptions } from './sync.js';
export interface FiliusServer {
readonly port: number;
readonly sync: Hocuspocus;
close(): Promise<void>;
}
export interface ServerOptions extends SyncOptions {
readonly port?: number;
readonly host?: string;
readonly log: Log;
}
function refuse(socket: Socket, status: string): void {
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
}
export async function startServer(options: ServerOptions): Promise<FiliusServer> {
const sync = createSync(options);
const sockets = new WebSocketServer({ noServer: true });
let closing = false;
const http: Server = createServer((request, response) => {
const path = new URL(request.url ?? '/', 'http://filius').pathname;
if (request.method === 'GET' && path === '/healthz') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ status: 'ok', documents: sync.getDocumentsCount(), connections: sync.getConnectionsCount() }));
return;
}
response.writeHead(404, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: 'notFound' }));
});
http.on('upgrade', (request, socket, head) => {
const path = new URL(request.url ?? '/', 'http://filius').pathname;
if (path !== '/sync') {
refuse(socket as Socket, '404 Not Found');
return;
}
if (closing) {
refuse(socket as Socket, '503 Service Unavailable');
return;
}
sockets.handleUpgrade(request, socket, head, (ws) => sync.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,
async close() {
closing = true;
sync.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; sync.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: sync.getDocumentsCount() });
},
};
}