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
+6
View File
@@ -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<void>;
service(method: string, path: string, body?: unknown): Promise<Response>;
/** The size of a blob in F's store, none without it. */
blobSize(sha: string): Promise<number | undefined>;
close(): Promise<void>;
}
@@ -88,6 +90,10 @@ export async function startServerF(editorBase: string): Promise<ServerF> {
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));
+50
View File
@@ -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();
+3 -1
View File
@@ -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 {
+1
View File
@@ -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",
+1
View File
@@ -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",
+37 -3
View File
@@ -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<SharedDocState, { kind: 'ready' }>;
/** 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<SharedDocState>({ kind: 'connecting' });
/** F ended the room for this page: the editor goes as when the document is let go. */
const [roomEnd, setRoomEnd] = useState<string | null>(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<SimHostFactory>(
() => () => {
@@ -63,5 +88,14 @@ export function SharedProject({ server, project, onPeople }: SharedProjectProps)
{t('remote.connecting')}
</p>
);
return <ReadyEditor key={state.doc.guid} state={state} createSimHost={createSimHost} onPeople={onPeople} />;
return (
<>
{uploadFailed ? (
<p role="alert" className="remote-notice">
{t('remote.uploadFailed')}
</p>
) : null}
<ReadyEditor key={state.doc.guid} state={state} createSimHost={createSimHost} onPeople={onPeople} blobs={blobs} />
</>
);
}
+89
View File
@@ -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<void>;
}
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<void> => {
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<string>();
/** Blobs F has not got (not asked for until their wait is over), and how often F said so. */
const unavailable = new Set<string>();
const misses = new Map<string, number>();
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;
}
+16
View File
@@ -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);
}
+135
View File
@@ -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<Response>) {
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<Y.Map<unknown>>(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);
});
});