feat: F5.5 large binary files as blobs on server F

From 16 KB a picture's or binary file's entry names its bytes by their
SHA-256 instead of holding them; the bytes are on F, content-addressed
(GET/POST /blobs with the session's grant, from the editor's origin, at
most 16 MiB). A document's blob side says where they are: the editor's
fetches and uploads, the room thread's asks F's main thread, and a start
waits for the ones asked for; without a side everything stays in the
document. Text stays Y.Text. A blob whose bytes are not there is never
written back empty. A blob F has not got yet is asked for again a few
times, and a running room reads late bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 16:46:50 +00:00
co-authored by Claude Opus 5.5
parent 47beda6b85
commit 4b33e36580
29 changed files with 1185 additions and 29 deletions
+7 -1
View File
@@ -1,6 +1,7 @@
// 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 WebSockets /sync for the shared
// 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);
// anything else is not found. A WebSocket from a page of another
@@ -18,6 +19,8 @@ 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 { errorText, type Log } from './log.js';
import { handleService } from './service.js';
import { holdMessages, SimRooms } from './simRooms.js';
@@ -46,6 +49,7 @@ export interface ServerOptions extends SyncOptions {
readonly secureCookie: boolean;
/** The bundled room thread (dist/simWorker.mjs). */
readonly workerUrl: URL;
readonly blobs: BlobStore;
readonly simThreads?: number;
readonly lingerMilliseconds?: number;
readonly pingMilliseconds?: number;
@@ -65,6 +69,7 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
const { hocuspocus } = sync;
const sims = new SimRooms({
hocuspocus,
blobs: options.blobs,
log: options.log,
workerUrl: options.workerUrl,
...(options.simThreads === undefined ? {} : { threads: options.simThreads }),
@@ -105,6 +110,7 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
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 json(response, 404, { error: 'notFound' });
};
const http: Server = createServer((request, response) => {