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:
@@ -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() });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user