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();