feat(core): FILIUS' web server and web browser after Java, measured against Java

The servers share ServerAnwendung's frame (server.ts); the web server serves
/webserver with virtual hosts, the browser loads a page and its images over
one connection to port 80 (web.ts); HTTP in Java's text form (http.ts).
WebRun records eleven stories on the new web-lan network, with the server's
files; webJava.test.ts plays them card by card. A program's close is a
request, as Java's schliessen. Not taken from Java: /../ serving any file of
the host; an unreadable request closing the connection without a word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 16:53:00 +00:00
co-authored by Claude Opus 5.5
parent de3a1d4dfe
commit 4cd5749350
33 changed files with 53601 additions and 216 deletions
+305
View File
@@ -0,0 +1,305 @@
// The web server and the web browser as Java FILIUS runs them.
// tools/java-oracle's WebRun played stories of the two programs on web-lan's
// network in Java FILIUS and wrote every frame each card saw, Java's tables
// of them, every line the server wrote into its window, everything the
// browser told its window, and the server's files (web-java-<story>.java.json).
// The engine plays the same stories with its programs (apps/web.ts), on a
// server with the same files: each card's frames must come in the same
// order, with the same content and rows, at about the same time; the
// server's lines and the browser's pages and images in the same order, at
// about the same time.
//
// The browser's error pages are its own (the editor's): for a status other
// than 200 the page is compared by its status and content type, not by
// Java's text for it.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
javaBase64Decode,
parentPath,
VirtualFileSystem,
withSnapshot,
type LogLine,
type NetworkRuntimeEngine,
type WebBrowserLogEntry,
} from '../src/index.js';
import { expectSameCaptures, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js';
import { clientText, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const BROWSER = '00000000-0000-0000-0000-000000000201';
const SERVER = '00000000-0000-0000-0000-000000000203';
type Program = 'server' | 'browser' | 'client';
/**
* Java's threads wake a millisecond or two late at each segment; over a page
* with its images (some 70 frames, one after the other) Java falls behind by
* up to some 130 ms (measured: byName, post, caseInsensitive).
*/
const TOLERANCE_MILLISECONDS = 200;
/** Java's timeout of a TCP attempt (3 round trips at 90 %). */
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
events: [number, Program, string][];
/** The server's files at the end: path, Java's type, content (images in Base64). */
files: [string, string, string][];
}
type Act = (engine: NetworkRuntimeEngine) => void;
const browser = (engine: NetworkRuntimeEngine) => engine.webBrowser(BROWSER)!;
const client = (engine: NetworkRuntimeEngine) => engine.simpleClient(BROWSER)!;
const open = (typed: string, post?: string): Act => (engine) => browser(engine).open(typed, post);
/** Each story's calls at their times, as WebRun makes them. */
const STORIES: Record<string, [number, Act][]> = {
page: [[500, open('192.168.0.30')]],
byName: [
[500, open('www.filius.de/seite.html')],
[6000, open('http:///text.txt')],
],
notFound: [[500, open('http://www.filius.de/nichts.html')]],
vhost: [
[500, open('seite.filius.de')],
[5000, open('andere.filius.de')],
[9500, open('www.filius.de/vhost1/')],
],
noServer: [[500, open('leer.filius.de')]],
unresolved: [[500, open('unbekannt.filius.de')]],
post: [[500, open('www.filius.de/seite.html', 'name=Filius&x=1')]],
encoded: [
[500, open('www.filius.de/mein bild.html')],
[5000, open('www.filius.de/grüße.html')],
],
traversal: [[500, open('www.filius.de/../geheim.txt')]],
caseInsensitive: [[500, open('WWW.Filius.de/SEITE.HTML')]],
raw: [
[500, (engine) => client(engine).connect('192.168.0.30', 80)],
[2500, (engine) => client(engine).send('GET /text.txt HTTP/1.1\nHost: 192.168.0.30\n')],
[4500, (engine) => client(engine).send('HALLO')],
[7000, (engine) => client(engine).connect('192.168.0.30', 80)],
[9000, (engine) => client(engine).send('GET /')],
],
};
const IMAGE_TYPES = new Set(['png', 'gif', 'jpg', 'bmp']);
/** The server's files as Java had them: text as it is, images decoded. */
function fileSystemOf(files: [string, string, string][]): VirtualFileSystem {
const fileSystem = new VirtualFileSystem();
for (const [path, type, content] of files) {
const parent = parentPath(path);
if (parent !== '/' && !fileSystem.contains(parent)) fileSystem.createDirectory(parent, true);
if (IMAGE_TYPES.has(type)) {
fileSystem.writeImageFile(path, javaBase64Decode(content)!, `image/${type === 'jpg' ? 'jpeg' : type}`);
} else {
fileSystem.writeTextFile(path, content);
}
}
return fileSystem;
}
/** A browser's line as its window took it: a page (with its text only for 200), or "update" for an image. */
function browserText(entry: WebBrowserLogEntry): string | undefined {
switch (entry.kind) {
case 'page':
return `page ${entry.statusCode} ${entry.contentType ?? 'null'}${entry.statusCode === 200 ? `\n${entry.body ?? 'null'}` : ''}`;
case 'unreachable':
return 'page 0 null';
case 'image': {
// WebBrowser.retrieveImages tells the window of an image that came, or of a status other than 200.
const isImage = entry.contentType !== undefined && IMAGE_TYPES.has(entry.contentType.toLowerCase().replace('image/', ''));
return entry.statusCode !== 200 || isImage ? 'update' : undefined;
}
}
}
type Lines = Record<Program, [number, string][]>;
function javaLines(java: JavaRun): Lines {
const lines: Lines = { server: [], browser: [], client: [] };
const firstCall = Math.min(...java.events.filter(([, , text]) => text.startsWith('call ')).map(([time]) => time));
for (const [time, program, text] of java.events) {
// Before the first call: Anwendung.starten's empty notice (null, or the browser's "update").
if (time < firstCall || text === 'null' || text.startsWith('call ') || text === 'no url') continue;
// A page other than 200 carries Java's error page; the editor makes its own.
const page = /^page (\d+) (\S+)\n/.exec(text);
lines[program].push([time, withClientPortText(page !== null && page[1] !== '200' ? `page ${page[1]} ${page[2]}` : text)]);
}
return lines;
}
function engineLines(engine: NetworkRuntimeEngine): Lines {
const lines = <E>(log: readonly LogLine<E>[] | undefined, text: (entry: E) => string | undefined): [number, string][] =>
(log ?? []).flatMap((line) => {
const each = text(line);
return each === undefined ? [] : [[line.timeMilliseconds, withClientPortText(each)] as [number, string]];
});
return {
server: lines(engine.webServer(SERVER)!.log.all, serverText),
browser: lines(browser(engine).log.all, browserText),
client: lines(engine.simpleClient(BROWSER)?.log.all, clientText),
};
}
/** When the story first made the call. */
/** A frame's text with placeholders: free ports, and DNS message IDs (drawn at random in Java, from the seed in the engine). */
const frameText = (text: string) => withClientPortText(text).replace(/ID=\d+/g, 'ID=x');
const callTime = (java: JavaRun, call: string) => java.events.find(([, , text]) => text === `call ${call}`)![0];
/**
* Where Java errs, per story: its run corrected, as Java means it.
*
* traversal: the server resolves "/../geheim.txt" below its document root to
* /geheim.txt and serves it; any file of the host is open to every browser.
* The engine's server answers 404.
*
* raw: "HALLO" (no request) makes HTTPNachricht throw; the server's thread
* closes the connection and writes "null" (which its window skips). The
* engine's server answers 501 Not Implemented — what WebServerMitarbeiter
* means for a method it does not know — and stays connected.
*
* noServer: Java's browser sends one SYN, not three (tcpJava.test.ts,
* closedPort); its page "server not reachable" comes two attempts later in
* the engine.
*/
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
noServer: (java) => ({
...java,
events: java.events.map(([time, who, text]) => (who === 'browser' && text.startsWith('page 0') ? [time + 2 * JAVA_TIMEOUT_MILLISECONDS, who, text] : [time, who, text])),
}),
traversal: (java) => ({
...java,
events: java.events.map(([time, who, text]) =>
who === 'browser' && text.startsWith('page 200')
? [time, who, 'page 404 null\n']
: who === 'server' && text.startsWith('<<HTTP/1.1 200')
? [time, who, '<<HTTP/1.1 404 Not Found\n']
: [time, who, text],
),
}),
raw: (java) => {
const unreadable = java.events.findIndex(([, who, text]) => who === 'server' && text === '>>HALLO');
const [time] = java.events[unreadable]!;
const answer = 'HTTP/1.1 501 Not Implemented\n';
return {
...java,
events: [
...java.events.slice(0, unreadable + 1),
[time + 111, 'client', '<<HALLO'],
[time + 167, 'client', `>>${answer}`],
[time + 279, 'server', `<<${answer}`],
],
};
},
};
/**
* Where a story is compared only up to the request whose answer Java gets
* wrong (each card's frames up to it, the lines up to its arrival): after
* it, the engine goes on beyond Java's error. traversal: the answer differs
* (404, not the file), and with it every later sequence number. raw: after
* "HALLO" Java's connection is gone, the engine's lives on.
*/
const UNTIL_REQUEST: Record<string, string> = { traversal: 'GET /../geheim.txt', raw: 'HALLO' };
/** Java's tables of each card up to the frame that carries the request (with it). */
function tablesUpTo(java: JavaRun, text: string | undefined): JavaTables {
if (text === undefined) return java;
const upToRequest = (rows: Record<string, (string | null)[][]> | undefined) =>
Object.fromEntries(
Object.entries(rows ?? {}).map(([card, cardRows]) => {
const index = java.frames[card]!.findIndex((frame) => frame.payload?.startsWith(text) === true);
return [card, index < 0 ? cardRows : cardRows.filter((row) => Number(row[0]) <= index + 1)];
}),
);
return { rows: upToRequest(java.rows), layeredRows: upToRequest(java.layeredRows) };
}
/**
* Where a story is compared only up to a time (Java's). noServer: the
* engine's second SYN, which Java does not send (the switch floods it to
* every card).
*/
const UNTIL: Record<string, (java: JavaRun) => number> = {
noServer: (java) => java.events.find(([, who, text]) => who === 'browser' && text.startsWith('page 0'))![0] - 250,
};
/** Where the lines are compared only up to a time (Java's). raw: the story's second connection, which the engine's client does not need. */
const LINES_UNTIL: Record<string, (java: JavaRun) => number> = {
raw: (java) => java.events.filter(([, , text]) => text === 'call verbinden')[1]![0],
};
/** Each card's frames up to the one that carries the request in Java's list (with it); all where it does not show. */
function upTo(java: JavaRun, text: string | undefined) {
return (frames: SeenFrame[], card: string) => {
const index = text === undefined ? -1 : java.frames[card]!.findIndex((frame) => frame.payload?.startsWith(text) === true);
return index < 0 ? frames : frames.slice(0, index + 1);
};
}
describe('the web server and the web browser as Java FILIUS runs them', () => {
it('corrects Java only where it errs, and cuts only after the story began', () => {
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `web-java-${name}.java.json`), 'utf8')) as JavaRun;
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
const firstCall = java.events.find(([, , text]) => text.startsWith('call '))![0];
for (const until of [UNTIL[name], LINES_UNTIL[name]]) if (until !== undefined) expect(until(java), name).toBeGreaterThan(firstCall + 500);
}
});
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'web-lan.scenario.json'), 'utf8')) as Scenario;
for (const [name, acts] of Object.entries(STORIES)) {
it(name, () => {
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `web-java-${name}.java.json`), 'utf8')) as JavaRun;
const java = JAVA_BUGS[name]?.(measured) ?? measured;
const snapshot = withSnapshot(snapshotOf(scenario), {
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: true }]]),
webBrowserNodeIds: new Set([BROWSER]),
fileSystemsByNodeId: new Map([[SERVER, fileSystemOf(measured.files)]]),
simpleClientsByNodeId: name === 'raw' ? new Map([[BROWSER, { targetAddress: '', targetPort: 80 }]]) : new Map(),
});
const { engine } = watchedEngine(scenario, snapshot);
for (const [time, act] of acts) {
engine.handle({ type: 'advance', toMilliseconds: time });
act(engine);
}
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
const until = UNTIL[name]?.(measured);
expectSameCaptures(engine, scenario, java.frames, {
text: frameText,
normalized: upTo(measured, UNTIL_REQUEST[name]),
until,
tables: tablesUpTo(measured, UNTIL_REQUEST[name]),
tolerance: TOLERANCE_MILLISECONDS,
});
const linesUntil = LINES_UNTIL[name]?.(measured) ?? until;
const before = (lines: [number, string][]) => lines.filter(([time]) => linesUntil === undefined || time < linesUntil);
const expected = javaLines(java);
const actual = engineLines(engine);
for (const program of ['server', 'browser', 'client'] as const) {
const expectedLines = before(expected[program]);
const actualLines = before(actual[program]);
expect(actualLines.map(([, text]) => text), `${program}'s lines`).toEqual(expectedLines.map(([, text]) => text));
for (const [index, [javaTime, text]] of expectedLines.entries()) {
const late = javaTime - actualLines[index]![0];
const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + TOLERANCE_MILLISECONDS;
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}: Java ${late} ms later`).toBeGreaterThanOrEqual(-TOLERANCE_MILLISECONDS);
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(most);
}
}
});
}
});