Files
mdg-filius/packages/editor/browser/serverF.ts
T
julian.kiedaischandClaude Opus 5.5 4b33e36580 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>
2026-10-04 16:46:50 +00:00

107 lines
4.9 KiB
TypeScript

// A real server F for the editor's browser tests: bundled as the build
// bundles it (dist/main.mjs and its room thread), started as a process with
// a throwaway Postgres (packages/server/test/database.ts), on the host the
// editor's page comes from, so that F's cookie goes with the page's
// requests. A project is made in F's store straight from a FILIUS file; a
// person signs in as the dashboard would let them, with a ticket at /__auth.
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import type { BrowserContext } from '@playwright/test';
import { build } from 'esbuild';
import pg from 'pg';
import * as Y from 'yjs';
import { writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { testDatabase, type TestDatabase } from '../../server/test/database.js';
import { signTicket } from '../../server/test/tickets.js';
const TICKET_SECRET = 'browser-ticket-secret';
export const SERVICE_KEY = 'browser-service-key';
export interface ServerF {
readonly base: string;
/** A project made from a FILIUS file of the corpus; its devices' ids by name. */
project(file: string): Promise<{ readonly id: string; readonly node: (name: string) => string }>;
/** 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>;
}
/** Server F on the editor page's host (`editorBase`), for pages from there. */
export async function startServerF(editorBase: string): Promise<ServerF> {
const editor = new URL(editorBase);
const database: TestDatabase = await testDatabase();
const out = mkdtempSync(join(tmpdir(), 'filius-server-f-'));
const source = new URL('../../server/src/', import.meta.url).pathname;
await build({
entryPoints: [join(source, 'main.ts'), join(source, 'simWorker.ts')],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
outdir: out,
outExtension: { '.js': '.mjs' },
external: ['pg-native'],
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" },
logLevel: 'error',
});
const port = 20_000 + Math.floor(Math.random() * 20_000);
const child: ChildProcess = spawn(process.execPath, [join(out, 'main.mjs')], {
env: {
...process.env,
DATABASE_URL: database.url,
FILIUS_TICKET_SECRET: TICKET_SECRET,
FILIUS_SERVICE_KEY: SERVICE_KEY,
FILIUS_ORIGINS: editor.origin,
FILIUS_PORT: String(port),
FILIUS_INSECURE_COOKIE: '1',
},
stdio: ['ignore', 'pipe', 'inherit'],
});
await new Promise<void>((resolve, reject) => {
child.stdout!.on('data', (data: Buffer) => {
if (data.toString().includes('"server.listening"')) resolve();
});
child.once('exit', (code) => reject(new Error(`server F ended (${code})`)));
});
const base = `${editor.protocol}//${editor.hostname}:${port}`;
const sql = new pg.Pool({ connectionString: database.url });
return {
base,
async project(file) {
const id = randomUUID();
const imported = openProjectArchive(new Uint8Array(readFileSync(new URL(`../../../corpus/${file}`, import.meta.url))), () => randomUUID());
const doc = new Y.Doc();
writeProject(doc, imported);
await sql.query('INSERT INTO filius_doc (project_uuid, state) VALUES ($1, $2)', [id, Buffer.from(Y.encodeStateAsUpdate(doc))]);
return { id, node: (name) => imported.graph.nodes.find((each) => each.displayName === name)!.id };
},
async signIn(context, project, { name, level = 'write', user = randomUUID() }) {
const ticket = signTicket({ u: user, n: name.toLowerCase(), d: name, p: project, lvl: level }, TICKET_SECRET);
const page = await context.newPage();
const response = await page.goto(`${base}/__auth?ticket=${encodeURIComponent(ticket)}&next=/healthz`);
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));
child.kill('SIGTERM');
await ended;
await sql.end();
await database.stop();
},
};
}