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
+17 -123
View File
@@ -1,40 +1,20 @@
// FILIUS' echo server and generic client (filius.software.clientserver:
// ServerAnwendung, ServerMitarbeiter, ServerBaustein, ServerBausteinMitarbeiter;
// ClientAnwendung, ClientBaustein). The server takes connections on its port
// while it is switched on and sends each message back; the client connects
// to a server, sends what it is given and shows what comes back.
// ServerBaustein, ServerBausteinMitarbeiter; ClientAnwendung, ClientBaustein).
// The server (server.ts) sends each message back; the client connects to a
// server, sends what it is given and shows what comes back.
//
// Better than Java: the server notices at once that it was switched off, and
// the client at once that it is connected (Java looks every 100 ms); a
// connection to an address without a route fails (Java's client waits for
// ever, without a word).
// Better than Java: the client notices at once that it is connected (Java
// looks every 100 ms); a connection to an address without a route fails
// (Java's client waits for ever, without a word).
import type { Id } from '../../ids.js';
import type { NetworkRuntimeEngine, TcpSendResult } from '../engine.js';
import { pause, waitUntil, type Steps } from '../threads.js';
import type { NetworkRuntimeEngine } from '../engine.js';
import type { Steps } from '../threads.js';
import { CommandThread, ProgramLog } from './program.js';
import { TcpServerProgram, type SendFailure, type ServerLogEntry } from './server.js';
/** Why a send failed (TCPSocket.senden's exceptions). */
export type SendFailure = Exclude<TcpSendResult, 'sent'>;
export type EchoServerLogEntry =
/** sw_serveranwendung_msg2: switched on. */
| { readonly kind: 'acceptingStarted' }
/** sw_serveranwendung_msg1: switched off. */
| { readonly kind: 'acceptingStopped' }
/** sw_serveranwendung_msg3: the listening socket closed after the server was switched off. */
| { readonly kind: 'serverSocketClosed' }
/** sw_serveranwendung_msg6: the port is taken; the server switches itself off. */
| { readonly kind: 'serverSocketFailed' }
/** sw_serveranwendung_msg4, msg5: connected to the client at its address and port. */
| { readonly kind: 'connectionAccepted'; readonly address: string; readonly port: number }
/** ">>" and the message. */
| { readonly kind: 'received'; readonly text: string }
/** "<<" and the message, once it was acknowledged. */
| { readonly kind: 'sent'; readonly text: string }
| { readonly kind: 'sendFailed'; readonly failure: SendFailure }
/** sw_servermitarbeiter_msg1, msg2: the connection to the client at its address and port closed. */
| { readonly kind: 'connectionClosed'; readonly address: string; readonly port: number };
/** The echo server's lines are those of every server (ServerAnwendung, ServerMitarbeiter). */
export type EchoServerLogEntry = ServerLogEntry;
/** Why a connection could not be made (shown after sw_clientbaustein_msg1). */
export type ConnectFailure =
@@ -65,96 +45,10 @@ export type SimpleClientLogEntry =
const encode = (text: string) => new TextEncoder().encode(text);
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
export class EchoServer {
readonly log: ProgramLog<EchoServerLogEntry>;
private active: boolean;
private portValue: number;
private listenerId: Id | undefined;
private readonly connections = new Set<Id>();
constructor(
private readonly engine: NetworkRuntimeEngine,
private readonly nodeId: Id,
configuration: { readonly port: number; readonly isActive: boolean },
) {
this.log = new ProgramLog(engine);
this.portValue = configuration.port;
this.active = configuration.isActive;
engine.startThread(this.acceptConnections());
}
get isActive(): boolean {
return this.active;
}
get port(): number {
return this.portValue;
}
/** Takes effect when the server next opens its socket, as in Java. */
setPort(port: number): void {
this.portValue = port;
}
/** ServerAnwendung.setAktiv: switched off, it closes its socket and every connection. */
setActive(active: boolean): void {
this.active = active;
if (!active) {
if (this.listenerId !== undefined) this.engine.closeTcpSocket(this.listenerId);
for (const socketId of this.connections) this.engine.closeTcpSocket(socketId);
this.connections.clear();
this.log.write({ kind: 'acceptingStopped' });
} else {
this.log.write({ kind: 'acceptingStarted' });
}
this.engine.resumeThreads();
}
/** ServerAnwendung.annehmenVerbindungen. */
private *acceptConnections(): Steps<never> {
for (;;) {
if (this.active) {
if (this.listenerId === undefined) {
this.listenerId = this.engine.openTcpServerSocket(this.nodeId, this.portValue);
if (this.listenerId === undefined) {
this.log.write({ kind: 'serverSocketFailed' });
this.setActive(false);
continue;
}
}
const accepted = yield* this.engine.acceptTcpSteps(this.listenerId);
const socket = accepted === undefined ? undefined : this.engine.state.socketsById.get(accepted);
if (accepted !== undefined && socket?.remoteIPAddress !== undefined && socket.remotePort !== undefined) {
this.connections.add(accepted);
this.engine.startThread(this.serve(accepted, socket.remoteIPAddress, socket.remotePort));
this.log.write({ kind: 'connectionAccepted', address: socket.remoteIPAddress, port: socket.remotePort });
}
} else {
if (this.listenerId !== undefined) {
// After the connections' threads have seen theirs close (Java's loop looks later than they do).
yield pause(this.engine.state.currentTimeMilliseconds);
this.engine.closeTcpSocket(this.listenerId);
this.log.write({ kind: 'serverSocketClosed' });
}
this.listenerId = undefined;
yield* waitUntil(() => this.active);
}
}
}
/** ServerMitarbeiter.run with ServerBausteinMitarbeiter: each message back to where it came from. */
private *serve(socketId: Id, address: string, port: number): Steps {
for (;;) {
const received = yield* this.engine.receiveTcpSteps(socketId);
if (received.type !== 'message') break;
const text = decode(received.payload);
this.log.write({ kind: 'received', text });
const result = yield* this.engine.sendTcpSteps(socketId, encode(text));
this.log.write(result === 'sent' ? { kind: 'sent', text } : { kind: 'sendFailed', failure: result });
}
this.engine.closeTcpSocket(socketId);
this.connections.delete(socketId);
this.log.write({ kind: 'connectionClosed', address, port });
/** ServerBaustein with ServerBausteinMitarbeiter: each message back to where it came from. */
export class EchoServer extends TcpServerProgram {
protected answer(message: string): string {
return message;
}
}
@@ -204,7 +98,7 @@ export class SimpleClient {
/** ClientBaustein.trennen. */
disconnect(): void {
if (this.socketId !== undefined) {
this.engine.closeTcpSocket(this.socketId);
this.engine.requestTcpClose(this.socketId);
this.log.write({ kind: 'disconnected' });
}
this.engine.resumeThreads();
@@ -247,7 +141,7 @@ export class SimpleClient {
if (received.type === 'message') {
this.log.write({ kind: 'received', text: decode(received.payload) });
} else if (received.type === 'closed') {
this.engine.closeTcpSocket(socketId);
this.engine.requestTcpClose(socketId);
const socket = this.engine.state.socketsById.get(socketId);
this.log.write({ kind: 'connectionClosed', address: socket?.remoteIPAddress ?? '', port: socket?.remotePort ?? 0 });
} else {
+138
View File
@@ -0,0 +1,138 @@
// What FILIUS' servers share (filius.software.clientserver.ServerAnwendung,
// ServerMitarbeiter): while switched on, a thread takes connections on the
// port; each connection gets a thread that reads its messages and sends the
// program's answer to each. Switched off, the server closes its socket and
// every connection.
//
// Better than Java: the server notices at once that it was switched off
// (Java looks every 100 ms); switched off, it still answers its
// connections' last FIN (Java frees the port first, and the FIN goes
// unanswered).
import type { Id } from '../../ids.js';
import type { NetworkRuntimeEngine, TcpSendResult } from '../engine.js';
import { pause, waitUntil, type Steps } from '../threads.js';
import { ProgramLog } from './program.js';
/** Why a send failed (TCPSocket.senden's exceptions). */
export type SendFailure = Exclude<TcpSendResult, 'sent'>;
/** A line of a server's window. */
export type ServerLogEntry =
/** sw_serveranwendung_msg2: switched on. */
| { readonly kind: 'acceptingStarted' }
/** sw_serveranwendung_msg1: switched off. */
| { readonly kind: 'acceptingStopped' }
/** sw_serveranwendung_msg3: the listening socket closed after the server was switched off. */
| { readonly kind: 'serverSocketClosed' }
/** sw_serveranwendung_msg6: the port is taken; the server switches itself off. */
| { readonly kind: 'serverSocketFailed' }
/** sw_serveranwendung_msg4, msg5: connected to the client at its address and port. */
| { readonly kind: 'connectionAccepted'; readonly address: string; readonly port: number }
/** ">>" and the message. */
| { readonly kind: 'received'; readonly text: string }
/** "<<" and the message, once it was acknowledged. */
| { readonly kind: 'sent'; readonly text: string }
| { readonly kind: 'sendFailed'; readonly failure: SendFailure }
/** sw_servermitarbeiter_msg1, msg2: the connection to the client at its address and port closed. */
| { readonly kind: 'connectionClosed'; readonly address: string; readonly port: number };
const encode = (text: string) => new TextEncoder().encode(text);
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
export abstract class TcpServerProgram {
readonly log: ProgramLog<ServerLogEntry>;
private active: boolean;
private portValue: number;
private listenerId: Id | undefined;
private readonly connections = new Set<Id>();
constructor(
protected readonly engine: NetworkRuntimeEngine,
protected readonly nodeId: Id,
configuration: { readonly port: number; readonly isActive: boolean },
) {
this.log = new ProgramLog(engine);
this.portValue = configuration.port;
this.active = configuration.isActive;
engine.startThread(this.acceptConnections());
}
get isActive(): boolean {
return this.active;
}
get port(): number {
return this.portValue;
}
/** Takes effect when the server next opens its socket, as in Java. */
setPort(port: number): void {
this.portValue = port;
}
/** ServerAnwendung.setAktiv: switched off, it closes its socket and every connection. */
setActive(active: boolean): void {
this.active = active;
if (!active) {
if (this.listenerId !== undefined) this.engine.requestTcpClose(this.listenerId);
for (const socketId of this.connections) this.engine.requestTcpClose(socketId);
this.connections.clear();
this.log.write({ kind: 'acceptingStopped' });
} else {
this.log.write({ kind: 'acceptingStarted' });
}
this.engine.resumeThreads();
}
/** ServerAnwendung.annehmenVerbindungen. */
private *acceptConnections(): Steps<never> {
for (;;) {
if (this.active) {
if (this.listenerId === undefined) {
this.listenerId = this.engine.openTcpServerSocket(this.nodeId, this.portValue);
if (this.listenerId === undefined) {
this.log.write({ kind: 'serverSocketFailed' });
this.setActive(false);
continue;
}
}
const accepted = yield* this.engine.acceptTcpSteps(this.listenerId);
const socket = accepted === undefined ? undefined : this.engine.state.socketsById.get(accepted);
if (accepted !== undefined && socket?.remoteIPAddress !== undefined && socket.remotePort !== undefined) {
this.connections.add(accepted);
this.engine.startThread(this.serve(accepted, socket.remoteIPAddress, socket.remotePort));
this.log.write({ kind: 'connectionAccepted', address: socket.remoteIPAddress, port: socket.remotePort });
}
} else {
if (this.listenerId !== undefined) {
// After the connections' threads have seen theirs close (Java's loop looks later than they do).
yield pause(this.engine.state.currentTimeMilliseconds);
this.engine.requestTcpClose(this.listenerId);
this.log.write({ kind: 'serverSocketClosed' });
}
this.listenerId = undefined;
yield* waitUntil(() => this.active);
}
}
}
/** What the program answers to a message (the worker's verarbeiteNachricht). */
protected abstract answer(message: string): string;
/** ServerMitarbeiter.run: each message answered (verarbeiteNachricht, sendeNachricht), until the connection ends. */
private *serve(socketId: Id, address: string, port: number): Steps {
for (;;) {
const received = yield* this.engine.receiveTcpSteps(socketId);
if (received.type !== 'message') break;
const text = decode(received.payload);
this.log.write({ kind: 'received', text });
const answer = this.answer(text);
const result = yield* this.engine.sendTcpSteps(socketId, encode(answer));
this.log.write(result === 'sent' ? { kind: 'sent', text: answer } : { kind: 'sendFailed', failure: result });
}
this.engine.requestTcpClose(socketId);
this.connections.delete(socketId);
this.log.write({ kind: 'connectionClosed', address, port });
}
}
+343
View File
@@ -0,0 +1,343 @@
// FILIUS' web server and web browser (filius.software.www: WebServer,
// WebServerMitarbeiter, WebBrowser; the address line of
// GUIApplicationWebBrowserWindow). The server answers GET and POST with the
// files below its document root (/webserver), by host name from the virtual
// hosts of /www.conf/vhosts; the browser loads a page over port 80, then its
// images one after the other over the same connection, and closes.
//
// Better than Java: the server serves nothing outside its document root
// (Java resolves "/../" and serves any file of the host); a request it
// cannot read gets 400 or 501, one without a version is HTTP/1.1 (Java's
// thread dies of it and closes the connection); the browser shows "server
// not reachable" when its request cannot go out or the answer cannot be
// read (Java shows nothing).
//
// Not yet: the pages a router's or home router's server makes itself
// (Java's plug-ins: firewall log and settings, routing table).
import type { Id } from '../../ids.js';
import type { VirtualFileSystem } from '../../model/virtualFileSystem.js';
import { javaBase64, javaBase64Decode } from '../../util/javaBase64.js';
import type { WebServerSnapshot } from '../snapshot.js';
import type { NetworkRuntimeEngine } from '../engine.js';
import {
formatHttpRequest,
formatHttpResponse,
HTTP_GET,
HTTP_POST,
httpPath,
parseHttpRequest,
parseHttpResponse,
type HttpResponse,
} from '../http.js';
import type { Steps } from '../threads.js';
import { CommandThread, ProgramLog } from './program.js';
import { TcpServerProgram } from './server.js';
/** The port FILIUS' browser always connects to (WebBrowser.initConnection). */
export const WEB_BROWSER_PORT = 80;
/** WebServer's table of virtual hosts: five rows of host name and folder. */
const VIRTUAL_HOST_ROWS = 5;
/** Dateisystem.evaluatePathString: "." and empty parts dropped, ".." steps back (above the root: nothing left). */
function evaluatePath(path: string): string {
const parts: string[] = [];
let depth = -1;
for (const part of path.split('/')) {
if (part === '') continue;
if (part === '..') {
depth -= 1;
} else if (part !== '.') {
depth += 1;
parts[depth] = part;
}
}
if (depth < 0) return '';
const joined = parts.slice(0, depth + 1).join('/');
return path.startsWith('/') ? `/${joined}` : joined;
}
/** WebServerMitarbeiter: the content type by the file's name. */
function contentTypeOf(name: string): string {
const lower = name.toLowerCase();
if (lower.endsWith('html') || lower.endsWith('htm')) return 'text/html';
if (lower.endsWith('png')) return 'image/png';
if (lower.endsWith('bmp')) return 'image/bmp';
if (lower.endsWith('gif')) return 'image/gif';
if (lower.endsWith('jpg')) return 'image/jpg';
return 'text/plain';
}
const IMAGE_TYPES = new Set(['image/bmp', 'image/gif', 'image/jpg', 'image/png']);
export class WebServer extends TcpServerProgram {
private readonly documentRoot: string;
private readonly virtualHosts: readonly (readonly [string, string])[];
private readonly useVirtualHosts: boolean;
constructor(
engine: NetworkRuntimeEngine,
nodeId: Id,
configuration: WebServerSnapshot,
private readonly fileSystem: VirtualFileSystem | undefined,
) {
super(engine, nodeId, configuration);
this.documentRoot = configuration.documentRoot;
// WebServer.initialisiereVHosts: the file's lines, trimmed and without
// empty ones, in pairs; a file there switches the table on.
const vhosts = this.readText('/www.conf/vhosts');
const lines = (vhosts ?? '').split('\n').filter((line) => line !== '').map((line) => line.trim());
const rows: [string, string][] = [];
for (let index = 0; index < lines.length && rows.length < VIRTUAL_HOST_ROWS; index += 2) {
rows.push([lines[index]!, lines[index + 1] ?? '']);
}
this.virtualHosts = rows;
this.useVirtualHosts = configuration.virtualHosts || vhosts !== undefined;
}
/** WebServer.vhostPrefix: the folder of the host name, ignoring case; "" when none. */
private virtualHostFolder(host: string | undefined): string {
if (!this.useVirtualHosts || host === undefined) return '';
return this.virtualHosts.find(([name]) => name.toLowerCase() === host.toLowerCase())?.[1] ?? '';
}
private readText(path: string): string | undefined {
try {
const entry = this.fileSystem?.entry(path);
return entry?.content.kind === 'text' ? entry.content.text : undefined;
} catch {
return undefined;
}
}
/** WebServerMitarbeiter.verarbeiteNachricht. */
protected answer(message: string): string {
return formatHttpResponse(this.respond(message));
}
private respond(message: string): HttpResponse {
const response = (statusCode: number, contentType?: string, body?: string): HttpResponse => ({
protocolVersion: 'HTTP/1.1',
statusCode,
contentType,
body,
});
const parsed = parseHttpRequest(message);
if (parsed.type === 'invalid') return response(parsed.statusCode);
const path = httpPath(parsed.request.path);
let file = path === '/' ? 'index.html' : path.startsWith('/') ? path.slice(1) : path;
let folder = this.virtualHostFolder(parsed.request.host);
if (!folder.endsWith('/')) folder += '/';
file = folder + file;
if (file.startsWith('/')) file = file.slice(1);
const absolute = evaluatePath(`${this.documentRoot}/${file}`);
if (!absolute.toLowerCase().startsWith(`${this.documentRoot.toLowerCase()}/`)) return response(404);
let content: string;
let name: string;
try {
const entry = this.fileSystem?.entry(absolute);
if (entry === undefined || entry.content.kind === 'directory') return response(404);
content = entry.content.kind === 'text' ? entry.content.text : javaBase64(entry.content.data);
name = entry.path.slice(entry.path.lastIndexOf('/') + 1);
} catch {
return response(404);
}
return response(200, contentTypeOf(name), content);
}
}
/**
* GUIApplicationWebBrowserWindow.erzeugeURL: the host and path of what was
* typed into the address line ("host/path" or "http://host/path"; a host
* Java's patterns accept, also empty: the browser's last); undefined when
* it is no address.
*/
export function webBrowserAddress(typed: string): { readonly host: string; readonly path: string } | undefined {
const parts = javaSplit(typed, '/');
let host: string | undefined;
let path = '';
if (parts.length > 0 && parts[0]!.toLowerCase() !== 'http:' && isHostName(parts[0]!)) {
host = parts[0]!;
for (const part of parts.slice(1)) path += `/${part}`;
}
if (parts.length > 2 && parts[0]!.toLowerCase() === 'http:') {
if (isHostName(parts[2]!)) host = parts[2]!;
for (const part of parts.slice(3)) path += `/${part}`;
}
if (host === undefined) return undefined;
return { host, path: path === '' ? '/' : path };
}
/** Java's String.split: trailing empty parts dropped. */
function javaSplit(text: string, separator: string): string[] {
if (text === '') return [''];
const parts = text.split(separator);
while (parts.length > 0 && parts[parts.length - 1] === '') parts.pop();
return parts;
}
/** EingabenUeberpruefung.musterDomain and musterIpAdresse. */
const DOMAIN_PATTERN = /^([a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9]){0,1}(\.[a-zA-Z]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9]){0,1})*){0,1}\.{0,1}$/;
const IP_ADDRESS_PATTERN =
/^(0*([1-9]?[0-9]?|1[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.((0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))\.){2}(0*(1?[0-9]{1,2}|2[0-4]?[0-9]|25[0-5]))$/;
function isHostName(text: string): boolean {
return DOMAIN_PATTERN.test(text) || IP_ADDRESS_PATTERN.test(text);
}
/**
* The src of each img tag, in order, as Java's HTML parser gives it
* (TagFindingVisitor, ImageTag.getImageURL): the tag and attribute names in
* any case, the value in double, single or no quotes.
*/
export function imageSources(html: string): string[] {
const sources: string[] = [];
const tag = /<img(?=[\s/>])([^>]*)>/gi;
for (const match of html.matchAll(tag)) {
const attribute = /([^\s=/]+)\s*(?:=\s*("[^"]*"|'[^']*'|[^\s>]*))?/g;
for (const [, name, value] of match[1]!.matchAll(attribute)) {
if (name!.toLowerCase() !== 'src' || value === undefined) continue;
sources.push(/^["']/.test(value) ? value.slice(1, -1) : value);
break;
}
}
return sources;
}
export type WebBrowserLogEntry =
/** A page came: shown as it is with status 200, else as the browser's error page for the status. */
| { readonly kind: 'page'; readonly statusCode: number; readonly contentType: string | undefined; readonly body: string | undefined }
/** sw_webbrowser_msg1: the server could not be reached (no address, no connection, no answer). */
| { readonly kind: 'unreachable' }
/** An image of the page came (or not: its status); Java tells its window, which loads it anew. */
| { readonly kind: 'image'; readonly source: string; readonly statusCode: number; readonly contentType: string | undefined };
/** The page the browser shows: as it came, with the images that came for it. */
export interface WebBrowserPage {
readonly statusCode: number;
readonly contentType: string | undefined;
readonly body: string | undefined;
readonly images: ReadonlyMap<string, Uint8Array>;
}
export class WebBrowser {
readonly log: ProgramLog<WebBrowserLogEntry>;
private hostValue: string | undefined;
private socketId: Id | undefined;
private images: string[] = [];
private shown: { statusCode: number; contentType: string | undefined; body: string | undefined; images: Map<string, Uint8Array> } | undefined;
private readonly commands: CommandThread;
constructor(
private readonly engine: NetworkRuntimeEngine,
private readonly nodeId: Id,
) {
this.log = new ProgramLog(engine);
this.commands = new CommandThread(engine);
}
/** The host of the last address with one (links without a host go there). */
get host(): string | undefined {
return this.hostValue;
}
get page(): WebBrowserPage | undefined {
return this.shown;
}
/** What was typed into the address line, as the window loads it; false when it is no address (nothing happens). */
open(typed: string, post?: string): boolean {
const address = webBrowserAddress(typed);
if (address === undefined) return false;
this.load(address.host, address.path, post);
return true;
}
/**
* WebBrowser.holeWebseite: the page at the path (with its query) on the
* host, or on the last host when it is "". Java first hands over a close
* when a connection is open; it never is then here: every load ends with
* its close, which runs before the next load's commands.
*/
load(host: string, path: string, post?: string): void {
this.images = [];
this.commands.hand(() => this.initConnection(host));
this.commands.hand(() => this.retrieveWebpage(path, post));
this.commands.hand(() => this.retrieveImages());
this.commands.hand(() => this.closeConnection());
this.engine.resumeThreads();
}
private get isConnected(): boolean {
return this.socketId !== undefined && this.engine.state.socketsById.get(this.socketId)?.tcpState === 'ESTABLISHED';
}
/** WebBrowser.initConnection: to port 80 of the host. */
private *initConnection(host: string): Steps {
if (host !== '') this.hostValue = host;
if (this.socketId === undefined) {
const ipAddress = this.hostValue === undefined ? undefined : yield* this.engine.resolveHostSteps(this.nodeId, this.hostValue);
const socketId = ipAddress === undefined ? undefined : this.engine.openTcpClientSocket(this.nodeId, ipAddress, WEB_BROWSER_PORT);
if (socketId === undefined) {
this.log.write({ kind: 'unreachable' });
return;
}
this.socketId = socketId;
}
if (!this.isConnected && (yield* this.engine.connectTcpSteps(this.socketId)) !== 'connected') {
this.log.write({ kind: 'unreachable' });
}
}
/** One request and its answer over the connection; undefined when it could not go out or no answer came. */
private *exchange(path: string, post: string | undefined): Steps<HttpResponse | undefined> {
const request = formatHttpRequest({
method: post !== undefined && post !== '' ? HTTP_POST : HTTP_GET,
path,
host: this.hostValue,
data: post,
});
if ((yield* this.engine.sendTcpSteps(this.socketId!, new TextEncoder().encode(request))) !== 'sent') return undefined;
const received = yield* this.engine.receiveTcpSteps(this.socketId!);
return received.type === 'message' ? parseHttpResponse(new TextDecoder().decode(received.payload)) : undefined;
}
/** WebBrowser.retrieveWebpage. */
private *retrieveWebpage(path: string, post: string | undefined): Steps {
if (!this.isConnected) return;
const response = yield* this.exchange(path, post);
if (response === undefined) {
this.log.write({ kind: 'unreachable' });
return;
}
if (response.statusCode === 200 && response.contentType?.toLowerCase() === 'text/html' && response.body !== undefined) {
this.images = imageSources(response.body);
}
this.shown = { statusCode: response.statusCode, contentType: response.contentType, body: response.body, images: new Map() };
this.log.write({ kind: 'page', statusCode: response.statusCode, contentType: response.contentType, body: response.body });
}
/** WebBrowser.retrieveImages: each over the same connection, as long as it lasts. */
private *retrieveImages(): Steps {
for (const source of this.images) {
if (!this.isConnected) return;
const response = yield* this.exchange(source, undefined);
if (response === undefined) continue;
const isImage = response.contentType !== undefined && IMAGE_TYPES.has(response.contentType.toLowerCase());
if (response.statusCode === 200 && isImage) {
const data = javaBase64Decode(response.body ?? '');
if (data !== undefined) this.shown?.images.set(source, data);
}
this.log.write({ kind: 'image', source, statusCode: response.statusCode, contentType: response.contentType });
}
}
/** WebBrowser.closeConnection. */
private *closeConnection(): Steps {
if (this.socketId !== undefined) {
this.engine.requestTcpClose(this.socketId);
this.socketId = undefined;
}
}
}
+37
View File
@@ -113,6 +113,7 @@ import { EMPTY_TOPOLOGY_SNAPSHOT, withSnapshot, type NodeSnapshot, type Topology
import { RuntimeSpeed } from './speed.js';
import { ApplicationThreads, waitUntil, type Steps, type Wait } from './threads.js';
import { EchoServer, SimpleClient } from './apps/echo.js';
import { WebBrowser, WebServer } from './apps/web.js';
import {
PRODUCTION_TRACE_RETENTION,
type HeaderField,
@@ -154,6 +155,8 @@ export type ScheduledEventKind =
/** The lookups waiting for an address wake up, after what else happens at that time. */
| { readonly kind: 'arpAnswered'; readonly nodeId: Id; readonly ipAddress: string; readonly macAddress: string }
| { readonly kind: 'tcpTimeout'; readonly sessionId: Id }
/** A program asked its socket to close (requestTcpClose): the socket's thread does it. */
| { readonly kind: 'tcpClose'; readonly socketId: Id }
/** A program's thread waited until this time (threads.ts). */
| { readonly kind: 'applicationWake'; readonly wakeId: number }
/** A DNS query waited five round trips for its answer (DNSQueryAgent). */
@@ -883,6 +886,8 @@ export class NetworkRuntimeEngine {
/** The programs, by node (initializePrograms). */
private readonly echoServersByNodeId = new Map<Id, EchoServer>();
private readonly simpleClientsByNodeId = new Map<Id, SimpleClient>();
private readonly webServersByNodeId = new Map<Id, WebServer>();
private readonly webBrowsersByNodeId = new Map<Id, WebBrowser>();
/** Accepted connections a program has taken (acceptTcpSteps). */
private readonly handedOutTcpSocketIds = new Set<Id>();
private nextApplicationWakeId = 1;
@@ -997,6 +1002,8 @@ export class NetworkRuntimeEngine {
this.handedOutTcpSocketIds.clear();
this.echoServersByNodeId.clear();
this.simpleClientsByNodeId.clear();
this.webServersByNodeId.clear();
this.webBrowsersByNodeId.clear();
this.arpLookups = [];
this.protocolThreads.clear();
this.dnsQueriesBySocketId.clear();
@@ -1051,6 +1058,16 @@ export class NetworkRuntimeEngine {
return this.simpleClientsByNodeId.get(nodeId);
}
/** The node's web server while the simulation runs, if it has one installed. */
webServer(nodeId: Id): WebServer | undefined {
return this.webServersByNodeId.get(nodeId);
}
/** The node's web browser while the simulation runs, if it has one installed. */
webBrowser(nodeId: Id): WebBrowser | undefined {
return this.webBrowsersByNodeId.get(nodeId);
}
/**
* Each node's programs start with the simulation (Betriebssystem.starten),
* from the snapshot; what they show starts empty.
@@ -1059,11 +1076,18 @@ export class NetworkRuntimeEngine {
const snapshot = this.stateValue.topologySnapshot;
this.echoServersByNodeId.clear();
this.simpleClientsByNodeId.clear();
this.webServersByNodeId.clear();
this.webBrowsersByNodeId.clear();
for (const node of snapshot.nodes) {
const echoServer = snapshot.echoServersByNodeId.get(node.id);
if (echoServer !== undefined) this.echoServersByNodeId.set(node.id, new EchoServer(this, node.id, echoServer));
const simpleClient = snapshot.simpleClientsByNodeId.get(node.id);
if (simpleClient !== undefined) this.simpleClientsByNodeId.set(node.id, new SimpleClient(this, node.id, simpleClient));
const webServer = snapshot.webServersByNodeId.get(node.id);
if (webServer !== undefined) {
this.webServersByNodeId.set(node.id, new WebServer(this, node.id, webServer, snapshot.fileSystemsByNodeId.get(node.id)));
}
if (snapshot.webBrowserNodeIds.has(node.id)) this.webBrowsersByNodeId.set(node.id, new WebBrowser(this, node.id));
}
}
@@ -1261,6 +1285,9 @@ export class NetworkRuntimeEngine {
case 'tcpTimeout':
this.handleTcpTimeout(kind.sessionId);
return;
case 'tcpClose':
this.closeTcpSocket(kind.socketId);
return;
case 'applicationWake': {
const wake = this.applicationWakes.get(kind.wakeId);
this.applicationWakes.delete(kind.wakeId);
@@ -2089,6 +2116,16 @@ export class NetworkRuntimeEngine {
}
}
/**
* TCPSocket.schliessen from a program: it only asks; the socket's own
* thread leaves its loop and closes (sends the FIN) after what the
* program does next at that time.
*/
requestTcpClose(socketId: Id): void {
if (this.stateValue.phase !== 'running') return;
this.schedule(this.stateValue.currentTimeMilliseconds, { kind: 'tcpClose', socketId });
}
/**
* Java's close: the FIN goes once, never again; the first wait counts from
* handing it down (the timeout transmitTcpSegment schedules).
+213
View File
@@ -0,0 +1,213 @@
// HTTP as FILIUS writes and reads it (filius.software.www.HTTPNachricht):
// lines ended by "\n", a request line, a Host header, for POST an empty line
// and the form's data; a response line with Java's status text, a
// Content-type header, an empty line and the body. Paths go encoded as
// Spring's UriUtils.encodePath encodes them (UTF-8, every character a path
// segment may not hold) and are decoded by the server.
//
// Better than Java: a request Java cannot read (a first line without path,
// or an unknown method) gets 400 or 501 (HTTPNachricht throws, the server's
// thread closes the connection without a word); a request without a version
// is HTTP/1.1, the version HTTPNachricht starts with.
export const HTTP_GET = 'GET';
export const HTTP_POST = 'POST';
export type HttpContentType = 'text/html' | 'image/png' | 'image/bmp' | 'image/gif' | 'image/jpg' | 'text/plain';
export interface HttpRequest {
readonly method: string;
/** Decoded, with its query; `httpPath` gives it without. */
readonly path: string;
readonly protocolVersion: string;
readonly host: string | undefined;
/** POST only: what follows the empty line. */
readonly data: string | undefined;
}
export interface HttpResponse {
readonly protocolVersion: string;
readonly statusCode: number;
readonly contentType: string | undefined;
readonly body: string | undefined;
}
/** A first line FILIUS' server cannot read: no method, or a method without path (400), or another method (501). */
export type HttpRequestParse = { readonly type: 'request'; readonly request: HttpRequest } | { readonly type: 'invalid'; readonly statusCode: 400 | 501 };
/** Java's StringTokenizer on spaces: the words, without empty ones. */
function words(line: string): string[] {
return line.split(' ').filter((word) => word !== '');
}
/** Java's String.trim. */
function javaTrim(text: string): string {
let start = 0;
let end = text.length;
while (start < end && text.charCodeAt(start) <= 0x20) start += 1;
while (end > start && text.charCodeAt(end - 1) <= 0x20) end -= 1;
return text.slice(start, end);
}
/** Java's String.split("\n"): trailing empty lines dropped. */
function javaLines(text: string): string[] {
const lines = text.split('\n');
while (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
return lines;
}
/** HTTPNachricht(String) for a request, as the server reads it. */
export function parseHttpRequest(message: string): HttpRequestParse {
const lines = javaLines(message);
const [method, rawPath, version] = words(lines[0] ?? '').map(javaTrim);
if (method === undefined) return { type: 'invalid', statusCode: 400 };
const upper = method.toUpperCase();
if (upper !== HTTP_GET && upper !== HTTP_POST) return { type: 'invalid', statusCode: 501 };
if (rawPath === undefined) return { type: 'invalid', statusCode: 400 };
const path = decodeUriPath(rawPath);
if (path === undefined) return { type: 'invalid', statusCode: 400 };
let host: string | undefined;
let data: string | undefined;
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index]!;
if (line === '' && upper === HTTP_POST) {
data = lines.slice(index).join('\n');
break;
}
const [name, value] = words(line).map(javaTrim);
if (name !== undefined && name.toLowerCase() === 'host:' && value !== undefined) host = value;
}
return { type: 'request', request: { method, path, protocolVersion: version ?? 'HTTP/1.1', host, data } };
}
/** HTTPNachricht.getPfad: "/" for none, else without the query. */
export function httpPath(path: string): string {
if (javaTrim(path) === '') return '/';
const query = path.indexOf('?');
return query > 0 ? path.slice(0, query) : path;
}
/** HTTPNachricht.toString for a request (CLIENT): the path without its query, encoded. */
export function formatHttpRequest(request: { method: string; path: string; host: string | undefined; data?: string | undefined }): string {
let text = `${request.method} ${encodeUriPath(httpPath(request.path))} HTTP/1.1\n`;
if (request.host !== undefined) text += `Host: ${request.host}\n`;
if (request.method === HTTP_POST && request.data !== undefined) text += `\n${request.data}`;
return text;
}
/** HTTPNachricht.toString for a response (SERVER). */
export function formatHttpResponse(response: HttpResponse): string {
let text = `${response.protocolVersion} ${response.statusCode} ${httpStatusText(response.statusCode)}\n`;
if (response.contentType !== undefined) text += `Content-type: ${response.contentType}\n`;
if (response.body !== undefined) text += `\n${response.body}`;
return text;
}
/** HTTPNachricht(String) for a response, as the browser reads it; undefined when its status is no number. */
export function parseHttpResponse(message: string): HttpResponse | undefined {
const lines = javaLines(message);
const [version, status] = words(lines[0] ?? '').map(javaTrim);
if (version === undefined || status === undefined || !/^[+-]?\d+$/.test(status)) return undefined;
let contentType: string | undefined;
let body: string | undefined;
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index]!;
if (line === '') {
body = lines.slice(index + 1).join('\n');
break;
}
const [name, value] = words(line).map(javaTrim);
if (name !== undefined && name.toLowerCase() === 'content-type:' && value !== undefined) contentType = value;
}
return { protocolVersion: version, statusCode: Number(status), contentType, body };
}
const STATUS_TEXTS: Record<number, string> = {
100: 'Continue',
101: 'Switching Protocols',
102: 'Processing',
200: 'OK',
201: 'Created',
202: 'Accepted',
203: 'Non-Authoritative Information',
204: 'No Content',
205: 'Reset Content',
206: 'Partial Content',
207: 'Multi-Status',
300: 'Multiple Choice',
301: 'Moved Permanently',
302: 'Found',
303: 'See Other',
304: 'Not Modified',
305: 'Use Proxy',
307: 'Temporary Redirect',
400: 'Bad Request',
401: 'Unauthorized',
402: 'Payment Required',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
406: 'Not Acceptable',
407: 'Proxy Authentication Required',
408: 'Request Time-out',
409: 'Conflict',
410: 'Gone',
411: 'Length Required',
412: 'Precondition Failed',
413: 'Request Entity Too Large',
414: 'Request-URI Too Long',
415: 'Unsupported Media Type',
416: 'Requested range not satisfiable',
417: 'Expectation Failed',
422: 'Unprocessable Entity',
423: 'Locked',
424: 'Failed Dependency',
500: 'Internal Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Time-out',
505: 'HTTP Version not supported',
507: 'Insufficient Storage',
509: 'Bandwidth Limit Exceeded',
};
/** HTTPNachricht.holeStatusNachricht for the codes FILIUS' server sends (Java's texts for others are its message bundle's). */
export function httpStatusText(code: number): string {
return STATUS_TEXTS[code] ?? '';
}
/** Characters a path may hold as they are (RFC 3986 pchar and "/"), as Spring's UriUtils.encodePath keeps them. */
const PATH_CHARACTERS = /[A-Za-z0-9\-._~!$&'()*+,;=:@/]/;
/** UriUtils.encodePath(path, "utf8"). */
export function encodeUriPath(path: string): string {
let text = '';
for (const character of path) {
if (PATH_CHARACTERS.test(character)) {
text += character;
continue;
}
for (const byte of new TextEncoder().encode(character)) text += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
}
return text;
}
/** UriUtils.decode(path, "utf8"): undefined where Java throws (a "%" without two hex digits). */
export function decodeUriPath(path: string): string | undefined {
const bytes: number[] = [];
const encoder = new TextEncoder();
for (let index = 0; index < path.length; index += 1) {
if (path[index] === '%') {
const hex = path.slice(index + 1, index + 3);
if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return undefined;
bytes.push(Number.parseInt(hex, 16));
index += 2;
} else {
const code = path.codePointAt(index)!;
if (code > 0xffff) index += 1;
bytes.push(...encoder.encode(String.fromCodePoint(code)));
}
}
return new TextDecoder().decode(new Uint8Array(bytes));
}
+19
View File
@@ -20,7 +20,14 @@ import type {
ManualRoute,
SimpleClientConfiguration,
SwitchConfiguration,
WebServerConfiguration,
} from './config.js';
import type { VirtualFileSystem } from '../model/virtualFileSystem.js';
/** An installed web server: its configuration, and whether it is switched on (Java: WebServer.aktiv). */
export interface WebServerSnapshot extends WebServerConfiguration {
readonly isActive: boolean;
}
/** An installed echo server: its port, and whether it is switched on (Java: ServerBaustein.aktiv). */
export interface EchoServerSnapshot extends EchoServerConfiguration {
@@ -76,6 +83,12 @@ export interface TopologySnapshot {
readonly echoServersByNodeId: ReadonlyMap<Id, EchoServerSnapshot>;
/** The installed generic clients (ClientBaustein) with the server they last talked to, by node. */
readonly simpleClientsByNodeId: ReadonlyMap<Id, SimpleClientConfiguration>;
/** The installed web servers (WebServer), by node. */
readonly webServersByNodeId: ReadonlyMap<Id, WebServerSnapshot>;
/** The nodes with the web browser installed (WebBrowser). */
readonly webBrowserNodeIds: ReadonlySet<Id>;
/** Each node's files, which its programs read (a web server its pages). */
readonly fileSystemsByNodeId: ReadonlyMap<Id, VirtualFileSystem>;
}
export interface TopologySnapshotInput {
@@ -94,6 +107,9 @@ export interface TopologySnapshotInput {
ipForwardingByNodeId?: ReadonlyMap<Id, boolean>;
echoServersByNodeId?: ReadonlyMap<Id, EchoServerSnapshot>;
simpleClientsByNodeId?: ReadonlyMap<Id, SimpleClientConfiguration>;
webServersByNodeId?: ReadonlyMap<Id, WebServerSnapshot>;
webBrowserNodeIds?: ReadonlySet<Id>;
fileSystemsByNodeId?: ReadonlyMap<Id, VirtualFileSystem>;
}
export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot {
@@ -113,6 +129,9 @@ export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot
ipForwardingByNodeId: input.ipForwardingByNodeId ?? new Map(),
echoServersByNodeId: input.echoServersByNodeId ?? new Map(),
simpleClientsByNodeId: input.simpleClientsByNodeId ?? new Map(),
webServersByNodeId: input.webServersByNodeId ?? new Map(),
webBrowserNodeIds: input.webBrowserNodeIds ?? new Set(),
fileSystemsByNodeId: input.fileSystemsByNodeId ?? new Map(),
};
}