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:
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user