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
+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;