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
+1 -1
View File
@@ -8,7 +8,7 @@
".": "./src/index.ts"
},
"scripts": {
"build": "esbuild src/main.ts src/simWorker.ts --bundle --platform=node --format=esm --target=node20 --outdir=dist --out-extension:.js=.mjs --external:pg-native --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\"",
"build": "esbuild src/main.ts src/simWorker.ts src/precompressMain.ts --bundle --platform=node --format=esm --target=node20 --outdir=dist --out-extension:.js=.mjs --external:pg-native --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\"",
"start": "node dist/main.mjs"
},
"dependencies": {
+108
View File
@@ -0,0 +1,108 @@
// The editor's page and bundle, from the folder the build put them in
// (`editor/` beside main.mjs in the image), so that the editor comes from
// the host F answers on and takes it for its server (main.tsx). Only GET and
// HEAD, only files inside the folder: a `..`, also an encoded one, never
// leaves it. Vite names the files under assets/ by their content, so those
// are kept a year; everything else (index.html above all) is asked for again
// each time, so that a new image is seen at the next opening. Beside a
// compressible file the build lays a .br and a .gz (precompress.ts); they go
// out instead when the browser takes them (the proxy compresses nothing).
import { createReadStream, type Stats } from 'node:fs';
import { stat } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { extname, join, resolve, sep } from 'node:path';
import { pipeline } from 'node:stream/promises';
export const ASSET_CACHE = 'public, max-age=31536000, immutable';
export const PAGE_CACHE = 'no-cache';
/** The encodings laid beside a file, the better first. */
export const ENCODINGS = [
{ name: 'br', suffix: '.br' },
{ name: 'gzip', suffix: '.gz' },
] as const;
const TYPES: Readonly<Record<string, string>> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.wasm': 'application/wasm',
'.txt': 'text/plain; charset=utf-8',
};
/** Answers a request for a file of the editor; false when it is none (the caller says 404). */
export type EditorFiles = (request: IncomingMessage, response: ServerResponse, path: string) => Promise<boolean>;
/**
* The path of the file inside `root` a request path names, none when it
* leaves the folder. The one wall is the last line: `join` resolves every
* `..`, also a decoded one, and what lands outside `root` is refused.
*/
export function fileOf(root: string, path: string): string | undefined {
let decoded: string;
try {
decoded = decodeURIComponent(path);
} catch {
return undefined;
}
const file = join(root, decoded === '/' ? 'index.html' : decoded);
return file.startsWith(root + sep) ? file : undefined;
}
/** Whether an Accept-Encoding header takes `name` (not with q=0). */
export function accepts(header: string, name: string): boolean {
return header.split(',').some((each) => {
const [token, ...parameters] = each.split(';').map((part) => part.trim().toLowerCase());
return token === name && !parameters.some((parameter) => /^q=0(\.0*)?$/.test(parameter));
});
}
async function fileStats(file: string): Promise<Stats | undefined> {
try {
const found = await stat(file);
return found.isFile() ? found : undefined;
} catch {
return undefined;
}
}
export function editorFiles(folder: string): EditorFiles {
const root = resolve(folder);
return async (request, response, path) => {
if (request.method !== 'GET' && request.method !== 'HEAD') return false;
const file = fileOf(root, path);
if (file === undefined) return false;
const plain = await fileStats(file);
if (plain === undefined) return false;
let sent = { file, size: plain.size, encoding: undefined as string | undefined };
const accepted = String(request.headers['accept-encoding'] ?? '');
for (const { name, suffix } of ENCODINGS) {
if (!accepts(accepted, name)) continue;
const packed = await fileStats(file + suffix);
if (packed === undefined) continue;
sent = { file: file + suffix, size: packed.size, encoding: name };
break;
}
response.writeHead(200, {
'content-type': TYPES[extname(file)] ?? 'application/octet-stream',
'content-length': String(sent.size),
'cache-control': file.startsWith(join(root, 'assets') + sep) ? ASSET_CACHE : PAGE_CACHE,
'x-content-type-options': 'nosniff',
vary: 'Accept-Encoding',
...(sent.encoding === undefined ? {} : { 'content-encoding': sent.encoding }),
});
// Node sends no body for HEAD. A browser that goes away mid-file is no
// fault of F's.
await pipeline(createReadStream(sent.file), response).catch(() => response.destroy());
return true;
};
}
+2
View File
@@ -3,8 +3,10 @@
export * from './auth.js';
export * from './blobRoutes.js';
export * from './blobs.js';
export * from './editorFiles.js';
export * from './log.js';
export * from './persistence.js';
export * from './precompress.js';
export * from './server.js';
export * from './service.js';
export * from './sessions.js';
+11 -1
View File
@@ -5,9 +5,15 @@
// them F does not start: an empty secret would let anyone sign. The cookie
// goes over HTTPS only, unless FILIUS_INSECURE_COOKIE=1 (a developer's
// machine without HTTPS). The rooms' thread is the bundle beside this one
// (dist/simWorker.mjs).
// (dist/simWorker.mjs), the editor's page and bundle the folder editor/
// beside it (FILIUS_EDITOR_DIR elsewhere); without that folder F answers
// only its own routes and says so in the log.
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PostgresBlobs } from './blobs.js';
import { editorFiles } from './editorFiles.js';
import { jsonLog } from './log.js';
import { startServer } from './server.js';
import { PostgresSessions } from './sessions.js';
@@ -28,6 +34,9 @@ const blobs = new PostgresBlobs(store.pool);
await store.migrate();
await sessions.migrate();
await blobs.migrate();
const editorFolder = process.env.FILIUS_EDITOR_DIR ?? fileURLToPath(new URL('./editor/', import.meta.url));
const editor = existsSync(join(editorFolder, 'index.html')) ? editorFiles(editorFolder) : undefined;
if (editor === undefined) log.warn('editor.missing', { folder: editorFolder });
const server = await startServer({
port: Number(process.env.FILIUS_PORT ?? 8080),
store,
@@ -39,6 +48,7 @@ const server = await startServer({
origins: config.FILIUS_ORIGINS!.split(',').map((origin) => origin.trim()),
secureCookie,
workerUrl: new URL('./simWorker.mjs', import.meta.url),
...(editor === undefined ? {} : { editor }),
});
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
+37
View File
@@ -0,0 +1,37 @@
// Lays a .br and a .gz beside every compressible file of the editor's
// bundle, once, when the image is built (precompressMain.ts); F sends them
// instead of the file (editorFiles.ts). At the strongest level (brotli's is
// its default), because it costs only the build: the editor's bundle shrinks to about a third, and a
// tablet on the school's network feels that at every opening. A packed file
// that is not smaller is not laid.
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { promisify } from 'node:util';
import { brotliCompress, constants, gzip } from 'node:zlib';
export const COMPRESSIBLE = new Set(['.html', '.js', '.mjs', '.css', '.json', '.svg', '.txt']);
const packers = [
{ suffix: '.br', pack: (bytes: Buffer) => promisify(brotliCompress)(bytes) },
{ suffix: '.gz', pack: (bytes: Buffer) => promisify(gzip)(bytes, { level: constants.Z_BEST_COMPRESSION }) },
];
/** Packs the compressible files under `folder`; returns how many packed files it laid. */
export async function precompress(folder: string): Promise<number> {
let laid = 0;
for (const entry of await readdir(folder, { withFileTypes: true })) {
const path = join(folder, entry.name);
if (entry.isDirectory()) laid += await precompress(path);
else if (entry.isFile() && COMPRESSIBLE.has(extname(entry.name))) {
const bytes = await readFile(path);
for (const { suffix, pack } of packers) {
const packed = await pack(bytes);
if (packed.length >= bytes.length) continue;
await writeFile(path + suffix, packed);
laid += 1;
}
}
}
return laid;
}
+11
View File
@@ -0,0 +1,11 @@
// `node precompressMain.mjs <folder>`: packs the editor's bundle in the image's
// build (precompress.ts).
import { precompress } from './precompress.js';
const folder = process.argv[2];
if (folder === undefined) {
console.error('usage: node precompressMain.mjs <folder>');
process.exit(2);
}
console.log(`precompress: ${await precompress(folder)} packed files laid in ${folder}`);
+7 -3
View File
@@ -3,8 +3,9 @@
// the dashboard (with the service key), /blobs for the large binary files
// of the projects' computers, the WebSockets /sync for the shared
// documents and /sim?project=… for the simulation rooms (the session's grant
// decides there as at /sync; a refusal closes the socket with its reason);
// anything else is not found. A WebSocket from a page of another
// decides there as at /sync; a refusal closes the socket with its reason),
// and the editor's page and bundle (editorFiles.ts) for anything else; what
// is none of these is not found. A WebSocket from a page of another
// origin is refused (a page elsewhere under the same domain would bring the
// person's cookie along: SameSite only tells sites apart). Closed, it first
// takes no one new (a client lets go and comes back at once, which would
@@ -21,6 +22,7 @@ import { WebSocketServer, type WebSocket } from 'ws';
import { exchangeTicket } from './auth.js';
import { handleBlobs } from './blobRoutes.js';
import type { BlobStore } from './blobs.js';
import type { EditorFiles } from './editorFiles.js';
import { errorText, type Log } from './log.js';
import { handleService } from './service.js';
import { holdMessages, SimRooms } from './simRooms.js';
@@ -50,6 +52,8 @@ export interface ServerOptions extends SyncOptions {
/** The bundled room thread (dist/simWorker.mjs). */
readonly workerUrl: URL;
readonly blobs: BlobStore;
/** The editor's page and bundle; without them only F's own routes answer (development, tests). */
readonly editor?: EditorFiles;
readonly simThreads?: number;
readonly lingerMilliseconds?: number;
readonly pingMilliseconds?: number;
@@ -111,7 +115,7 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
else if (request.method === 'GET' && path === '/__auth') await exchangeTicket(request, response, options);
else if (path.startsWith('/svc/')) await handleService(request, response, path, { ...options, sync, sims });
else if (path === '/blobs' || path.startsWith('/blobs/')) await handleBlobs(request, response, new URL(request.url ?? '/', 'http://filius'), options);
else json(response, 404, { error: 'notFound' });
else if (options.editor === undefined || !(await options.editor(request, response, path))) json(response, 404, { error: 'notFound' });
};
const http: Server = createServer((request, response) => {
const path = new URL(request.url ?? '/', 'http://filius').pathname;
+267
View File
@@ -0,0 +1,267 @@
// The editor's page and bundle from F (editorFiles.ts, precompress.ts):
// which file a path names and which it never names, what goes out packed,
// how long a browser keeps it, and that F's own routes stay F's.
import { randomBytes } from 'node:crypto';
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createServer, request as httpRequest, type IncomingHttpHeaders, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { brotliDecompressSync, gunzipSync } from 'node:zlib';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { accepts, ASSET_CACHE, editorFiles, fileOf, PAGE_CACHE, PostgresSessions, PostgresStore, precompress } from '../src/index.js';
import { testDatabase, type TestDatabase } from './database.js';
import { startTestServer } from './harness.js';
const PAGE = '<!doctype html><title>Filius</title>';
const SCRIPT = `export const devices = [${Array.from({ length: 400 }, (_, index) => `'device ${index}'`).join(', ')}];\n`;
/** A built editor as Vite lays it, beside a file it must never hand out. */
function builtEditor(): string {
const outside = mkdtempSync(join(tmpdir(), 'filius-editor-files-'));
writeFileSync(join(outside, 'secret.txt'), 'not for the browser');
const folder = join(outside, 'editor');
mkdirSync(join(folder, 'assets'), { recursive: true });
writeFileSync(join(folder, 'index.html'), PAGE);
writeFileSync(join(folder, 'assets.json'), '{}');
writeFileSync(join(folder, 'assets', 'index-AbC123.js'), SCRIPT);
writeFileSync(join(folder, 'assets', 'index-AbC123.js.br'), 'packed with brotli');
writeFileSync(join(folder, 'assets', 'index-AbC123.js.gz'), 'packed with gzip');
writeFileSync(join(folder, 'assets', 'router-Xy9.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
writeFileSync(join(folder, 'assets', 'Netz-Ab1.fls'), 'PK');
writeFileSync(join(folder, 'assets', 'style-Q1.css'), 'a{color:red}');
writeFileSync(join(folder, 'assets', 'style-Q1.css.gz'), 'packed with gzip only');
return folder;
}
interface Answer {
readonly status: number;
readonly headers: IncomingHttpHeaders;
readonly body: Buffer;
}
/** A request as it leaves a browser, the body as it arrives (not unpacked). */
function ask(base: string, path: string, headers: Record<string, string> = {}, method = 'GET'): Promise<Answer> {
return new Promise((resolve, reject) => {
const sent = httpRequest(`${base}${path}`, { method, headers }, (response) => {
const parts: Buffer[] = [];
response.on('data', (part: Buffer) => parts.push(part));
response.on('end', () => resolve({ status: response.statusCode ?? 0, headers: response.headers, body: Buffer.concat(parts) }));
});
sent.on('error', reject);
sent.end();
});
}
describe('fileOf', () => {
const root = '/srv/editor';
it('names the page for the root and a file below for its path', () => {
expect(fileOf(root, '/')).toBe('/srv/editor/index.html');
expect(fileOf(root, '/index.html')).toBe('/srv/editor/index.html');
expect(fileOf(root, '/assets/index-AbC123.js')).toBe('/srv/editor/assets/index-AbC123.js');
expect(fileOf(root, '/assets/Gro%C3%9Fes%20Netz.fls')).toBe('/srv/editor/assets/Großes Netz.fls');
});
it('resolves a way round inside the folder to where it leads', () => {
expect(fileOf(root, '/assets/../index.html')).toBe('/srv/editor/index.html');
expect(fileOf(root, '/./assets//index-AbC123.js')).toBe('/srv/editor/assets/index-AbC123.js');
});
it.each([['/..'], ['/../secret.txt'], ['/%2e%2e/secret.txt'], ['/assets/..%2f..%2fsecret.txt'], ['/assets/../../editor-old/index.html'], ['/%E0%A4%A']])('names nothing for %s', (path) => {
expect(fileOf(root, path)).toBeUndefined();
});
});
describe('accepts', () => {
it.each([
['gzip, deflate, br, zstd', 'br', true],
['gzip, deflate, br, zstd', 'gzip', true],
['gzip;q=0.5, br;q=1.0', 'br', true],
['BR', 'br', true],
['gzip, br;q=0', 'br', false],
['br; q=0.000', 'br', false],
['br;q=0.01', 'br', true],
['gzip', 'br', false],
['', 'gzip', false],
['xbr, brx', 'br', false],
])('%j takes %s: %s', (header, name, taken) => {
expect(accepts(header, name)).toBe(taken);
});
});
describe('editorFiles', () => {
let server: Server;
let base: string;
beforeAll(async () => {
const files = editorFiles(builtEditor());
server = createServer((request, response) => {
void files(request, response, new URL(request.url ?? '/', 'http://filius').pathname).then((answered) => {
if (!answered) response.writeHead(404).end();
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
it('hands out the page, asked for again at every opening', async () => {
for (const path of ['/', '/?project=1b4e28ba-2fa1-41d2-883f-0016d3cca427', '/index.html']) {
const answer = await ask(base, path);
expect(answer.status).toBe(200);
expect(answer.body.toString()).toBe(PAGE);
expect(answer.headers['content-type']).toBe('text/html; charset=utf-8');
expect(answer.headers['cache-control']).toBe(PAGE_CACHE);
expect(answer.headers['x-content-type-options']).toBe('nosniff');
}
});
it('keeps a file of the bundle a year: its name changes with its content', async () => {
const answer = await ask(base, '/assets/router-Xy9.png');
expect(answer.headers['cache-control']).toBe(ASSET_CACHE);
expect(answer.headers['content-type']).toBe('image/png');
expect(answer.headers['content-length']).toBe('4');
});
it('keeps only what lies in assets/ a year, not a neighbour of a like name', async () => {
expect((await ask(base, '/assets.json')).headers['cache-control']).toBe(PAGE_CACHE);
});
it('names what it does not know a series of bytes', async () => {
expect((await ask(base, '/assets/Netz-Ab1.fls')).headers['content-type']).toBe('application/octet-stream');
});
it('sends the packed file the browser takes, the better first', async () => {
const brotli = await ask(base, '/assets/index-AbC123.js', { 'accept-encoding': 'gzip, deflate, br, zstd' });
expect([brotli.headers['content-encoding'], brotli.body.toString()]).toEqual(['br', 'packed with brotli']);
expect(brotli.headers['content-length']).toBe(String('packed with brotli'.length));
expect(brotli.headers['content-type']).toBe('text/javascript; charset=utf-8');
expect(brotli.headers.vary).toBe('Accept-Encoding');
const gzip = await ask(base, '/assets/index-AbC123.js', { 'accept-encoding': 'gzip, br;q=0' });
expect([gzip.headers['content-encoding'], gzip.body.toString()]).toEqual(['gzip', 'packed with gzip']);
const plain = await ask(base, '/assets/index-AbC123.js');
expect([plain.headers['content-encoding'], plain.body.toString()]).toEqual([undefined, SCRIPT]);
});
it('sends the next packed file the browser takes when the better one is missing', async () => {
const answer = await ask(base, '/assets/style-Q1.css', { 'accept-encoding': 'br, gzip' });
expect([answer.headers['content-encoding'], answer.body.toString()]).toEqual(['gzip', 'packed with gzip only']);
});
it('sends a file without a packed one as it is, whatever the browser takes', async () => {
const answer = await ask(base, '/index.html', { 'accept-encoding': 'br, gzip' });
expect([answer.headers['content-encoding'], answer.body.toString()]).toEqual([undefined, PAGE]);
});
it('answers HEAD with the headers alone', async () => {
const answer = await ask(base, '/', {}, 'HEAD');
expect(answer.status).toBe(200);
expect(answer.headers['content-length']).toBe(String(PAGE.length));
expect(answer.body.length).toBe(0);
});
it.each([
['GET', '/../secret.txt'],
['GET', '/%2e%2e/secret.txt'],
['GET', '/..%5csecret.txt'],
['GET', '/index.html%00'],
['GET', '/assets'],
['GET', '/assets/'],
['GET', '/missing.js'],
['POST', '/'],
['DELETE', '/index.html'],
])('leaves %s %s to the caller', async (method, path) => {
expect((await ask(base, path, {}, method)).status).toBe(404);
});
});
describe('precompress', () => {
it('lays a smaller .br and .gz beside each compressible file, below too, and nothing else', async () => {
const folder = mkdtempSync(join(tmpdir(), 'filius-precompress-'));
mkdirSync(join(folder, 'assets'));
writeFileSync(join(folder, 'index.html'), `<!doctype html>${'<p>Filius</p>'.repeat(50)}`);
writeFileSync(join(folder, 'assets', 'index.js'), SCRIPT);
writeFileSync(join(folder, 'assets', 'tiny.css'), 'a{}');
// A picture would pack well here; it is left alone for its kind, not its size.
writeFileSync(join(folder, 'assets', 'router.png'), SCRIPT);
writeFileSync(join(folder, 'assets', 'packed.js'), randomBytes(4096));
expect(await precompress(folder)).toBe(4);
expect(readdirSync(folder).sort()).toEqual(['assets', 'index.html', 'index.html.br', 'index.html.gz']);
expect(readdirSync(join(folder, 'assets')).sort()).toEqual(['index.js', 'index.js.br', 'index.js.gz', 'packed.js', 'router.png', 'tiny.css']);
const script = join(folder, 'assets', 'index.js');
expect(brotliDecompressSync(readFileSync(`${script}.br`)).toString()).toBe(SCRIPT);
expect(gunzipSync(readFileSync(`${script}.gz`)).toString()).toBe(SCRIPT);
expect(readFileSync(`${script}.br`).length).toBeLessThan(readFileSync(`${script}.gz`).length);
// A second run packs the same files again, not the packed ones.
expect(await precompress(folder)).toBe(4);
expect(readdirSync(join(folder, 'assets'))).toHaveLength(6);
});
});
describe('the editor on server F', () => {
let database: TestDatabase;
let store: PostgresStore;
let sessions: PostgresSessions;
beforeAll(async () => {
database = await testDatabase();
store = PostgresStore.connect(database.url);
sessions = new PostgresSessions(store.pool);
await store.migrate();
await sessions.migrate();
}, 60_000);
afterAll(async () => {
await store?.close();
await database?.stop();
});
it("comes from F's host, and F's own routes stay F's", async () => {
const folder = builtEditor();
writeFileSync(join(folder, 'healthz'), 'a file named like a route');
mkdirSync(join(folder, 'svc'));
writeFileSync(join(folder, 'svc', 'sessions'), 'a file named like a route');
const server = await startTestServer(store, sessions, { editor: editorFiles(folder) });
try {
expect((await ask(server.base, '/')).body.toString()).toBe(PAGE);
const health = await ask(server.base, '/healthz');
expect(JSON.parse(health.body.toString())).toMatchObject({ status: 'ok' });
expect((await ask(server.base, '/svc/sessions')).status).toBe(403);
expect(JSON.parse((await ask(server.base, '/missing.js')).body.toString())).toEqual({ error: 'notFound' });
} finally {
await server.close();
}
});
it('lets a browser go that leaves mid-file, without counting it a failure', async () => {
const folder = builtEditor();
writeFileSync(join(folder, 'assets', 'large-Zz1.js'), randomBytes(16 * 1024 * 1024));
const server = await startTestServer(store, sessions, { editor: editorFiles(folder) });
try {
await new Promise<void>((resolve) => {
const sent = httpRequest(`${server.base}/assets/large-Zz1.js`, (response) => response.once('data', () => sent.destroy()));
sent.on('close', () => resolve());
sent.on('error', () => undefined);
sent.end();
});
await new Promise((resolve) => setTimeout(resolve, 200));
expect(server.log.lines.filter((line) => line.event === 'http.failed')).toEqual([]);
expect((await ask(server.base, '/')).body.toString()).toBe(PAGE);
} finally {
await server.close();
}
});
it('answers only its own routes without an editor', async () => {
const server = await startTestServer(store, sessions);
try {
const answer = await ask(server.base, '/');
expect([answer.status, JSON.parse(answer.body.toString())]).toEqual([404, { error: 'notFound' }]);
} finally {
await server.close();
}
});
});