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
+34 -2
View File
@@ -3,7 +3,11 @@
// with the fewest rooms takes a new one). A room keeps its project's
// document open on F (a direct connection: what the simulation leaves goes
// into the document even when no one has it open) and gets every change of
// it; what the room writes comes back as the room's own change.
// it; what the room writes comes back as the room's own change. The blobs
// its document names come from F's store when the thread asks; a blob the
// simulation makes is kept before the change that names it is applied (the
// room's messages are taken in order), so that no one is told of a file
// whose bytes are not there.
//
// Who may do what: everyone in the project watches; only with a write grant
// does one start, stop, set the speed, hold "Pakete verwerfen", clear the
@@ -23,6 +27,7 @@ import type { DirectConnection, Hocuspocus } from '@hocuspocus/server';
import * as Y from 'yjs';
import { SIM_CLOSED_CODE, SIM_ROOM_FAILED_CODE, WRITING_KINDS, type ClientMessageKind, type HostMessage, type SyncClosing } from '@mdg-filius/protocol';
import type WebSocket from 'ws';
import type { BlobStore } from './blobs.js';
import { errorText, type Log } from './log.js';
import type { FromRoomThread, ToRoomThread } from './simChannel.js';
@@ -39,6 +44,7 @@ export interface SimPerson {
export interface SimRoomsOptions {
readonly hocuspocus: Hocuspocus;
readonly blobs: BlobStore;
readonly log: Log;
/** The bundled room thread (dist/simWorker.mjs). */
readonly workerUrl: URL;
@@ -64,6 +70,8 @@ interface Room {
frozen: boolean;
linger: ReturnType<typeof setTimeout> | undefined;
ended: boolean;
/** The thread's changes and blobs, taken in order. */
taken: Promise<void>;
}
type RawData = Buffer | ArrayBuffer | Buffer[];
@@ -124,9 +132,30 @@ export class SimRooms {
private fromThread(thread: Thread, message: FromRoomThread): void {
const room = [...thread.rooms].find((each) => each.project === message.room);
if (room === undefined) return;
const inOrder = (work: () => Promise<void> | void) => {
room.taken = room.taken.then(work).catch((error: unknown) => this.options.log.error('sim.blobFailed', { project: room.project, error: errorText(error) }));
};
switch (message.type) {
case 'doc':
Y.applyUpdate(room.doc, message.update, room);
inOrder(() => Y.applyUpdate(room.doc, message.update, room));
return;
case 'blob':
inOrder(async () => void (await this.options.blobs.put(message.bytes)));
return;
case 'needBlobs':
void this.options.blobs.get(message.shas).then(
(found) =>
this.post(room, {
type: 'blobs',
room: room.project,
blobs: [...found].map(([sha, bytes]) => ({ sha, bytes })),
missing: message.shas.filter((sha) => !found.has(sha)),
}),
(error: unknown) => {
this.options.log.error('sim.blobFailed', { project: room.project, error: errorText(error) });
this.post(room, { type: 'blobs', room: room.project, blobs: [], missing: message.shas });
},
);
return;
case 'host':
room.clients.get(message.client)?.socket.send(JSON.stringify(message.message));
@@ -168,6 +197,7 @@ export class SimRooms {
frozen,
linger: undefined,
ended: false,
taken: Promise.resolve(),
};
thread.rooms.add(room);
this.post(room, { type: 'open', room: project, state: Y.encodeStateAsUpdate(doc) });
@@ -267,6 +297,8 @@ export class SimRooms {
const done = (async () => {
this.rooms.delete(room.project);
await this.ask(room, { type: 'close', room: room.project, writeBack });
// What it wrote back is in the document before the document is let go.
await room.taken;
this.forget(room);
await room.direct.disconnect();
this.options.log.info('sim.roomClosed', { project: room.project, writeBack });