From 4b33e365806df5e56e8e5203c108435ec23a09b9 Mon Sep 17 00:00:00 2001
From: Julian Kiedaisch
Date: Sun, 4 Oct 2026 16:46:50 +0000
Subject: [PATCH] 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
---
CHANGES.md | 7 +-
package-lock.json | 13 +
packages/doc/package.json | 1 +
packages/doc/src/blobs.ts | 95 +++++++
packages/doc/src/commands.ts | 3 +-
packages/doc/src/files.ts | 20 +-
packages/doc/src/index.ts | 1 +
packages/doc/src/projectDoc.ts | 34 ++-
packages/doc/test/blobs.test.ts | 158 +++++++++++
packages/editor/browser/serverF.ts | 6 +
packages/editor/browser/shared.spec.ts | 50 ++++
packages/editor/src/desktop/fileExplorer.ts | 4 +-
packages/editor/src/i18n/de.json | 1 +
packages/editor/src/i18n/en.json | 1 +
packages/editor/src/remote/SharedProject.tsx | 40 ++-
packages/editor/src/remote/remoteBlobs.ts | 89 ++++++
packages/editor/src/sheet/sheet.css | 16 ++
packages/editor/test/remoteBlobs.test.ts | 135 ++++++++++
packages/server/src/blobRoutes.ts | 72 +++++
packages/server/src/blobs.ts | 52 ++++
packages/server/src/index.ts | 2 +
packages/server/src/main.ts | 4 +
packages/server/src/server.ts | 8 +-
packages/server/src/simChannel.ts | 16 +-
packages/server/src/simRooms.ts | 36 ++-
packages/server/src/simWorker.ts | 63 ++++-
packages/server/test/blobs.test.ts | 268 +++++++++++++++++++
packages/server/test/harness.ts | 10 +-
packages/sim/src/room.ts | 9 +
29 files changed, 1185 insertions(+), 29 deletions(-)
create mode 100644 packages/doc/src/blobs.ts
create mode 100644 packages/doc/test/blobs.test.ts
create mode 100644 packages/editor/src/remote/remoteBlobs.ts
create mode 100644 packages/editor/test/remoteBlobs.test.ts
create mode 100644 packages/server/src/blobRoutes.ts
create mode 100644 packages/server/src/blobs.ts
create mode 100644 packages/server/test/blobs.test.ts
diff --git a/CHANGES.md b/CHANGES.md
index 1ff3eb7..854e85d 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -159,6 +159,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `test/devices.test.ts`, `test/fixtures/new-devices.fls` | — | neu (F4.4a): neue Geräte jeder Art gegen Javas gespeicherte (Zufall maskiert), Kabelregeln, ein selbst gebautes Netz pingt im Kern, Rückgängig nur der eigenen Änderungen über zwei Replikate |
| `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge |
| `test/collaboration.test.ts`, `test/values.test.ts` | — | neu (F4.1): zwei Replikate — zwei Felder eines Geräts, zwei neue Zeilen, gemeinsames Tippen; der Hausmeister nach einem Zusammenführen (I1, I2 auf beiden Replikaten gleich, I4 auch in anderer Schreibweise), idempotent ohne Aktualisierung; `long` einer eigenen Anwendung, Binärdateien |
+| `src/blobs.ts`, `src/projectDoc.ts` (`fileEntry`, `bytesOf`), `src/files.ts` (`holds`), `src/commands.ts`, `test/blobs.test.ts` | Plan §5.1 | neu (F5.5): große Binärdateien der Rechner außerhalb des Dokuments — ab 16 KB trägt der Eintrag eines Bilds oder einer Binärdatei `blob` (SHA-256 der Bytes, hex; `@noble/hashes`, MIT) und `size` statt `data`; Text bleibt `Y.Text`, kleinere Binärdateien bleiben im Eintrag; wo die Bytes sind, sagt die Blob-Seite des Dokuments (`attachBlobs`), ohne sie bleibt alles im Dokument wie bisher; ein Blob ohne Bytes liest sich als leer, wird so aber nie zurückgeschrieben (sonst wäre die Datei weg); `missingBlobs`, `onBlobsArrive`, `memoryBlobSide` |
## packages/protocol
@@ -202,6 +203,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/room.ts` („Tabellen löschen“), `test/exchangeWindow.test.ts` | Java `Lauscher.reset`, `GUIContainer.closeDialogs/reopenDialogs` | erweitert (F4.7): `clearCapture` leert im Lauf die Listen aller Karten und die verworfenen Frames, jeder Beobachter bekommt sie neu (ab 1 nummeriert), das Kabel leuchtet schon mit dem ersten neuen Frame; im Entwurf `notRunning`. Der Mitschnitt bleibt über Stopp und Start, wie Javas |
| `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker |
| `src/room.ts` („gestartet von“), `test/room.test.ts` | Plan §6.2 | erweitert (F5.3a): ein Client trägt auf F einen Namen; wer die laufende Simulation gestartet hat, steht im Zustand (`startedBy`), bis sie hält oder der Kern scheitert; ein Raum der eigenen Seite nennt niemanden |
+| `src/room.ts` (`blobsArrived`) | — | erweitert (F5.5): kamen Bytes nach, liest die laufende Simulation die Dateien neu |
## packages/editor
@@ -264,7 +266,9 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (`locked`), `src/sheet/SheetLock.tsx`, `src/sheet/DeviceSheet.tsx`, `src/sheet/RouterSheets.tsx`, `src/sheet/FirewallPanel.tsx`, `src/sheet/ServicePanels.tsx`, `src/design/DocumentationSidebar.tsx`, `src/simulation/ModeBar.tsx`, `src/simulation/useSimulation.ts`, `src/i18n/*.json` | Plan §6.2, §6.4, §9.3 | geändert (F5.3b): wer nur liest, sieht alles und ändert nichts — keine Palette, kein Rückgängig, nichts bewegt sich oder hat ein Menü, die Felder des Blatts sind gesperrt (die Reiter bleiben), keine neuen Doku-Elemente, die Simulation wird von ihm weder gestartet noch angehalten; Desktops und Datenaustausch öffnen zum Zuschauen; ein „nein“ des Raums wird gesagt; „Simulation gestartet von …“ und „Nur ansehen“ bzw. „Eingefroren – nur ansehen“ neben den Modusknöpfen |
| `src/remote/presence.ts`, `src/remote/usePresence.ts`, `src/remote/PresenceMarks.tsx`, `src/remote/sharedDoc.ts` (`you`, Awareness), `src/remote/SharedProject.tsx` (`onPeople`), `src/Editor.tsx`, `src/canvas/Canvas.tsx` (`chosenBy`), `src/desktop/DesktopWindow.tsx`, `src/simulation/ViewerWindow.tsx`, `src/simulation/CaptureWindow.tsx`, `src/bridge/dashboardBridge.ts` (`people`), `src/main.tsx`, `src/i18n/*.json` | Plan §7.4 | neu (F5.4): die anderen im Projekt über Yjs-Awareness — jeder Editor sagt, wer er ist (von F), welches Gerät er gewählt und welche Fenster er offen hat, und nichts sonst (kein Mauszeiger, nichts, das sich ohne Anlass ändert; gesendet nur bei einer Änderung); fremde Zustände werden geprüft gelesen; ein Rahmen in der Farbe der Person um ein Gerät, das sie gewählt hat, ein Punkt an einem Fenster, das sie auch offen hat, ein Kreis mit den Initialen je Person neben den Modusknöpfen (eine Person mit zwei Tabs einmal); eine Person ohne Farbe des Dashboards bekommt eine aus ihrer Kennung; die Brücke meldet dem Dashboard die Personen (`people`). Name und Farbe sagt der Editor selbst — wer seinen Editor verändert, könnte den anderen im Projekt einen anderen Namen zeigen |
| `test/presence.test.ts` | — | neu (F5.4): Lesen fremder Zustände, die anderen ohne sich selbst, eine Person einmal, Rahmen und Punkte, Farben, Initialen, Senden nur bei Änderung |
-| `test/remoteSimHost.test.ts`, `browser/shared.spec.ts`, `browser/serverF.ts` | — | neu (F5.3b): `RemoteSimHost` auf nachgebauten Sockets mit gestellter Uhr; drei Personen in drei Browsern an einem echten F (aus dem Bündel gestartet): verschieben, nur lesen, eine Simulation für alle mit Namen, ein Ping in einer Befehlszeile bei der anderen, Eintippen als Leser abgewiesen, eingefroren und aufgetaut, Zugang beendet; Absagen ohne Sitzung und ohne Projekt |
+| `src/remote/remoteBlobs.ts`, `src/remote/SharedProject.tsx` (Blob-Seite), `src/desktop/fileExplorer.ts` (Bildbetrachter), `src/i18n/*.json` | Plan §5.1 | neu (F5.5): die Blob-Seite des Editors auf F — was das Dokument nennt, wird geholt (mit dem Keks; was F noch nicht hat, ein paar Mal im Abstand neu gefragt), was hier entsteht (eine importierte Datei), sofort gehalten und zu F gebracht (einmal wiederholt, dann gesagt); der Bildbetrachter zeichnet neu, wenn die Bytes kommen |
+| `test/remoteBlobs.test.ts` | — | neu (F5.5): Hochladen, Wiederholen, Scheitern (auch bei falschem Namen), Holen je Blob einmal, nachfragen bei 404, Netzverlust |
+| `test/remoteSimHost.test.ts`, `browser/shared.spec.ts`, `browser/serverF.ts` | — | neu (F5.3b), erweitert (F5.5: ein großes Bild, von Mia importiert, bei Ben im Bildbetrachter, in F's Speicher unter seinem SHA-256): `RemoteSimHost` auf nachgebauten Sockets mit gestellter Uhr; drei Personen in drei Browsern an einem echten F (aus dem Bündel gestartet): verschieben, nur lesen, eine Simulation für alle mit Namen, ein Ping in einer Befehlszeile bei der anderen, Eintippen als Leser abgewiesen, eingefroren und aufgetaut, Zugang beendet; Absagen ohne Sitzung und ohne Projekt |
## packages/server
@@ -278,6 +282,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `src/simRooms.ts`, `src/simWorker.ts`, `src/simChannel.ts`, `src/server.ts` (`/sim`), `src/service.ts` (Räume) | Plan §6.1–§6.4, §8.2 | neu (F5.3a): die Simulationsräume auf F — einer je Projekt für alle darin, gehalten von wenigen `worker_threads` (der mit den wenigsten Räumen nimmt den nächsten), jeder Raum mit einer Kopie des Dokuments: Hocuspocus' direkte Verbindung hält das Dokument offen, solange der Raum lebt, jede Änderung geht in den Raum, was der Raum schreibt, kommt als seine Änderung zurück; `/sim?project=…` meldet an wie `/sync` (Keks, Berechtigung, Herkunft), Nachrichten vor der Anmeldung werden gehalten; ändern nur mit Schreibstufe; nach dem Letzten bleibt der Raum 60 s und hält dann, schreibt zurück und endet; Einfrieren hält eine laufende Simulation an (ihr Stand kommt noch ins Projekt), Löschen beendet den Raum ohne Zurückschreiben, Widerruf trennt auch im Raum; ein scheiternder Faden beendet seine Räume; beim Herunterfahren halten alle Räume und schreiben zurück; ein Socket ohne Antwort auf Pings wird getrennt; Nachrichten höchstens 1 MiB; `/healthz` zählt die Räume; der Build bündelt den Faden als `dist/simWorker.mjs` |
| `src/sync.ts` (Zugang gleich zu Beginn, stille Absage) | Plan §8.5 | geändert (F5.3b): jede Verbindung erfährt ihren Zugang sofort nach der Anmeldung (Nachricht `access`), damit der Editor weiß, ob er liest und warum; eine Absage trägt keinen Meldungstext — Hocuspocus schreibt sonst die Meldung eines gescheiterten Hooks auf die Konsole, außerhalb des JSON-Protokolls |
| `src/sync.ts` (`you`), `protocol/src/sync.ts` (`AccessMessage.you`) | Plan §7.4 | erweitert (F5.4): die Zugangsnachricht beim Verbinden nennt die Person (Kennung, Name, Farbe aus der Karte), damit der Editor sich den anderen zeigen kann — der Keks ist für die Seite nicht lesbar |
+| `src/blobs.ts`, `src/blobRoutes.ts`, `src/simWorker.ts` (Blobs), `src/simRooms.ts` (Blobs), `src/simChannel.ts`, `src/server.ts` (`/blobs`), `src/main.ts`, `test/blobs.test.ts` | Plan §5.1 | neu (F5.5): die Blobs auf F, inhaltsadressiert (`filius_blob`, F rechnet den Namen selbst aus); `GET /blobs/?project=` mit Berechtigung am Projekt, `POST /blobs?project=` mit Schreibstufe, nicht eingefroren, nur von der Herkunft des Editors, höchstens 16 MiB; CORS für die Herkünfte des Editors; der Raumfaden fragt nach den Blobs seines Dokuments, ein Start wartet auf die gefragten, was F noch nicht hat, wird ein paar Mal neu gefragt und kommt in die laufende Simulation; ein Blob, den die Simulation macht, wird gespeichert, bevor die Änderung, die ihn nennt, angewandt wird. Noch nicht: Blobs vergessen, die kein Projekt mehr nennt |
| `test/editorConnection.test.ts` | — | neu (F5.3b), erweitert (F5.4: Identität von F, die anderen über die Awareness, auch eine lesende Person, weg mit dem Schließen): `connectSharedDoc` des Editors gegen einen echten F — bereit mit Schreib- und Lesestufe, eingefroren und aufgetaut (neues Dokument, die verirrte Änderung ist weg, Schreiben geht wieder), eingefroren von Beginn an, Absage, Widerruf, Löschen, Loslassen |
| `test/sim.test.ts`, `test/harness.ts` (Faden) | — | neu (F5.3a): echte Sockets, der echte Faden (esbuild bündelt ihn wie der Build), Beispiel internetwork_komplett: ein Start für alle mit Namen, Ping in einer Befehlszeile bei der anderen, Zurückschreiben ins Dokument und eine Datei aus dem Dokument im Raum, Lesestufe, Absagen, Nachlauf, Einfrieren, Löschen, Widerruf, Wahl des Fadens, scheiternder Faden, Herunterfahren, Ping |
| `test/ticket.test.ts`, `test/tickets.ts`, `test/tickets.python.json` | `tools/ticket-oracle` | neu (F5.2): jede Karte des Python-Orakels liest Node wie Python (Nutzlast oder derselbe Grund der Ablehnung); in Node signierte Karten tragen dasselbe JSON zum selben Zeitpunkt (Nodes zlib packt anders, ungepackt byte-gleich); die Ansprüche |
diff --git a/package-lock.json b/package-lock.json
index dab6c27..44840a3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -895,6 +895,18 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
+ "node_modules/@noble/hashes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
+ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
@@ -2843,6 +2855,7 @@
"dependencies": {
"@mdg-filius/core": "0.0.0",
"@mdg-filius/fls": "0.0.0",
+ "@noble/hashes": "2.4.0",
"yjs": "^13.6.33"
}
},
diff --git a/packages/doc/package.json b/packages/doc/package.json
index 9691bce..ceb7ca9 100644
--- a/packages/doc/package.json
+++ b/packages/doc/package.json
@@ -10,6 +10,7 @@
"dependencies": {
"@mdg-filius/core": "0.0.0",
"@mdg-filius/fls": "0.0.0",
+ "@noble/hashes": "2.4.0",
"yjs": "^13.6.33"
}
}
diff --git a/packages/doc/src/blobs.ts b/packages/doc/src/blobs.ts
new file mode 100644
index 0000000..4ca9082
--- /dev/null
+++ b/packages/doc/src/blobs.ts
@@ -0,0 +1,95 @@
+// A computer's large binary files outside the document (plan §5.1: no
+// binary in the CRDT): from BLOB_THRESHOLD_BYTES on, a picture's or other
+// binary file's entry holds `blob` — the SHA-256 of its bytes, in hex — and
+// `size` instead of `data`; the bytes are on F, content-addressed. Text
+// stays Y.Text whatever its size: two type into it together. Below the
+// threshold a binary stays in the entry, which spares a small icon a fetch.
+//
+// Where the bytes are is a document's blob side (attachBlobs): the editor's
+// on F (it fetches and uploads), the room thread's (it asks F's main
+// thread). A document without one — the development page, the tests, the
+// room in a worker of the page — keeps every binary in its entry. Reading a
+// blob whose bytes have not come yet gives no bytes; whoever needs them all
+// asks for the missing ones first (missingBlobs) and hears them come
+// (onBlobsArrive).
+
+import { sha256 } from '@noble/hashes/sha2.js';
+import type * as Y from 'yjs';
+import { ROOTS } from './projectDoc.js';
+
+export const BLOB_THRESHOLD_BYTES = 16 * 1024;
+
+export interface BlobSide {
+ /** A blob's bytes, if this side has them. */
+ bytes(sha: string): Uint8Array | undefined;
+ /** Bytes become a blob: kept here and taken to F. */
+ keep(sha: string, bytes: Uint8Array): void;
+ /** Hears blobs come; returns how to stop. */
+ onArrive(listener: () => void): () => void;
+}
+
+const sides = new WeakMap();
+
+/** Where a document's blobs are, from now on. */
+export function attachBlobs(doc: Y.Doc, side: BlobSide): void {
+ sides.set(doc, side);
+}
+
+export function blobSideOf(doc: Y.Doc | null | undefined): BlobSide | undefined {
+ return doc === null || doc === undefined ? undefined : sides.get(doc);
+}
+
+const hex = (bytes: Uint8Array) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+
+/** A blob's name: the SHA-256 of its bytes, in hex. */
+export function blobSha(bytes: Uint8Array): string {
+ return hex(sha256(bytes));
+}
+
+export const isBlobSha = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
+
+/** Every blob the document's files name. */
+export function blobsOf(doc: Y.Doc): Set {
+ const found = new Set();
+ for (const node of doc.getMap>(ROOTS.nodes).values()) {
+ const files = node.get('files') as Y.Map> | undefined;
+ for (const entry of files?.values() ?? []) {
+ const sha = entry.get('blob');
+ if (isBlobSha(sha)) found.add(sha);
+ }
+ }
+ return found;
+}
+
+/** The blobs the document names whose bytes its side has not got (all, without a side). */
+export function missingBlobs(doc: Y.Doc): string[] {
+ const side = blobSideOf(doc);
+ return [...blobsOf(doc)].filter((sha) => side?.bytes(sha) === undefined);
+}
+
+/** Hears the document's blobs come (nothing without a side); returns how to stop. */
+export function onBlobsArrive(doc: Y.Doc, listener: () => void): () => void {
+ return blobSideOf(doc)?.onArrive(listener) ?? (() => {});
+}
+
+/** A side that keeps blobs in memory and tells when some come (`arrived`); `taken` hears each kept one. */
+export function memoryBlobSide(taken: (sha: string, bytes: Uint8Array) => void = () => {}): BlobSide & { arrived(blobs: ReadonlyMap): void } {
+ const held = new Map();
+ const listeners = new Set<() => void>();
+ return {
+ bytes: (sha) => held.get(sha),
+ keep(sha, bytes) {
+ if (held.has(sha)) return;
+ held.set(sha, bytes);
+ taken(sha, bytes);
+ },
+ onArrive(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ arrived(blobs) {
+ for (const [sha, bytes] of blobs) held.set(sha, bytes);
+ if (blobs.size > 0) for (const listener of listeners) listener();
+ },
+ };
+}
diff --git a/packages/doc/src/commands.ts b/packages/doc/src/commands.ts
index 0a08bd3..a361a87 100644
--- a/packages/doc/src/commands.ts
+++ b/packages/doc/src/commands.ts
@@ -29,6 +29,7 @@ import {
} from '@mdg-filius/core';
import { DEFAULT_MODEM_PORT, modemPair, type ImportedProject } from '@mdg-filius/fls';
import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
+import { blobSideOf } from './blobs.js';
/** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */
export const PALETTE: readonly Exclude[] = ['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink'];
@@ -120,7 +121,7 @@ export function addDevice(doc: Y.Doc, device: NewDevice, origin: unknown): void
const project = newDeviceProject(device);
doc.transact(() => {
const order = Math.max(-1, ...[...nodes(doc).values()].map((node) => node.get('order') as number)) + 1;
- nodes(doc).set(device.id, nodeEntry(project.graph.nodes[0]!, order, project));
+ nodes(doc).set(device.id, nodeEntry(project.graph.nodes[0]!, order, project, blobSideOf(doc)));
}, origin);
}
diff --git a/packages/doc/src/files.ts b/packages/doc/src/files.ts
index bde3107..f794492 100644
--- a/packages/doc/src/files.ts
+++ b/packages/doc/src/files.ts
@@ -11,6 +11,7 @@
import * as Y from 'yjs';
import type { Id, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core';
import { VirtualFileSystem as FileSystem } from '@mdg-filius/core';
+import { blobSha, blobSideOf } from './blobs.js';
import { fileEntry, fileSystemOf, inOrder, ROOTS } from './projectDoc.js';
export function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
@@ -29,11 +30,21 @@ export function replaceText(text: Y.Text, next: string): void {
if (next.length - start - end > 0) text.insert(start, next.slice(start, next.length - end));
}
-/** Whether a document entry holds the content. */
+/**
+ * Whether a document entry holds the content (a blob by its bytes where they
+ * are here, else by its name).
+ */
function holds(entry: Y.Map, content: VirtualFileContent): boolean {
if (entry.get('kind') !== content.kind) return false;
if (content.kind === 'directory' || content.kind === 'text') return true;
- return sameBytes(entry.get('data') as Uint8Array, content.data) && entry.get('mediaType') === content.mediaType;
+ if (entry.get('mediaType') !== content.mediaType) return false;
+ const blob = entry.get('blob');
+ if (typeof blob !== 'string') return sameBytes(entry.get('data') as Uint8Array, content.data);
+ const held = blobSideOf(entry.doc)?.bytes(blob);
+ // A blob whose bytes are not here was read as no bytes: written back so, that is no change (it would empty the file).
+ if (held === undefined && content.data.length === 0) return true;
+ if (entry.get('size') !== content.data.length) return false;
+ return held === undefined ? blobSha(content.data) === blob : sameBytes(held, content.data);
}
/**
@@ -42,6 +53,7 @@ function holds(entry: Y.Map, content: VirtualFileContent): boolean {
* longer the file system's (a renamed entry), numbered anew.
*/
export function writeFiles(node: Y.Map, fileSystem: VirtualFileSystem): void {
+ const blobs = blobSideOf(node.doc);
let files = node.get('files') as Y.Map> | undefined;
const wanted: [string, VirtualFileContent][] = [];
const walk = (folder: string): void => {
@@ -62,9 +74,9 @@ export function writeFiles(node: Y.Map, fileSystem: VirtualFileSystem):
for (const [path, content] of wanted) {
const entry = files.get(path);
if (entry === undefined) {
- files.set(path, fileEntry(order++, content));
+ files.set(path, fileEntry(order++, content, blobs));
} else if (!holds(entry, content)) {
- files.set(path, fileEntry(entry.get('order') as number, content));
+ files.set(path, fileEntry(entry.get('order') as number, content, blobs));
} else if (content.kind === 'text') {
replaceText(entry.get('text') as Y.Text, content.text);
}
diff --git a/packages/doc/src/index.ts b/packages/doc/src/index.ts
index 52c9097..0be74bc 100644
--- a/packages/doc/src/index.ts
+++ b/packages/doc/src/index.ts
@@ -1,4 +1,5 @@
// Public surface of the shared document.
+export * from './blobs.js';
export * from './commands.js';
export * from './documentation.js';
export * from './fields.js';
diff --git a/packages/doc/src/projectDoc.ts b/packages/doc/src/projectDoc.ts
index 6f1c5ab..58705b5 100644
--- a/packages/doc/src/projectDoc.ts
+++ b/packages/doc/src/projectDoc.ts
@@ -35,6 +35,7 @@ import {
} from '@mdg-filius/core';
import type { FlsOwnApplication, ImportedProject } from '@mdg-filius/fls';
import { fromY, toY, type PlainValue } from './values.js';
+import { BLOB_THRESHOLD_BYTES, blobSha, blobSideOf, type BlobSide } from './blobs.js';
export const SCHEMA_VERSION = 1;
@@ -98,23 +99,29 @@ function ownApplications(text: string): FlsOwnApplication[] {
) as FlsOwnApplication[];
}
-export function fileEntry(order: number, content: VirtualFileContent): Y.Map {
+/** A file's entry; a large binary as a blob when the document has a blob side (blobs.ts). */
+export function fileEntry(order: number, content: VirtualFileContent, blobs?: BlobSide): Y.Map {
const entry = new Y.Map();
entry.set('order', order);
entry.set('kind', content.kind);
if (content.kind === 'text') entry.set('text', new Y.Text(content.text));
- if (content.kind === 'binary' || content.kind === 'image') entry.set('data', content.data);
+ if ((content.kind === 'binary' || content.kind === 'image') && blobs !== undefined && content.data.length >= BLOB_THRESHOLD_BYTES) {
+ const sha = blobSha(content.data);
+ blobs.keep(sha, content.data);
+ entry.set('blob', sha);
+ entry.set('size', content.data.length);
+ } else if (content.kind === 'binary' || content.kind === 'image') entry.set('data', content.data);
if ((content.kind === 'binary' || content.kind === 'image') && content.mediaType !== undefined) entry.set('mediaType', content.mediaType);
return entry;
}
/** A device's files, each folder's entries in the order they were made (parents before their entries). */
-function filesMap(files: VirtualFileSystem): Y.Map> {
+function filesMap(files: VirtualFileSystem, blobs: BlobSide | undefined): Y.Map> {
const map = new Y.Map>();
let order = 0;
const walk = (folder: string): void => {
for (const entry of files.entriesInCreationOrder(folder)) {
- map.set(entry.path, fileEntry(order++, entry.content));
+ map.set(entry.path, fileEntry(order++, entry.content, blobs));
if (entry.content.kind === 'directory') walk(entry.path);
}
};
@@ -123,7 +130,7 @@ function filesMap(files: VirtualFileSystem): Y.Map> {
}
/** A device's entry in the document, with what the project holds for it. */
-export function nodeEntry(node: TopologyNode, order: number, project: ImportedProject): Y.Map {
+export function nodeEntry(node: TopologyNode, order: number, project: ImportedProject, blobs?: BlobSide): Y.Map {
const map = new Y.Map();
map.set('order', order);
map.set('kind', node.kind);
@@ -159,7 +166,7 @@ export function nodeEntry(node: TopologyNode, order: number, project: ImportedPr
}
map.set('programs', programs);
const files = project.virtualFileSystems.get(node.id);
- if (files !== undefined) map.set('files', filesMap(files));
+ if (files !== undefined) map.set('files', filesMap(files, blobs));
const own = project.ownApplicationsByNodeId.get(node.id);
if (own !== undefined) map.set('own', ownText(own));
return map;
@@ -202,7 +209,7 @@ export function writeProject(doc: Y.Doc, project: ImportedProject, origin: unkno
meta.set('schema', SCHEMA_VERSION);
meta.set('versionLine', project.versionLine);
const nodes = doc.getMap>(ROOTS.nodes);
- project.graph.nodes.forEach((node, index) => nodes.set(node.id, nodeEntry(node, index, project)));
+ project.graph.nodes.forEach((node, index) => nodes.set(node.id, nodeEntry(node, index, project, blobSideOf(doc))));
const links = doc.getMap>(ROOTS.links);
project.graph.links.forEach((link, index) => links.set(link.id, linkMap(link, index)));
const docs = doc.getMap>(ROOTS.docs);
@@ -225,6 +232,15 @@ export function inOrder(map: Y.Map>): [string, Y.Map][]
* system refuses (its folder missing before the janitor ran, or a name two
* people gave at once in different case) is left out, not the project.
*/
+const NO_BYTES = new Uint8Array(0);
+
+/** A binary entry's bytes: its own, or its blob's (none while they have not come). */
+export function bytesOf(entry: Y.Map): Uint8Array {
+ const blob = entry.get('blob');
+ if (typeof blob === 'string') return blobSideOf(entry.doc)?.bytes(blob) ?? NO_BYTES;
+ return entry.get('data') as Uint8Array;
+}
+
export function fileSystemOf(files: Y.Map>): VirtualFileSystem {
const fileSystem = new VirtualFileSystem();
for (const [path, entry] of inOrder(files)) {
@@ -233,8 +249,8 @@ export function fileSystemOf(files: Y.Map>): VirtualFileSystem {
try {
if (kind === 'directory') fileSystem.createDirectory(path);
else if (kind === 'text') fileSystem.writeTextFile(path, (entry.get('text') as Y.Text).toString());
- else if (kind === 'image') fileSystem.writeImageFile(path, entry.get('data') as Uint8Array, mediaType!);
- else fileSystem.writeBinaryFile(path, entry.get('data') as Uint8Array, mediaType);
+ else if (kind === 'image') fileSystem.writeImageFile(path, bytesOf(entry), mediaType!);
+ else fileSystem.writeBinaryFile(path, bytesOf(entry), mediaType);
} catch {
// Left out (see above).
}
diff --git a/packages/doc/test/blobs.test.ts b/packages/doc/test/blobs.test.ts
new file mode 100644
index 0000000..915904e
--- /dev/null
+++ b/packages/doc/test/blobs.test.ts
@@ -0,0 +1,158 @@
+// A computer's large binary files as blobs (plan §5.1): with a blob side, a
+// picture or binary from 16 KB on is named by its SHA-256 in its entry and
+// kept by the side, a smaller one stays in its entry, a text stays Y.Text
+// whatever its size; without a side everything stays in the document as
+// before. Read back with the side's bytes; a copy of the document whose
+// side has not got them yet reads no bytes, names them as missing, and
+// hears them come. Written again unchanged, nothing changes.
+
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+import * as Y from 'yjs';
+import { VirtualFileSystem } from '@mdg-filius/core';
+import { openProjectArchive } from '@mdg-filius/fls';
+import {
+ attachBlobs,
+ BLOB_THRESHOLD_BYTES,
+ blobsOf,
+ blobSha,
+ changeFiles,
+ emptyProject,
+ isBlobSha,
+ memoryBlobSide,
+ missingBlobs,
+ nodeFiles,
+ onBlobsArrive,
+ writeFiles,
+ writeProject,
+ ROOTS,
+} from '../src/index.js';
+
+const bytes = (length: number, seed = 1) => Uint8Array.from({ length }, (_, index) => (index * 31 + seed) % 251);
+
+/** A document with one computer, its files as given, and a side (when given) attached before it is written. */
+function docWith(files: (fs: VirtualFileSystem) => void, side?: ReturnType): { doc: Y.Doc; node: string } {
+ const doc = new Y.Doc();
+ if (side !== undefined) attachBlobs(doc, side);
+ writeProject(doc, emptyProject());
+ const node = 'pc';
+ doc.transact(() => {
+ const entry = new Y.Map();
+ doc.getMap>(ROOTS.nodes).set(node, entry);
+ const fs = new VirtualFileSystem();
+ files(fs);
+ writeFiles(entry, fs);
+ });
+ return { doc, node };
+}
+
+const entryOf = (doc: Y.Doc, node: string, path: string) => (doc.getMap>(ROOTS.nodes).get(node)!.get('files') as Y.Map>).get(path)!;
+
+describe('blobs', () => {
+ it('names a blob by the SHA-256 of its bytes, in hex', () => {
+ expect(blobSha(new TextEncoder().encode('abc'))).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
+ expect([isBlobSha(blobSha(bytes(3))), isBlobSha('ABC'), isBlobSha(7)]).toEqual([true, false, false]);
+ expect(BLOB_THRESHOLD_BYTES).toBe(16 * 1024);
+ });
+
+ it('keeps a large picture and a large binary as blobs, a small one and every text in the document', () => {
+ const taken: string[] = [];
+ const side = memoryBlobSide((sha) => taken.push(sha));
+ const big = bytes(BLOB_THRESHOLD_BYTES);
+ const { doc, node } = docWith((fs) => {
+ fs.writeImageFile('/big.png', big, 'image/png');
+ fs.writeBinaryFile('/big.bin', bytes(BLOB_THRESHOLD_BYTES + 5, 2), 'application/zip');
+ fs.writeBinaryFile('/small.bin', bytes(BLOB_THRESHOLD_BYTES - 1, 3));
+ fs.writeTextFile('/long.txt', 'x'.repeat(BLOB_THRESHOLD_BYTES * 2));
+ }, side);
+ const picture = entryOf(doc, node, '/big.png');
+ expect([picture.get('blob'), picture.get('size'), picture.has('data'), picture.get('mediaType')]).toEqual([blobSha(big), big.length, false, 'image/png']);
+ expect(entryOf(doc, node, '/big.bin').get('blob')).toBe(blobSha(bytes(BLOB_THRESHOLD_BYTES + 5, 2)));
+ expect(entryOf(doc, node, '/small.bin').get('data')).toEqual(bytes(BLOB_THRESHOLD_BYTES - 1, 3));
+ expect(entryOf(doc, node, '/long.txt').get('text')).toBeInstanceOf(Y.Text);
+ expect(taken.sort()).toEqual([blobSha(big), blobSha(bytes(BLOB_THRESHOLD_BYTES + 5, 2))].sort());
+ // Read back with the side: the bytes.
+ const read = nodeFiles(doc, node)!;
+ expect(read.entry('/big.png')!.content).toEqual({ kind: 'image', data: big, mediaType: 'image/png' });
+ expect(blobsOf(doc)).toEqual(new Set(taken));
+ expect(missingBlobs(doc)).toEqual([]);
+ });
+
+ it('keeps the large pictures of a whole project as blobs when it is written with a side', () => {
+ const big = bytes(BLOB_THRESHOLD_BYTES * 2, 4);
+ const project = openProjectArchive(new Uint8Array(readFileSync(new URL('../../../corpus/java-examples/zwei_rechnernetze_komplett.fls', import.meta.url))), () => crypto.randomUUID());
+ const [node, files] = [...project.virtualFileSystems][0]!;
+ files.writeImageFile('/big.png', big, 'image/png');
+ const taken: string[] = [];
+ const doc = new Y.Doc();
+ attachBlobs(
+ doc,
+ memoryBlobSide((sha) => taken.push(sha)),
+ );
+ writeProject(doc, project);
+ expect(entryOf(doc, node, '/big.png').get('blob')).toBe(blobSha(big));
+ expect(taken).toEqual([blobSha(big)]);
+ });
+
+ it('keeps everything in the document without a side', () => {
+ const big = bytes(BLOB_THRESHOLD_BYTES * 2);
+ const { doc, node } = docWith((fs) => fs.writeImageFile('/big.png', big, 'image/png'));
+ expect(entryOf(doc, node, '/big.png').get('data')).toEqual(big);
+ expect(entryOf(doc, node, '/big.png').has('blob')).toBe(false);
+ expect(nodeFiles(doc, node)!.entry('/big.png')!.content).toEqual({ kind: 'image', data: big, mediaType: 'image/png' });
+ expect(missingBlobs(doc)).toEqual([]);
+ expect(onBlobsArrive(doc, () => undefined)).toBeTypeOf('function');
+ });
+
+ it('reads no bytes in a copy whose side has not got them, names them missing, and hears them come', () => {
+ const big = bytes(BLOB_THRESHOLD_BYTES * 2);
+ const { doc, node } = docWith((fs) => fs.writeImageFile('/big.png', big, 'image/png'), memoryBlobSide());
+ const copy = new Y.Doc();
+ const side = memoryBlobSide();
+ attachBlobs(copy, side);
+ Y.applyUpdate(copy, Y.encodeStateAsUpdate(doc));
+ expect(nodeFiles(copy, node)!.entry('/big.png')!.content).toEqual({ kind: 'image', data: new Uint8Array(0), mediaType: 'image/png' });
+ expect(missingBlobs(copy)).toEqual([blobSha(big)]);
+ let heard = 0;
+ const stop = onBlobsArrive(copy, () => (heard += 1));
+ side.arrived(new Map());
+ side.arrived(new Map([[blobSha(big), big]]));
+ stop();
+ side.arrived(new Map([[blobSha(big), big]]));
+ expect(heard).toBe(1);
+ expect(missingBlobs(copy)).toEqual([]);
+ expect(nodeFiles(copy, node)!.entry('/big.png')!.content).toMatchObject({ data: big });
+ // A copy without a side names every blob missing.
+ const bare = new Y.Doc();
+ Y.applyUpdate(bare, Y.encodeStateAsUpdate(doc));
+ expect(missingBlobs(bare)).toEqual([blobSha(big)]);
+ });
+
+ it('changes nothing when the same files are written again, with the bytes here or only their name', () => {
+ const big = bytes(BLOB_THRESHOLD_BYTES * 2);
+ const side = memoryBlobSide();
+ const { doc, node } = docWith((fs) => fs.writeImageFile('/big.png', big, 'image/png'), side);
+ const copy = new Y.Doc();
+ attachBlobs(copy, memoryBlobSide());
+ Y.applyUpdate(copy, Y.encodeStateAsUpdate(doc));
+ for (const each of [doc, copy]) {
+ let updates = 0;
+ each.on('update', () => (updates += 1));
+ changeFiles(each, node, 'person', (fs) => fs.writeImageFile('/big.png', big, 'image/png'));
+ expect(updates).toBe(0);
+ }
+ // Other bytes of the same size, another type, another size: a new entry each.
+ let updates = 0;
+ doc.on('update', () => (updates += 1));
+ changeFiles(doc, node, 'person', (fs) => fs.writeImageFile('/big.png', bytes(big.length, 9), 'image/png'));
+ expect(entryOf(doc, node, '/big.png').get('blob')).toBe(blobSha(bytes(big.length, 9)));
+ changeFiles(doc, node, 'person', (fs) => fs.writeImageFile('/big.png', bytes(big.length, 9), 'image/gif'));
+ expect(entryOf(doc, node, '/big.png').get('mediaType')).toBe('image/gif');
+ changeFiles(doc, node, 'person', (fs) => fs.writeImageFile('/big.png', bytes(big.length + 1, 9), 'image/gif'));
+ expect(entryOf(doc, node, '/big.png').get('size')).toBe(big.length + 1);
+ expect(updates).toBe(3);
+ // A copy without the bytes compares by the name.
+ changeFiles(copy, node, 'person', (fs) => fs.writeImageFile('/big.png', bytes(big.length, 5), 'image/png'));
+ expect(entryOf(copy, node, '/big.png').get('blob')).toBe(blobSha(bytes(big.length, 5)));
+ });
+});
diff --git a/packages/editor/browser/serverF.ts b/packages/editor/browser/serverF.ts
index 427eea3..5bbb240 100644
--- a/packages/editor/browser/serverF.ts
+++ b/packages/editor/browser/serverF.ts
@@ -29,6 +29,8 @@ export interface ServerF {
/** Signs a person in, in this browser context. */
signIn(context: BrowserContext, project: string, person: { readonly name: string; readonly level?: 'read' | 'write'; readonly user?: string }): Promise;
service(method: string, path: string, body?: unknown): Promise;
+ /** The size of a blob in F's store, none without it. */
+ blobSize(sha: string): Promise;
close(): Promise;
}
@@ -88,6 +90,10 @@ export async function startServerF(editorBase: string): Promise {
if (response?.status() !== 200) throw new Error(`not signed in: ${response?.status()}`);
await page.close();
},
+ async blobSize(sha) {
+ const result = await sql.query<{ size: number }>('SELECT size FROM filius_blob WHERE sha = $1', [sha]);
+ return result.rows[0]?.size;
+ },
service: (method, path, body) => fetch(`${base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }),
async close() {
const ended = new Promise((resolve) => child.once('exit', resolve));
diff --git a/packages/editor/browser/shared.spec.ts b/packages/editor/browser/shared.spec.ts
index d700ef8..26ec877 100644
--- a/packages/editor/browser/shared.spec.ts
+++ b/packages/editor/browser/shared.spec.ts
@@ -9,7 +9,12 @@
// told, thawed he writes again; his grant ended, he is told and the editor
// goes.
+import { createHash } from 'node:crypto';
+import { mkdtempSync, readFileSync, statSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import { expect, test, type Browser, type Page } from '@playwright/test';
+import { BLOB_THRESHOLD_BYTES } from '@mdg-filius/doc';
import { drag, onDevice, serveEditor } from './editorPage.js';
import { colorOf } from '../src/remote/presence.js';
import { startServerF, type ServerF } from './serverF.js';
@@ -158,6 +163,51 @@ test('three people in one project of F: moving, reading only, one simulation for
await expect(canvas(mia)).toBeVisible();
});
+test('takes a large picture one person imports to F as a blob, and shows it to another', async ({ browser }) => {
+ test.setTimeout(120_000);
+ const project = await f.project('java-examples/internetwork_komplett.fls');
+ const notebook = project.node('Notebook 1.10');
+ const mia = await person(browser, project.id, 'Mia');
+ const ben = await person(browser, project.id, 'Ben');
+ // A picture far over 16 KB: a screenshot of the page.
+ const file = join(mkdtempSync(join(tmpdir(), 'filius-blob-')), 'bild.png');
+ await mia.screenshot({ path: file });
+ const size = statSync(file).size;
+ expect(size).toBeGreaterThan(BLOB_THRESHOLD_BYTES);
+ await mia.locator('button[name="simulationMode"]').click();
+ for (const page of [mia, ben]) await expect(canvas(page)).toHaveAttribute('data-mode', 'simulation');
+ const open = async (page: Page) => {
+ const at = await onDevice(page, notebook);
+ await page.mouse.click(at.x, at.y);
+ return desktop(page, notebook);
+ };
+ const miaDesktop = await open(mia);
+ await miaDesktop.locator('[data-program="installation"]').click();
+ const installation = miaDesktop.locator('[data-program-window="installation"]');
+ for (const program of ['Datei-Explorer', 'Bildbetrachter']) await installation.locator('[data-list="available"] [role="option"]').filter({ hasText: program }).click();
+ await installation.getByRole('button', { name: 'Installieren' }).click();
+ await installation.getByRole('button', { name: 'Änderungen annehmen' }).click();
+ await miaDesktop.locator('[data-program="fileExplorer"]').click();
+ const explorer = miaDesktop.locator('[data-program-window="fileExplorer"]');
+ await explorer.getByRole('toolbar', { name: 'Aktionen' }).getByRole('button', { name: 'Importieren' }).click();
+ const importing = explorer.getByRole('dialog', { name: 'Datei importieren' });
+ await importing.locator('input[type="file"]').setInputFiles(file);
+ await importing.getByRole('button', { name: 'Datei importieren' }).click();
+ await expect(importing.getByRole('status')).toHaveText('Datei erfolgreich importiert!');
+ // Ben opens it in his viewer: the picture, as large as the page was.
+ const benDesktop = await open(ben);
+ await benDesktop.locator('[data-program="imageViewer"]').click();
+ const viewer = benDesktop.locator('[data-program-window="imageViewer"]');
+ await viewer.getByRole('button', { name: 'Öffnen' }).click();
+ const dialog = viewer.getByRole('dialog', { name: 'Datei Öffnen' });
+ await dialog.locator('.file-dialog-entries button', { hasText: 'bild.png' }).click();
+ await dialog.getByRole('button', { name: 'Öffnen' }).click();
+ await expect.poll(() => viewer.getByRole('img', { name: 'Bild' }).evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(mia.viewportSize()!.width);
+ // In F's store under its SHA-256.
+ const sha = createHash('sha256').update(readFileSync(file)).digest('hex');
+ expect(await f.blobSize(sha)).toBe(size);
+});
+
test('tells the dashboard over the bridge who else is in the project, each time it changes', async ({ browser }) => {
const project = await f.project('java-examples/zwei_rechnernetze_komplett.fls');
const context = await browser.newContext();
diff --git a/packages/editor/src/desktop/fileExplorer.ts b/packages/editor/src/desktop/fileExplorer.ts
index c3323ca..60ffb39 100644
--- a/packages/editor/src/desktop/fileExplorer.ts
+++ b/packages/editor/src/desktop/fileExplorer.ts
@@ -41,7 +41,7 @@ import {
type FolderEntry,
type Id,
} from '@mdg-filius/core';
-import { changeFiles, nodeFiles } from '@mdg-filius/doc';
+import { changeFiles, nodeFiles, onBlobsArrive } from '@mdg-filius/doc';
import { WindowState, windowOf } from './windowState.js';
/** A row of the explorer's tree: every folder and file, each folder's entries after it in the order they were made. */
@@ -214,6 +214,8 @@ export class ImageViewerWindow extends WindowState {
) {
super();
doc.on('update', () => this.pathValue !== null && this.changed());
+ // A picture that is a blob shows once its bytes have come.
+ onBlobsArrive(doc, () => this.pathValue !== null && this.changed());
}
private files(): VirtualFileSystem {
diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json
index 1b9ee03..52e548d 100644
--- a/packages/editor/src/i18n/de.json
+++ b/packages/editor/src/i18n/de.json
@@ -367,6 +367,7 @@
"remote.refused": "Der Filius-Dienst hat die Verbindung abgelehnt ({reason}).",
"remote.revoked": "Ihr Zugang zu diesem Projekt wurde beendet.",
"remote.unknownProject": "Dieses Projekt gibt es nicht mehr.",
+ "remote.uploadFailed": "Eine Datei konnte nicht zum Filius-Dienst gebracht werden; andere sehen sie nicht. Laden Sie die Seite neu und versuchen Sie es noch einmal.",
"report.apps": "Anwendungen",
"report.baseConfig": "Basiskonfiguration",
"report.components": "Komponenten",
diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json
index ecf0a73..c2370e7 100644
--- a/packages/editor/src/i18n/en.json
+++ b/packages/editor/src/i18n/en.json
@@ -367,6 +367,7 @@
"remote.refused": "The Filius service refused the connection ({reason}).",
"remote.revoked": "Your access to this project has ended.",
"remote.unknownProject": "This project no longer exists.",
+ "remote.uploadFailed": "A file could not be taken to the Filius service; others do not see it. Reload the page and try again.",
"report.apps": "Applications",
"report.baseConfig": "Base Configuration",
"report.components": "Components",
diff --git a/packages/editor/src/remote/SharedProject.tsx b/packages/editor/src/remote/SharedProject.tsx
index 88ad6dd..b7c4dc8 100644
--- a/packages/editor/src/remote/SharedProject.tsx
+++ b/packages/editor/src/remote/SharedProject.tsx
@@ -4,9 +4,14 @@
// refuses or lets go — of the document or the room — a word why, instead of
// the editor. Who only reads gets the editor locked (Editor's readOnly).
// The others in the project are shown in the editor and told to whoever
-// embeds it (`onPeople`: the dashboard's header, over the bridge).
+// embeds it (`onPeople`: the dashboard's header, over the bridge). The
+// document's large binary files come from F and go to it (remoteBlobs):
+// fetched as the document names them; one that could not be taken to F is
+// said.
import { useEffect, useMemo, useState } from 'react';
+import { attachBlobs } from '@mdg-filius/doc';
+import { remoteBlobs, type RemoteBlobs } from './remoteBlobs.js';
import { peopleOf, type PresenceUser } from './presence.js';
import { usePresence } from './usePresence.js';
import { Editor } from '../Editor.js';
@@ -18,7 +23,25 @@ import { connectSharedDoc, type SharedDocState } from './sharedDoc.js';
type Ready = Extract;
/** The editor on the ready document, with the others in the project. */
-function ReadyEditor({ state, createSimHost, onPeople }: { readonly state: Ready; readonly createSimHost: SimHostFactory; readonly onPeople: ((people: readonly PresenceUser[]) => void) | undefined }) {
+function ReadyEditor({
+ state,
+ createSimHost,
+ onPeople,
+ blobs,
+}: {
+ readonly state: Ready;
+ readonly createSimHost: SimHostFactory;
+ readonly onPeople: ((people: readonly PresenceUser[]) => void) | undefined;
+ readonly blobs: RemoteBlobs;
+}) {
+ // The blob side before anything reads or writes a file of the document.
+ useMemo(() => attachBlobs(state.doc, blobs), [state.doc, blobs]);
+ useEffect(() => {
+ const fetchMissing = () => void blobs.fetchMissing(state.doc);
+ fetchMissing();
+ state.doc.on('update', fetchMissing);
+ return () => state.doc.off('update', fetchMissing);
+ }, [state.doc, blobs]);
const presence = usePresence(state.awareness, state.you);
const key = JSON.stringify(peopleOf(presence.peers, state.you.id));
useEffect(() => onPeople?.(JSON.parse(key) as PresenceUser[]), [key, onPeople]);
@@ -39,6 +62,8 @@ export function SharedProject({ server, project, onPeople }: SharedProjectProps)
const [state, setState] = useState({ kind: 'connecting' });
/** F ended the room for this page: the editor goes as when the document is let go. */
const [roomEnd, setRoomEnd] = useState(null);
+ const [uploadFailed, setUploadFailed] = useState(false);
+ const blobs = useMemo(() => remoteBlobs({ server, project, onFailure: () => setUploadFailed(true) }), [server, project]);
useEffect(() => connectSharedDoc({ server, project, onState: setState }), [server, project]);
const createSimHost = useMemo(
() => () => {
@@ -63,5 +88,14 @@ export function SharedProject({ server, project, onPeople }: SharedProjectProps)
{t('remote.connecting')}
);
- return ;
+ return (
+ <>
+ {uploadFailed ? (
+
+ {t('remote.uploadFailed')}
+
+ ) : null}
+
+ >
+ );
}
diff --git a/packages/editor/src/remote/remoteBlobs.ts b/packages/editor/src/remote/remoteBlobs.ts
new file mode 100644
index 0000000..26f7ff6
--- /dev/null
+++ b/packages/editor/src/remote/remoteBlobs.ts
@@ -0,0 +1,89 @@
+// The editor's blob side on F (packages/doc's blobs.ts): the blobs the
+// document names are fetched from F as they come into it (each once, with
+// the cookie; one F has not got — yet: the editor that made it may still be
+// taking it there — is asked for again a few times, a while apart; one the
+// network lost is asked for with the next change). A blob made here (a file
+// put into a computer) is kept at once and taken to F — tried again once
+// after a while, then told as failed.
+// What arrives is told to the document's listeners, so that a picture
+// shows once its bytes are here.
+
+import type * as Y from 'yjs';
+import { memoryBlobSide, missingBlobs, type BlobSide } from '@mdg-filius/doc';
+
+/** The wait before an upload is tried again. */
+export const UPLOAD_RETRY_MILLISECONDS = 2_000;
+/** How often, and how long apart, a blob F has not got is asked for again. */
+export const BLOB_ASKS = 5;
+export const BLOB_ASK_AGAIN_MILLISECONDS = 2_000;
+
+export interface RemoteBlobsOptions {
+ /** F's address. */
+ readonly server: string;
+ readonly project: string;
+ /** A blob that could not be taken to F. */
+ readonly onFailure?: (sha: string) => void;
+ readonly fetch?: typeof fetch;
+}
+
+export interface RemoteBlobs extends BlobSide {
+ /** Fetches what the document names and is not here; done when every answer has come. */
+ fetchMissing(doc: Y.Doc): Promise;
+}
+
+export function remoteBlobs({ server, project, onFailure, fetch: request = (input, init) => fetch(input, init) }: RemoteBlobsOptions): RemoteBlobs {
+ const address = (path: string) => `${new URL(path, server).href}?project=${encodeURIComponent(project)}`;
+ const upload = async (sha: string, bytes: Uint8Array, tries: number): Promise => {
+ try {
+ const response = await request(address('/blobs'), { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/octet-stream' }, body: bytes as BodyInit });
+ const answer = response.ok ? ((await response.json()) as { sha?: unknown }) : undefined;
+ if (answer?.sha === sha) return;
+ } catch {
+ // Tried again below.
+ }
+ if (tries > 1) {
+ await new Promise((resolve) => setTimeout(resolve, UPLOAD_RETRY_MILLISECONDS));
+ return upload(sha, bytes, tries - 1);
+ }
+ onFailure?.(sha);
+ };
+ const side = memoryBlobSide((sha, bytes) => void upload(sha, bytes, 2));
+ const asked = new Set();
+ /** Blobs F has not got (not asked for until their wait is over), and how often F said so. */
+ const unavailable = new Set();
+ const misses = new Map();
+ const blobs: RemoteBlobs = {
+ bytes: side.bytes,
+ keep: side.keep,
+ onArrive: side.onArrive,
+ async fetchMissing(doc) {
+ const wanted = missingBlobs(doc).filter((sha) => !asked.has(sha) && !unavailable.has(sha));
+ for (const sha of wanted) asked.add(sha);
+ const found = await Promise.all(
+ wanted.map(async (sha): Promise<[string, Uint8Array] | undefined> => {
+ try {
+ const response = await request(address(`/blobs/${sha}`), { credentials: 'include' });
+ if (response.ok) return [sha, new Uint8Array(await response.arrayBuffer())];
+ if (response.status === 404) {
+ const asks = (misses.get(sha) ?? 0) + 1;
+ misses.set(sha, asks);
+ unavailable.add(sha);
+ if (asks < BLOB_ASKS)
+ setTimeout(() => {
+ unavailable.delete(sha);
+ void blobs.fetchMissing(doc);
+ }, BLOB_ASK_AGAIN_MILLISECONDS);
+ }
+ return undefined;
+ } catch {
+ return undefined;
+ } finally {
+ asked.delete(sha);
+ }
+ }),
+ );
+ side.arrived(new Map(found.filter((each) => each !== undefined)));
+ },
+ };
+ return blobs;
+}
diff --git a/packages/editor/src/sheet/sheet.css b/packages/editor/src/sheet/sheet.css
index 8e2af9d..84af0be 100644
--- a/packages/editor/src/sheet/sheet.css
+++ b/packages/editor/src/sheet/sheet.css
@@ -396,3 +396,19 @@
height: 10px;
border-radius: 5px;
}
+
+/* Over the editor on a project of F: something went wrong that the person should know. */
+.remote-notice {
+ position: fixed;
+ left: 50%;
+ bottom: 16px;
+ z-index: 100;
+ transform: translateX(-50%);
+ max-width: min(90vw, 560px);
+ margin: 0;
+ padding: 10px 14px;
+ border: 1px solid var(--danger);
+ border-radius: 8px;
+ background: var(--danger-soft);
+ color: var(--danger-on-soft);
+}
diff --git a/packages/editor/test/remoteBlobs.test.ts b/packages/editor/test/remoteBlobs.test.ts
new file mode 100644
index 0000000..6545566
--- /dev/null
+++ b/packages/editor/test/remoteBlobs.test.ts
@@ -0,0 +1,135 @@
+// The editor's blob side on F (remoteBlobs), on a fetch the test answers: a
+// blob made here is kept at once and taken to F with the cookie, tried
+// again once, then told as failed (also when F names it otherwise); the
+// blobs a document names are fetched once each and told as they come; one F
+// has not got is not asked for again, one the network lost is.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as Y from 'yjs';
+import { attachBlobs, BLOB_THRESHOLD_BYTES, blobSha, changeFiles, emptyProject, memoryBlobSide, missingBlobs, ROOTS, writeProject } from '@mdg-filius/doc';
+import { BLOB_ASK_AGAIN_MILLISECONDS, BLOB_ASKS, remoteBlobs, UPLOAD_RETRY_MILLISECONDS } from '../src/remote/remoteBlobs.js';
+
+const picture = (seed: number) => Uint8Array.from({ length: BLOB_THRESHOLD_BYTES }, (_, index) => (index + seed) % 256);
+
+interface Call {
+ readonly url: string;
+ readonly init: RequestInit | undefined;
+}
+
+function fakeFetch(answer: (call: Call) => Response | Promise) {
+ const calls: Call[] = [];
+ const fetch = (async (url: string, init?: RequestInit) => {
+ const call = { url, init };
+ calls.push(call);
+ return answer(call);
+ }) as typeof globalThis.fetch;
+ return { calls, fetch };
+}
+
+/** A document naming these pictures as blobs on a computer, made with a side of its own. */
+function docNaming(...pictures: Uint8Array[]): Y.Doc {
+ const made = new Y.Doc();
+ attachBlobs(made, memoryBlobSide());
+ writeProject(made, emptyProject());
+ made.transact(() => made.getMap>(ROOTS.nodes).set('pc', new Y.Map()));
+ changeFiles(made, 'pc', 'person', (files) => pictures.forEach((each, index) => files.writeImageFile(`/p${index}.png`, each, 'image/png')));
+ const copy = new Y.Doc();
+ Y.applyUpdate(copy, Y.encodeStateAsUpdate(made));
+ return copy;
+}
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => vi.useRealTimers());
+
+describe("the editor's blobs on F", () => {
+ it('keeps a blob at once and takes it to F with the cookie', async () => {
+ const bytes = picture(1);
+ const { calls, fetch } = fakeFetch(() => Response.json({ sha: blobSha(bytes) }, { status: 201 }));
+ const failed: string[] = [];
+ const side = remoteBlobs({ server: 'https://f.test/', project: 'p 1', fetch, onFailure: (sha) => failed.push(sha) });
+ side.keep(blobSha(bytes), bytes);
+ expect(side.bytes(blobSha(bytes))).toBe(bytes);
+ await vi.runAllTimersAsync();
+ expect(calls.map(({ url, init }) => [url, init?.method, init?.credentials, init?.body])).toEqual([['https://f.test/blobs?project=p%201', 'POST', 'include', bytes]]);
+ expect(failed).toEqual([]);
+ // Kept once: the same again goes nowhere.
+ side.keep(blobSha(bytes), bytes);
+ await vi.runAllTimersAsync();
+ expect(calls).toHaveLength(1);
+ });
+
+ it('tries again once after a while, then says it failed — also when F names the bytes otherwise', async () => {
+ for (const answer of [() => new Response('', { status: 500 }), () => Response.json({ sha: 'other' }), () => Promise.reject(new Error('offline'))]) {
+ const bytes = picture(2);
+ const { calls, fetch } = fakeFetch(answer);
+ const failed: string[] = [];
+ remoteBlobs({ server: 'https://f.test', project: 'p', fetch, onFailure: (sha) => failed.push(sha) }).keep(blobSha(bytes), bytes);
+ await vi.advanceTimersByTimeAsync(UPLOAD_RETRY_MILLISECONDS - 1);
+ expect(calls).toHaveLength(1);
+ await vi.advanceTimersByTimeAsync(1);
+ await vi.runAllTimersAsync();
+ expect([calls.length, failed]).toEqual([2, [blobSha(bytes)]]);
+ }
+ // A second try that works says nothing.
+ const bytes = picture(3);
+ let tries = 0;
+ const { fetch } = fakeFetch(() => (++tries === 1 ? new Response('', { status: 503 }) : Response.json({ sha: blobSha(bytes) })));
+ const failed: string[] = [];
+ remoteBlobs({ server: 'https://f.test', project: 'p', fetch, onFailure: (sha) => failed.push(sha) }).keep(blobSha(bytes), bytes);
+ await vi.runAllTimersAsync();
+ expect([tries, failed]).toEqual([2, []]);
+ });
+
+ it('fetches the blobs a document names once each, and tells when they came', async () => {
+ const [first, second] = [picture(4), picture(5)];
+ const doc = docNaming(first, second);
+ const { calls, fetch } = fakeFetch(({ url }) => new Response(url.includes(blobSha(first)) ? first : second));
+ const side = remoteBlobs({ server: 'https://f.test', project: 'p', fetch });
+ attachBlobs(doc, side);
+ let heard = 0;
+ side.onArrive(() => (heard += 1));
+ expect(missingBlobs(doc).sort()).toEqual([blobSha(first), blobSha(second)].sort());
+ await Promise.all([side.fetchMissing(doc), side.fetchMissing(doc)]);
+ expect(calls.map(({ url, init }) => [url, init?.credentials]).sort()).toEqual(
+ [
+ [`https://f.test/blobs/${blobSha(first)}?project=p`, 'include'],
+ [`https://f.test/blobs/${blobSha(second)}?project=p`, 'include'],
+ ].sort(),
+ );
+ expect([missingBlobs(doc), heard]).toEqual([[], 1]);
+ expect(side.bytes(blobSha(first))).toEqual(first);
+ });
+
+ it('asks again a few times, a while apart, for a blob F has not got yet; for one the network lost with the next change', async () => {
+ const [late, gone, lost] = [picture(6), picture(7), picture(8)];
+ const doc = docNaming(late, gone, lost);
+ let offline = true;
+ let lateAsked = 0;
+ const { calls, fetch } = fakeFetch(({ url }) => {
+ if (url.includes(blobSha(late))) return ++lateAsked < 3 ? new Response('', { status: 404 }) : new Response(late);
+ if (url.includes(blobSha(gone))) return new Response('', { status: 404 });
+ if (offline) return Promise.reject(new Error('offline'));
+ return new Response(lost);
+ });
+ const side = remoteBlobs({ server: 'https://f.test', project: 'p', fetch });
+ attachBlobs(doc, side);
+ await side.fetchMissing(doc);
+ offline = false;
+ // Without a change, only what F has not got is asked for again.
+ await vi.advanceTimersByTimeAsync(BLOB_ASK_AGAIN_MILLISECONDS - 1);
+ expect(calls).toHaveLength(3);
+ await vi.advanceTimersByTimeAsync(1);
+ await vi.runAllTimersAsync();
+ expect(side.bytes(blobSha(late))).toEqual(late);
+ expect(calls.filter(({ url }) => url.includes(blobSha(late)))).toHaveLength(3);
+ expect(calls.filter(({ url }) => url.includes(blobSha(gone)))).toHaveLength(BLOB_ASKS);
+ // The one the network lost comes with the next change (its fetch then asks for what is still missing).
+ await side.fetchMissing(doc);
+ expect(side.bytes(blobSha(lost))).toEqual(lost);
+ expect(missingBlobs(doc)).toEqual([blobSha(gone)]);
+ // Given up on: not asked for again.
+ await side.fetchMissing(doc);
+ await vi.runAllTimersAsync();
+ expect(calls.filter(({ url }) => url.includes(blobSha(gone)))).toHaveLength(BLOB_ASKS);
+ });
+});
diff --git a/packages/server/src/blobRoutes.ts b/packages/server/src/blobRoutes.ts
new file mode 100644
index 0000000..54fe1f6
--- /dev/null
+++ b/packages/server/src/blobRoutes.ts
@@ -0,0 +1,72 @@
+// The editor's way to the blobs (blobs.ts): GET /blobs/?project=… for
+// whoever has a grant to the project, POST /blobs?project=… (the bytes as
+// the body) for whoever may write in it — not in a frozen project, and only
+// from the editor's origin (a page elsewhere under the same domain would
+// bring the person's cookie along). The answer to a POST is the name F gave
+// the bytes. For the editor's origins F answers CORS, so that a page served
+// from elsewhere (the development page) can reach it with its cookie.
+
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import { BLOB_MAX_BYTES, type BlobStore } from './blobs.js';
+import type { Log } from './log.js';
+import type { SessionStore } from './sessions.js';
+import { cookieValue, SESSION_COOKIE } from './sync.js';
+import { isUuid } from './ticket.js';
+
+export interface BlobRouteOptions {
+ readonly blobs: BlobStore;
+ readonly sessions: SessionStore;
+ readonly log: Log;
+ readonly origins: readonly string[];
+}
+
+export async function handleBlobs(request: IncomingMessage, response: ServerResponse, url: URL, { blobs, sessions, log, origins }: BlobRouteOptions): Promise {
+ const origin = request.headers.origin;
+ const allowed = origin !== undefined && origins.includes(origin);
+ const headers: Record = allowed ? { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true', vary: 'Origin' } : {};
+ const send = (status: number, value: object) => {
+ response.writeHead(status, { ...headers, 'content-type': 'application/json', 'cache-control': 'no-store' });
+ response.end(JSON.stringify(value));
+ };
+ if (request.method === 'OPTIONS') {
+ response.writeHead(allowed ? 204 : 403, { ...headers, 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '600' });
+ response.end();
+ return;
+ }
+ const project = url.searchParams.get('project');
+ if (!isUuid(project)) return send(400, { error: 'notAProject' });
+ const key = cookieValue(request.headers.cookie, SESSION_COOKIE);
+ const access = key === undefined ? ({ kind: 'notSignedIn' } as const) : await sessions.access(key, project);
+ if (access.kind !== 'granted') return send(403, { error: access.kind });
+ const name = /^\/blobs\/([^/]+)$/.exec(url.pathname)?.[1];
+ if (request.method === 'GET' && name !== undefined) {
+ const bytes = (await blobs.get([name])).get(name);
+ if (bytes === undefined) return send(404, { error: 'unknownBlob' });
+ // A blob's bytes never change under its name.
+ response.writeHead(200, { ...headers, 'content-type': 'application/octet-stream', 'content-length': String(bytes.length), 'cache-control': 'private, max-age=31536000, immutable' });
+ response.end(bytes);
+ return;
+ }
+ if (request.method === 'POST' && url.pathname === '/blobs') {
+ if (!allowed) {
+ log.warn('blob.foreignOrigin', { project, origin: origin ?? null });
+ return send(403, { error: 'foreignOrigin' });
+ }
+ if (access.level !== 'write') return send(403, { error: 'readOnly' });
+ if (access.frozen) return send(403, { error: 'frozen' });
+ const parts: Buffer[] = [];
+ let size = 0;
+ for await (const part of request as AsyncIterable) {
+ size += part.length;
+ if (size > BLOB_MAX_BYTES) {
+ log.warn('blob.tooLarge', { project, user: access.person.login });
+ return send(413, { error: 'tooLarge' });
+ }
+ parts.push(part);
+ }
+ const sha = await blobs.put(Buffer.concat(parts));
+ log.info('blob.kept', { project, user: access.person.login, sha, size });
+ return send(201, { sha });
+ }
+ send(404, { error: 'notFound' });
+}
diff --git a/packages/server/src/blobs.ts b/packages/server/src/blobs.ts
new file mode 100644
index 0000000..22f967f
--- /dev/null
+++ b/packages/server/src/blobs.ts
@@ -0,0 +1,52 @@
+// The large binary files of the projects' computers (plan §5.1,
+// packages/doc's blobs.ts), content-addressed: named by the SHA-256 of
+// their bytes, kept once however many projects hold them. F computes the
+// name itself; what a client says a blob is called is not believed.
+//
+// Not yet: forgetting a blob no project names any more (they stay; the
+// commits keep theirs in the dashboard, plan §10.1).
+
+import { createHash } from 'node:crypto';
+import type pg from 'pg';
+
+/** A blob at most (a picture or a file put into a simulated computer). */
+export const BLOB_MAX_BYTES = 16 * 1024 * 1024;
+
+export interface BlobStore {
+ migrate(): Promise;
+ /** Bytes kept; their name. */
+ put(bytes: Uint8Array): Promise;
+ /** The blobs of these names that F has. */
+ get(shas: readonly string[]): Promise