feat: F5.6b server F as an image: the editor from F itself, one stack per environment

F hands out the editor's page and bundle (editor/ beside main.mjs, or
FILIUS_EDITOR_DIR): GET and HEAD only, one wall against leaving the folder,
F's own routes first, assets/ cached a year, the rest asked for again; the
build lays .br and .gz beside the files and F sends the better one the
browser takes (bundle 695 KB to 212 KB).

deploy/Dockerfile: two stages, Node 24, no node_modules, not root.
deploy/docker-compose.yml with an env file per environment: the file sets
the stack's name, volume, image tag and port (test 8081, production 8080).
Measured with both stacks side by side behind nginx and Chromium.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 17:43:51 +00:00
co-authored by Claude Opus 5.5
parent 709146b58a
commit 087721cba3
19 changed files with 714 additions and 10 deletions
+5
View File
@@ -10,6 +10,11 @@ import type { DesktopView, DeviceSheet, TopologyView } from '@mdg-filius/doc';
const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts');
/** The editor built into `outDir`, as the image's build builds it. */
export async function buildEditor(outDir: string): Promise<void> {
await build({ configFile: config, logLevel: 'silent', build: { outDir, emptyOutDir: true } });
}
export async function serveEditor(): Promise<{ readonly base: string; readonly close: () => Promise<void> }> {
await build({ configFile: config, logLevel: 'silent' });
const server: PreviewServer = await preview({ configFile: config, preview: { port: 0 }, logLevel: 'silent' });
+54
View File
@@ -0,0 +1,54 @@
// The editor as the image delivers it (F5.6b): built, packed, and handed out
// by server F itself, so that the page comes from F's own origin and takes it
// for its server — no `&server=` in the address. Two people in one project
// see each other, and a simulation one starts runs for the other; the
// bundle arrives packed with brotli.
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, test, type Browser, type Page } from '@playwright/test';
import { precompress } from '../../server/src/precompress.js';
import { buildEditor } from './editorPage.js';
import { startServerF, type ServerF } from './serverF.js';
let f: ServerF;
test.beforeAll(async () => {
const folder = mkdtempSync(join(tmpdir(), 'filius-editor-from-f-'));
await buildEditor(folder);
await precompress(folder);
f = await startServerF('http://localhost', folder);
});
test.afterAll(async () => {
await f?.close();
});
async function person(browser: Browser, project: string, name: string, user: string): Promise<{ readonly page: Page; readonly encodings: Map<string, string> }> {
const context = await browser.newContext();
await f.signIn(context, project, { name, user });
const page = await context.newPage();
const encodings = new Map<string, string>();
page.on('response', async (response) => {
const path = new URL(response.url()).pathname;
if (path.startsWith('/assets/index-') && path.endsWith('.js')) encodings.set(path, (await response.allHeaders())['content-encoding'] ?? 'none');
});
await page.goto(`${f.base}/?project=${project}`);
await page.waitForSelector('svg.filius-canvas');
return { page, encodings };
}
test('the editor from F itself: two people, one simulation, the bundle packed', async ({ browser }) => {
const project = await f.project('java-examples/internetwork_komplett.fls');
const [miaUser, benUser] = [crypto.randomUUID(), crypto.randomUUID()];
const mia = await person(browser, project.id, 'Mia', miaUser);
const ben = await person(browser, project.id, 'Ben', benUser);
await expect(mia.page.locator(`[data-person="${benUser}"]`)).toBeVisible();
await expect(ben.page.locator(`[data-person="${miaUser}"]`)).toBeVisible();
await expect(ben.page.locator('g.device')).toHaveCount(await mia.page.locator('g.device').count());
await mia.page.locator('button[name="simulationMode"]').click();
await expect(ben.page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
await expect(ben.page.locator('[data-note="startedBy"]')).toHaveText('Simulation gestartet von Mia');
expect([...mia.encodings.values()]).toEqual(['br']);
});
+7 -4
View File
@@ -4,6 +4,8 @@
// 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.
// Given a built editor, F hands it out itself, as in the image: then the
// editor's page comes from F's own origin.
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtempSync, readFileSync } from 'node:fs';
@@ -34,8 +36,8 @@ export interface ServerF {
close(): Promise<void>;
}
/** Server F on the editor page's host (`editorBase`), for pages from there. */
export async function startServerF(editorBase: string): Promise<ServerF> {
/** Server F on the editor page's host (`editorBase`), for pages from there, or from itself with `editorFolder`. */
export async function startServerF(editorBase: string, editorFolder?: string): Promise<ServerF> {
const editor = new URL(editorBase);
const database: TestDatabase = await testDatabase();
const out = mkdtempSync(join(tmpdir(), 'filius-server-f-'));
@@ -53,13 +55,15 @@ export async function startServerF(editorBase: string): Promise<ServerF> {
logLevel: 'error',
});
const port = 20_000 + Math.floor(Math.random() * 20_000);
const base = `${editor.protocol}//${editor.hostname}:${port}`;
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_ORIGINS: editorFolder === undefined ? editor.origin : base,
...(editorFolder === undefined ? {} : { FILIUS_EDITOR_DIR: editorFolder }),
FILIUS_PORT: String(port),
FILIUS_INSECURE_COOKIE: '1',
},
@@ -71,7 +75,6 @@ export async function startServerF(editorBase: string): Promise<ServerF> {
});
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,