Files
julian.kiedaischandClaude Opus 5.5 087721cba3 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>
2026-10-04 17:43:51 +00:00

268 lines
12 KiB
TypeScript

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