From 087721cba36d266c8a2a9f3a63f9ea791aae8207 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Sun, 4 Oct 2026 17:43:51 +0000 Subject: [PATCH] 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 --- .dockerignore | 12 + .gitignore | 1 + CHANGES.md | 10 + README.md | 20 +- deploy/Dockerfile | 43 ++++ deploy/docker-compose.yml | 73 +++++++ deploy/production.env.example | 23 ++ deploy/test.env.example | 23 ++ packages/editor/browser/editorPage.ts | 5 + packages/editor/browser/fromF.spec.ts | 54 +++++ packages/editor/browser/serverF.ts | 11 +- packages/server/package.json | 2 +- packages/server/src/editorFiles.ts | 108 +++++++++ packages/server/src/index.ts | 2 + packages/server/src/main.ts | 12 +- packages/server/src/precompress.ts | 37 ++++ packages/server/src/precompressMain.ts | 11 + packages/server/src/server.ts | 10 +- packages/server/test/editorFiles.test.ts | 267 +++++++++++++++++++++++ 19 files changed, 714 insertions(+), 10 deletions(-) create mode 100644 .dockerignore create mode 100644 deploy/Dockerfile create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/production.env.example create mode 100644 deploy/test.env.example create mode 100644 packages/editor/browser/fromF.spec.ts create mode 100644 packages/server/src/editorFiles.ts create mode 100644 packages/server/src/precompress.ts create mode 100644 packages/server/src/precompressMain.ts create mode 100644 packages/server/test/editorFiles.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..74012fa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# What the image's build (deploy/Dockerfile) does not need: installed and +# built things are made anew inside, the oracles and test output stay here, +# and the environment files hold secrets. +**/node_modules +**/dist +**/*.tsbuildinfo +.git +tools +test-results +playwright-report +coverage +deploy/*.env diff --git a/.gitignore b/.gitignore index b6069ec..eabfba6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ !.vscode/extensions.json test-results/ playwright-report/ +deploy/*.env diff --git a/CHANGES.md b/CHANGES.md index 854e85d..b980436 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -283,6 +283,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/sync.ts` (Zugang gleich zu Beginn, stille Absage) | Plan §8.5 | geändert (F5.3b): jede Verbindung erfährt ihren Zugang sofort nach der Anmeldung (Nachricht `access`), damit der Editor weiß, ob er liest und warum; eine Absage trägt keinen Meldungstext — Hocuspocus schreibt sonst die Meldung eines gescheiterten Hooks auf die Konsole, außerhalb des JSON-Protokolls | | `src/sync.ts` (`you`), `protocol/src/sync.ts` (`AccessMessage.you`) | Plan §7.4 | erweitert (F5.4): die Zugangsnachricht beim Verbinden nennt die Person (Kennung, Name, Farbe aus der Karte), damit der Editor sich den anderen zeigen kann — der Keks ist für die Seite nicht lesbar | | `src/blobs.ts`, `src/blobRoutes.ts`, `src/simWorker.ts` (Blobs), `src/simRooms.ts` (Blobs), `src/simChannel.ts`, `src/server.ts` (`/blobs`), `src/main.ts`, `test/blobs.test.ts` | Plan §5.1 | neu (F5.5): die Blobs auf F, inhaltsadressiert (`filius_blob`, F rechnet den Namen selbst aus); `GET /blobs/?project=` mit Berechtigung am Projekt, `POST /blobs?project=` mit Schreibstufe, nicht eingefroren, nur von der Herkunft des Editors, höchstens 16 MiB; CORS für die Herkünfte des Editors; der Raumfaden fragt nach den Blobs seines Dokuments, ein Start wartet auf die gefragten, was F noch nicht hat, wird ein paar Mal neu gefragt und kommt in die laufende Simulation; ein Blob, den die Simulation macht, wird gespeichert, bevor die Änderung, die ihn nennt, angewandt wird. Noch nicht: Blobs vergessen, die kein Projekt mehr nennt | +| `src/editorFiles.ts`, `src/precompress.ts`, `src/precompressMain.ts`, `src/server.ts` (Editor), `src/main.ts` (`FILIUS_EDITOR_DIR`), `test/editorFiles.test.ts`, `editor/browser/fromF.spec.ts` | Plan §8.2, §8.3 | neu (F5.6b): F liefert die Seite und das Bundle des Editors selbst aus (Ordner `editor/` neben `main.mjs`), damit der Editor von Fs Herkunft kommt und sie als seinen Server nimmt; nur GET und HEAD, nur Dateien im Ordner (jedes `..`, auch kodiert, wird aufgelöst und außerhalb abgewiesen); Fs eigene Wege gehen vor; `assets/` (Vite benennt nach dem Inhalt) ein Jahr im Cache, alles andere jedes Mal neu gefragt; der Build legt `.br` und `.gz` daneben, wo sie kleiner sind, F schickt die bessere, die der Browser nimmt (das Bundle 695 KB → 212 KB mit brotli). Ohne Ordner beantwortet F nur seine Wege und meldet es im Protokoll | | `test/editorConnection.test.ts` | — | neu (F5.3b), erweitert (F5.4: Identität von F, die anderen über die Awareness, auch eine lesende Person, weg mit dem Schließen): `connectSharedDoc` des Editors gegen einen echten F — bereit mit Schreib- und Lesestufe, eingefroren und aufgetaut (neues Dokument, die verirrte Änderung ist weg, Schreiben geht wieder), eingefroren von Beginn an, Absage, Widerruf, Löschen, Loslassen | | `test/sim.test.ts`, `test/harness.ts` (Faden) | — | neu (F5.3a): echte Sockets, der echte Faden (esbuild bündelt ihn wie der Build), Beispiel internetwork_komplett: ein Start für alle mit Namen, Ping in einer Befehlszeile bei der anderen, Zurückschreiben ins Dokument und eine Datei aus dem Dokument im Raum, Lesestufe, Absagen, Nachlauf, Einfrieren, Löschen, Widerruf, Wahl des Fadens, scheiternder Faden, Herunterfahren, Ping | | `test/ticket.test.ts`, `test/tickets.ts`, `test/tickets.python.json` | `tools/ticket-oracle` | neu (F5.2): jede Karte des Python-Orakels liest Node wie Python (Nutzlast oder derselbe Grund der Ablehnung); in Node signierte Karten tragen dasselbe JSON zum selben Zeitpunkt (Nodes zlib packt anders, ungepackt byte-gleich); die Ansprüche | @@ -300,3 +301,12 @@ antwortet: `packages/server/test/tickets.python.json`. Baut den unveränderten Swift-Kern (Stand `3b5ded0`) unter Linux, nur zwei Ausdrücke sind für den Linux-Compiler aufgeteilt (`patches/`). Die Quellen selbst werden beim Bau geholt und nicht eingecheckt. + +## deploy + +`Dockerfile`, `docker-compose.yml`, `test.env.example`, +`production.env.example`, `proxy.conf`, dazu `.dockerignore` — neu +(F5.6a/b): das Image von F (zwei Stufen, Node 24, ohne `node_modules`, nicht +als root), ein Stack je Umgebung mit eigenem Namen, Volume, Image-Tag und Port +(Test 8081, Produktion 8080) und der Eintrag für den externen OpenResty. + diff --git a/README.md b/README.md index 0032095..9d1c3f7 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ die Herkunft je Paket. Lizenz dieses Repos: **GPL-3.0** (`LICENSE`). | `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) | | `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` | | `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite), auf einem Projekt von F (`?project=`) oder mit einem Raum in der eigenen Seite (Entwicklung): Fläche, Palette, Werkzeuge des Entwurfs, Geräte-Blatt, Aktionsmodus (Desktops, Anwendungen, Datenaustausch), Dokumentationsmodus (Text, Gliederungsfläche, Bild exportieren, Bericht als PDF); `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite | -| `packages/server` | Filius-Dienst auf Server F: gemeinsame Dokumente über Hocuspocus (`/sync`), gespeichert in Postgres (Stand und Aktualisierungen, verdichtet), Hausmeister; Anmeldung mit der Eintrittskarte des Dashboards (`/__auth`, Keks, Lese- und Schreibstufe); Dienstweg des Dashboards (`/svc/*`: Projekte anlegen, löschen, einfrieren, Zugänge widerrufen); Simulationsräume auf `/sim` in `worker_threads`, einer je Projekt für alle; `/healthz`; `npm run build -w @mdg-filius/server` bündelt ihn nach `dist/main.mjs` und den Faden der Räume nach `dist/simWorker.mjs` | +| `packages/server` | Filius-Dienst auf Server F: gemeinsame Dokumente über Hocuspocus (`/sync`), gespeichert in Postgres (Stand und Aktualisierungen, verdichtet), Hausmeister; Anmeldung mit der Eintrittskarte des Dashboards (`/__auth`, Keks, Lese- und Schreibstufe); Dienstweg des Dashboards (`/svc/*`: Projekte anlegen, löschen, einfrieren, Zugänge widerrufen); Simulationsräume auf `/sim` in `worker_threads`, einer je Projekt für alle; `/healthz`; liefert Seite und Bundle des Editors aus (`editor/` neben `main.mjs`, vorab gepackt); `npm run build -w @mdg-filius/server` bündelt ihn nach `dist/main.mjs` und den Faden der Räume nach `dist/simWorker.mjs` | +| `deploy` | Image (`Dockerfile`), ein Stack je Umgebung (`docker-compose.yml`, `*.env.example`), Eintrag für den externen OpenResty (`proxy.conf`) | Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard. @@ -61,9 +62,26 @@ Zwei Docker-Stacks auf Server F, je mit eigener Datenbank, eigenem | `test-filius.hub.mdg-hamburg.de` | 8081 | `test-dashboard` | | `filius.hub.mdg-hamburg.de` | 8080 | `dashboard` | +Im Klon auf F, je Umgebung einmal: + +```bash +cp deploy/test.env.example deploy/test.env # leere Werte füllen +docker compose -f deploy/docker-compose.yml --env-file deploy/test.env up -d --build +``` + +(für die Produktion dasselbe mit `production`). Die Umgebungsdatei bestimmt +Stack-Namen, Volume, Image-Tag und Port; die beiden Stacks teilen nichts. +Braucht Docker Compose v2. Gesichert werden muss nur das Volume +`_db` (Befehl in `deploy/docker-compose.yml`). + `deploy/proxy.conf` ist der Eintrag für den externen OpenResty (in dessen `conf.d`); vorher die Adresse von F für `filius-lxc.invalid` einsetzen. `FILIUS_ORIGINS` ist jeweils der eigene Name mit `https://`. +Das Image liefert auch den Editor aus: F nimmt den Ordner `editor/` neben +`main.mjs` (anderswo `FILIUS_EDITOR_DIR`). Es läuft auf Node 24, die +Entwicklung auf Node 20; vor einem Update einmal mit +`docker compose … up --build` auf `test-filius` prüfen. + Code, Kommentare und Testnamen sind englisch; sichtbare Texte stehen hinter i18n-Schlüsseln (Deutsch/Englisch). diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..cedc7b3 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,43 @@ +# Server F as an image: the Filius service and the editor's page in one. +# +# Built from the repository's root (the build needs every package and the +# corpus, whose FILIUS examples the editor carries): +# +# docker compose -f deploy/docker-compose.yml --env-file deploy/test.env build +# +# Two stages. The first installs everything and builds: esbuild bundles the +# service into main.mjs and its room thread into simWorker.mjs, with every +# dependency inside; Vite builds the editor; precompressMain.mjs lays a .br +# and a .gz beside its files. The second stage takes only these files, so +# the image holds no node_modules, no sources and no compiler. +# +# Node 24 (active LTS), not the 20 of the development machines: 20 has had +# no security fixes since April 2026, and a service facing the browsers of a +# whole school should not run on it. The bundles are built for node20 and +# run unchanged on 24; the image's run is checked before each release (see +# the README). + +FROM node:24-alpine AS build +WORKDIR /src +COPY . . +RUN npm ci --no-audit --no-fund +RUN npm run build -w @mdg-filius/server \ + && npm run build -w @mdg-filius/editor \ + && node packages/server/dist/precompressMain.mjs packages/editor/dist + +FROM node:24-alpine +LABEL org.opencontainers.image.title="mdg-filius" \ + org.opencontainers.image.description="Filius network simulation, shared editing (server F)" \ + org.opencontainers.image.licenses="GPL-3.0-only" +ENV NODE_ENV=production +WORKDIR /app +COPY --from=build /src/packages/server/dist/main.mjs /src/packages/server/dist/simWorker.mjs ./ +COPY --from=build /src/packages/editor/dist ./editor +COPY LICENSE README.md CHANGES.md ./ +# Not root: F needs no privilege, and a hole in it should not get one. +USER node +EXPOSE 8080 +# The proxy keeps no watch of its own; this one shows in `docker compose ps`. +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 +CMD ["node", "main.mjs"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..4ed1bb2 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,73 @@ +# Server F: one stack per environment, both on the same machine. +# +# docker compose -f deploy/docker-compose.yml --env-file deploy/test.env up -d --build +# docker compose -f deploy/docker-compose.yml --env-file deploy/production.env up -d --build +# +# The environment file decides everything that tells the two apart, and it +# does so through **names**, not through the place the command runs in: +# +# * `name:` is the stack's name (`filius-test`, `filius-production`). Docker +# prefixes containers, network and volume with it, so the two stacks share +# nothing — above all not the database. Without it, compose would take the +# folder's name, and both stacks started from this folder would be **one** +# stack: the second `up` would replace the first. That is also why no +# `-p` is needed (and why forgetting one does no harm, unlike on server S). +# * `image:` carries the stack's name as its tag. A shared tag would let a +# build for the test environment change what production runs at its next +# restart. +# * The port: test 8081, production 8080, as `deploy/proxy.conf` expects. +# +# Needs Docker Compose v2 (`docker compose`; the old `docker-compose` 1.x +# does not know `name:`). +# +# The data lies in the volume `_db` (documents, sessions, blobs). It is +# the one thing of a stack that has to be backed up: +# +# docker compose -f deploy/docker-compose.yml --env-file deploy/production.env \ +# exec -T db pg_dump -U filius filius > filius-production.sql + +name: "${FILIUS_STACK:?FILIUS_STACK missing, start with --env-file deploy/.env}" + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: filius + POSTGRES_USER: filius + POSTGRES_PASSWORD: ${FILIUS_DB_PASSWORD:?FILIUS_DB_PASSWORD missing} + volumes: + - db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U filius -d filius"] + interval: 5s + timeout: 5s + retries: 12 + # No `ports:`: only F talks to its database, over the stack's network. + + filius: + image: mdg-filius:${FILIUS_STACK} + build: + context: .. + dockerfile: deploy/Dockerfile + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + # The password goes into a URL: hex only (`openssl rand -hex 32`), a + # `@`, `/` or `:` in it would break the URL. + DATABASE_URL: postgres://filius:${FILIUS_DB_PASSWORD}@db:5432/filius + FILIUS_TICKET_SECRET: ${FILIUS_TICKET_SECRET:?FILIUS_TICKET_SECRET missing} + FILIUS_SERVICE_KEY: ${FILIUS_SERVICE_KEY:?FILIUS_SERVICE_KEY missing} + FILIUS_ORIGINS: ${FILIUS_ORIGINS:?FILIUS_ORIGINS missing} + ports: + # The address the openresty reaches F on, and the stack's port. + - "${FILIUS_BIND:?FILIUS_BIND missing}:${FILIUS_PUBLIC_PORT:?FILIUS_PUBLIC_PORT missing}:8080" + # F stops its rooms and stores every open document before it ends + # (main.ts, SIGTERM); Docker's 10 s are enough today, 30 s leave room + # for a large school day. + stop_grace_period: 30s + +volumes: + db: diff --git a/deploy/production.env.example b/deploy/production.env.example new file mode 100644 index 0000000..5c00382 --- /dev/null +++ b/deploy/production.env.example @@ -0,0 +1,23 @@ +# Server F, production environment: copy to deploy/production.env and fill in the empty +# values (deploy/*.env is not in git). Started with +# +# docker compose -f deploy/docker-compose.yml --env-file deploy/production.env up -d --build + +# The stack's name: containers, volume and image tag (docker-compose.yml). +FILIUS_STACK=filius-production + +# Where the openresty reaches F: the address of the LXC container, and the +# port deploy/proxy.conf names for filius.hub.mdg-hamburg.de. +FILIUS_BIND= +FILIUS_PUBLIC_PORT=8080 + +# The origin of the page the editor runs in: this environment's own name. +FILIUS_ORIGINS=https://filius.hub.mdg-hamburg.de + +# Hex only (openssl rand -hex 32): the password goes into F's database URL. +FILIUS_DB_PASSWORD= + +# The same values as in the environment of dashboard (it signs the +# tickets and calls /svc/* with the key). Each environment its own. +FILIUS_TICKET_SECRET= +FILIUS_SERVICE_KEY= diff --git a/deploy/test.env.example b/deploy/test.env.example new file mode 100644 index 0000000..4a9e7a4 --- /dev/null +++ b/deploy/test.env.example @@ -0,0 +1,23 @@ +# Server F, test environment: copy to deploy/test.env and fill in the empty +# values (deploy/*.env is not in git). Started with +# +# docker compose -f deploy/docker-compose.yml --env-file deploy/test.env up -d --build + +# The stack's name: containers, volume and image tag (docker-compose.yml). +FILIUS_STACK=filius-test + +# Where the openresty reaches F: the address of the LXC container, and the +# port deploy/proxy.conf names for test-filius.hub.mdg-hamburg.de. +FILIUS_BIND= +FILIUS_PUBLIC_PORT=8081 + +# The origin of the page the editor runs in: this environment's own name. +FILIUS_ORIGINS=https://test-filius.hub.mdg-hamburg.de + +# Hex only (openssl rand -hex 32): the password goes into F's database URL. +FILIUS_DB_PASSWORD= + +# The same values as in the environment of test-dashboard (it signs the +# tickets and calls /svc/* with the key). Each environment its own. +FILIUS_TICKET_SECRET= +FILIUS_SERVICE_KEY= diff --git a/packages/editor/browser/editorPage.ts b/packages/editor/browser/editorPage.ts index 5043ef0..8aae7ba 100644 --- a/packages/editor/browser/editorPage.ts +++ b/packages/editor/browser/editorPage.ts @@ -10,6 +10,11 @@ import type { DesktopView, DeviceSheet, TopologyView } from '@mdg-filius/doc'; const config = join(dirname(fileURLToPath(import.meta.url)), '../vite.config.ts'); +/** The editor built into `outDir`, as the image's build builds it. */ +export async function buildEditor(outDir: string): Promise { + await build({ configFile: config, logLevel: 'silent', build: { outDir, emptyOutDir: true } }); +} + export async function serveEditor(): Promise<{ readonly base: string; readonly close: () => Promise }> { await build({ configFile: config, logLevel: 'silent' }); const server: PreviewServer = await preview({ configFile: config, preview: { port: 0 }, logLevel: 'silent' }); diff --git a/packages/editor/browser/fromF.spec.ts b/packages/editor/browser/fromF.spec.ts new file mode 100644 index 0000000..f314775 --- /dev/null +++ b/packages/editor/browser/fromF.spec.ts @@ -0,0 +1,54 @@ +// The editor as the image delivers it (F5.6b): built, packed, and handed out +// by server F itself, so that the page comes from F's own origin and takes it +// for its server — no `&server=` in the address. Two people in one project +// see each other, and a simulation one starts runs for the other; the +// bundle arrives packed with brotli. + +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test, type Browser, type Page } from '@playwright/test'; +import { precompress } from '../../server/src/precompress.js'; +import { buildEditor } from './editorPage.js'; +import { startServerF, type ServerF } from './serverF.js'; + +let f: ServerF; + +test.beforeAll(async () => { + const folder = mkdtempSync(join(tmpdir(), 'filius-editor-from-f-')); + await buildEditor(folder); + await precompress(folder); + f = await startServerF('http://localhost', folder); +}); + +test.afterAll(async () => { + await f?.close(); +}); + +async function person(browser: Browser, project: string, name: string, user: string): Promise<{ readonly page: Page; readonly encodings: Map }> { + const context = await browser.newContext(); + await f.signIn(context, project, { name, user }); + const page = await context.newPage(); + const encodings = new Map(); + page.on('response', async (response) => { + const path = new URL(response.url()).pathname; + if (path.startsWith('/assets/index-') && path.endsWith('.js')) encodings.set(path, (await response.allHeaders())['content-encoding'] ?? 'none'); + }); + await page.goto(`${f.base}/?project=${project}`); + await page.waitForSelector('svg.filius-canvas'); + return { page, encodings }; +} + +test('the editor from F itself: two people, one simulation, the bundle packed', async ({ browser }) => { + const project = await f.project('java-examples/internetwork_komplett.fls'); + const [miaUser, benUser] = [crypto.randomUUID(), crypto.randomUUID()]; + const mia = await person(browser, project.id, 'Mia', miaUser); + const ben = await person(browser, project.id, 'Ben', benUser); + await expect(mia.page.locator(`[data-person="${benUser}"]`)).toBeVisible(); + await expect(ben.page.locator(`[data-person="${miaUser}"]`)).toBeVisible(); + await expect(ben.page.locator('g.device')).toHaveCount(await mia.page.locator('g.device').count()); + await mia.page.locator('button[name="simulationMode"]').click(); + await expect(ben.page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); + await expect(ben.page.locator('[data-note="startedBy"]')).toHaveText('Simulation gestartet von Mia'); + expect([...mia.encodings.values()]).toEqual(['br']); +}); diff --git a/packages/editor/browser/serverF.ts b/packages/editor/browser/serverF.ts index 5bbb240..7c94bc4 100644 --- a/packages/editor/browser/serverF.ts +++ b/packages/editor/browser/serverF.ts @@ -4,6 +4,8 @@ // editor's page comes from, so that F's cookie goes with the page's // requests. A project is made in F's store straight from a FILIUS file; a // person signs in as the dashboard would let them, with a ticket at /__auth. +// Given a built editor, F hands it out itself, as in the image: then the +// editor's page comes from F's own origin. import { spawn, type ChildProcess } from 'node:child_process'; import { mkdtempSync, readFileSync } from 'node:fs'; @@ -34,8 +36,8 @@ export interface ServerF { close(): Promise; } -/** Server F on the editor page's host (`editorBase`), for pages from there. */ -export async function startServerF(editorBase: string): Promise { +/** Server F on the editor page's host (`editorBase`), for pages from there, or from itself with `editorFolder`. */ +export async function startServerF(editorBase: string, editorFolder?: string): Promise { const editor = new URL(editorBase); const database: TestDatabase = await testDatabase(); const out = mkdtempSync(join(tmpdir(), 'filius-server-f-')); @@ -53,13 +55,15 @@ export async function startServerF(editorBase: string): Promise { logLevel: 'error', }); const port = 20_000 + Math.floor(Math.random() * 20_000); + const base = `${editor.protocol}//${editor.hostname}:${port}`; const child: ChildProcess = spawn(process.execPath, [join(out, 'main.mjs')], { env: { ...process.env, DATABASE_URL: database.url, FILIUS_TICKET_SECRET: TICKET_SECRET, FILIUS_SERVICE_KEY: SERVICE_KEY, - FILIUS_ORIGINS: editor.origin, + FILIUS_ORIGINS: editorFolder === undefined ? editor.origin : base, + ...(editorFolder === undefined ? {} : { FILIUS_EDITOR_DIR: editorFolder }), FILIUS_PORT: String(port), FILIUS_INSECURE_COOKIE: '1', }, @@ -71,7 +75,6 @@ export async function startServerF(editorBase: string): Promise { }); child.once('exit', (code) => reject(new Error(`server F ended (${code})`))); }); - const base = `${editor.protocol}//${editor.hostname}:${port}`; const sql = new pg.Pool({ connectionString: database.url }); return { base, diff --git a/packages/server/package.json b/packages/server/package.json index 5ab0f11..7cd58b9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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": { diff --git a/packages/server/src/editorFiles.ts b/packages/server/src/editorFiles.ts new file mode 100644 index 0000000..5d9cbed --- /dev/null +++ b/packages/server/src/editorFiles.ts @@ -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> = { + '.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; + +/** + * 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 { + 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; + }; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 014b6c6..1c85a41 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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'; diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index 08e3d39..74b453b 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -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, () => { diff --git a/packages/server/src/precompress.ts b/packages/server/src/precompress.ts new file mode 100644 index 0000000..b63b409 --- /dev/null +++ b/packages/server/src/precompress.ts @@ -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 { + 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; +} diff --git a/packages/server/src/precompressMain.ts b/packages/server/src/precompressMain.ts new file mode 100644 index 0000000..b78eb41 --- /dev/null +++ b/packages/server/src/precompressMain.ts @@ -0,0 +1,11 @@ +// `node precompressMain.mjs `: 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 '); + process.exit(2); +} +console.log(`precompress: ${await precompress(folder)} packed files laid in ${folder}`); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 4262ede..3c29761 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -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 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; diff --git a/packages/server/test/editorFiles.test.ts b/packages/server/test/editorFiles.test.ts new file mode 100644 index 0000000..eaa41c6 --- /dev/null +++ b/packages/server/test/editorFiles.test.ts @@ -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 = 'Filius'; +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 = {}, method = 'GET'): Promise { + 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((resolve) => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(() => new Promise((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'), `${'

Filius

'.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((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(); + } + }); +});