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