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:
@@ -19,7 +19,11 @@ export * from './runtime/capture.js';
|
||||
export * from './runtime/captureRows.js';
|
||||
export * from './runtime/threads.js';
|
||||
export * from './runtime/apps/program.js';
|
||||
export * from './runtime/apps/server.js';
|
||||
export * from './runtime/apps/echo.js';
|
||||
export * from './runtime/apps/web.js';
|
||||
export * from './runtime/http.js';
|
||||
export * from './util/equal.js';
|
||||
export * from './util/javaBase64.js';
|
||||
export * from './util/javaIp.js';
|
||||
export * from './util/swiftString.js';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Base64 as FILIUS writes and reads it (filius.rahmenprogramm.Base64,
|
||||
// iHarder Base64 2.x): how image and other binary files are kept in Java's
|
||||
// file system, and how its web server sends them.
|
||||
|
||||
import { base64Encode } from './swiftString.js';
|
||||
|
||||
/**
|
||||
* FILIUS's Base64.encodeBytes: a line break after every 76 characters —
|
||||
* also after the last full line — then the padded rest.
|
||||
*/
|
||||
export function javaBase64(bytes: Uint8Array): string {
|
||||
const full = bytes.length - (bytes.length % 3);
|
||||
let text = '';
|
||||
for (let offset = 0; offset < full; offset += 57) {
|
||||
const end = Math.min(offset + 57, full);
|
||||
text += base64Encode(bytes.subarray(offset, end));
|
||||
if (end - offset === 57) text += '\n';
|
||||
}
|
||||
return text + base64Encode(bytes.subarray(full));
|
||||
}
|
||||
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
/**
|
||||
* Base64.decode: white space skipped; undefined where a character is none
|
||||
* of Base64's or the length does not come out (iHarder throws).
|
||||
*/
|
||||
export function javaBase64Decode(text: string): Uint8Array | undefined {
|
||||
const characters = text.replace(/[ \t\r\n\f]/g, '');
|
||||
if (characters.length % 4 !== 0) return undefined;
|
||||
const bytes: number[] = [];
|
||||
for (let index = 0; index < characters.length; index += 4) {
|
||||
const quad = characters.slice(index, index + 4);
|
||||
const values = [...quad].map((character) => (character === '=' ? 0 : ALPHABET.indexOf(character)));
|
||||
if (values.some((value) => value < 0)) return undefined;
|
||||
const padding = quad.endsWith('==') ? 2 : quad.endsWith('=') ? 1 : 0;
|
||||
if (quad.slice(0, 4 - padding).includes('=') || (padding > 0 && index + 4 < characters.length)) return undefined;
|
||||
const chunk = (values[0]! << 18) | (values[1]! << 12) | (values[2]! << 6) | values[3]!;
|
||||
bytes.push((chunk >> 16) & 255);
|
||||
if (padding < 2) bytes.push((chunk >> 8) & 255);
|
||||
if (padding < 1) bytes.push(chunk & 255);
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
@@ -17,8 +17,9 @@ import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { withSnapshot, type EchoServerLogEntry, type LogLine, type NetworkRuntimeEngine, type SendFailure, type SimpleClientLogEntry } from '../src/index.js';
|
||||
import { withSnapshot, type LogLine, type NetworkRuntimeEngine } from '../src/index.js';
|
||||
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js';
|
||||
import { clientText, CONNECT_FAILURES, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
|
||||
import { snapshotOf, type Scenario } from './scenario.js';
|
||||
|
||||
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
||||
@@ -26,7 +27,6 @@ const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenari
|
||||
const SERVER = '00000000-0000-0000-0000-000000000101';
|
||||
const CLIENT = '00000000-0000-0000-0000-000000000102';
|
||||
const OTHER_CLIENT = '00000000-0000-0000-0000-000000000103';
|
||||
const POLL_MILLISECONDS = 100;
|
||||
|
||||
type Program = 'server' | 'client' | 'other';
|
||||
|
||||
@@ -36,70 +36,6 @@ interface JavaRun extends JavaTables {
|
||||
events: [number, Program, string][];
|
||||
}
|
||||
|
||||
/** sw_tcpsocket_msg6, msg7, msg8. */
|
||||
const SEND_FAILURES: Record<SendFailure, string> = {
|
||||
notConnected: 'Verbindung nicht aufgebaut!',
|
||||
connectionLost: 'Verbindung wurde unterbrochen',
|
||||
timedOut: 'Zeitüberschreitung während des Versands einer Nachricht',
|
||||
};
|
||||
|
||||
/** A server's line as Java's window shows it (German message bundle). */
|
||||
function serverText(entry: EchoServerLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'acceptingStarted':
|
||||
return 'Annahme von Verbindungsanfragen gestartet';
|
||||
case 'acceptingStopped':
|
||||
return 'Annahme von Verbindungsanfragen beendet';
|
||||
case 'serverSocketClosed':
|
||||
return 'Server-Socket geschlossen';
|
||||
case 'serverSocketFailed':
|
||||
return 'Server-Socket konnte nicht geöffnet werden.';
|
||||
case 'connectionAccepted':
|
||||
return `Verbindung zu ${entry.address}:${entry.port} hergestellt`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} geschlossen`;
|
||||
}
|
||||
}
|
||||
|
||||
const CONNECT_FAILURES = {
|
||||
unresolved: 'Ziel konnte nicht bestimmt werden!',
|
||||
timedOut: 'Zeitüberschreitung beim Verbindungsaufbau',
|
||||
failed: 'Aktives Öffnen des Sockets fehlgeschlagen',
|
||||
};
|
||||
|
||||
/** A client's line as Java's window shows it. */
|
||||
function clientText(entry: SimpleClientLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'connected':
|
||||
return 'Verbindung hergestellt';
|
||||
case 'connectFailed':
|
||||
return `Fehler beim Verbindungsaufbau\n${CONNECT_FAILURES[entry.failure]}`;
|
||||
case 'disconnected':
|
||||
return 'Verbindung getrennt';
|
||||
case 'notConnected':
|
||||
return 'Fehler beim Verbindungsaufbau';
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} getrennt`;
|
||||
case 'receiveFailed':
|
||||
return 'Keine Verbindung hergestellt!';
|
||||
}
|
||||
}
|
||||
|
||||
/** Lines Java writes after one of its 100 ms looks. */
|
||||
const POLLED = new Set(['Verbindung hergestellt', 'Server-Socket geschlossen']);
|
||||
const isPolled = (text: string) => POLLED.has(text) || /^Verbindung zu .* hergestellt$/.test(text);
|
||||
|
||||
type Act = (engine: NetworkRuntimeEngine) => void;
|
||||
const server = (engine: NetworkRuntimeEngine) => engine.echoServer(SERVER)!;
|
||||
|
||||
@@ -197,7 +197,7 @@ export function expectSameCaptures(
|
||||
java: Record<string, JavaFrame[]>,
|
||||
options: {
|
||||
text?: (text: string) => string;
|
||||
normalized?: (frames: SeenFrame[]) => SeenFrame[];
|
||||
normalized?: (frames: SeenFrame[], card: string) => SeenFrame[];
|
||||
from?: number | undefined;
|
||||
until?: number | undefined;
|
||||
tolerance?: number;
|
||||
@@ -223,7 +223,7 @@ export function expectSameCaptures(
|
||||
const javaStart = options.starts?.java ?? first(javaByCard);
|
||||
const from = (options.from ?? Number.NEGATIVE_INFINITY) - javaStart;
|
||||
const until = (options.until ?? Number.POSITIVE_INFINITY) - javaStart;
|
||||
const normalized = options.normalized ?? ((frames: SeenFrame[]) => frames);
|
||||
const normalized = options.normalized ?? ((frames: SeenFrame[], _card: string) => frames);
|
||||
const window = (frames: SeenFrame[], start: number) =>
|
||||
frames.filter((frame) => frame.time - start >= from && frame.time - start < until).map((frame) => ({ time: frame.time - start, text: frame.text }));
|
||||
const inWindow = (time: number, start: number) => time - start >= from && time - start < until;
|
||||
@@ -249,8 +249,8 @@ export function expectSameCaptures(
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = normalized(window(javaList, javaStart));
|
||||
const actual = normalized(window(engineByCard.get(card)!, engineStart));
|
||||
const expected = normalized(window(javaList, javaStart), card);
|
||||
const actual = normalized(window(engineByCard.get(card)!, engineStart), card);
|
||||
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
||||
expect(actual[index]?.text, `${card} frame #${index + 1}`).toBe(expected[index]?.text);
|
||||
expect(Math.abs(actual[index]!.time - expected[index]!.time), `${card} time of frame #${index + 1}`).toBeLessThanOrEqual(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// The lines FILIUS' programs write into their windows, as Java's German
|
||||
// message bundle says them: for comparing the core's structured lines with
|
||||
// what the Java oracle recorded (echoJava.test.ts, webJava.test.ts).
|
||||
|
||||
import type { EchoServerLogEntry, SendFailure, SimpleClientLogEntry } from '../src/index.js';
|
||||
|
||||
/** Java's client and server look every 100 ms whether they are connected, or switched off; the core at once. */
|
||||
export const POLL_MILLISECONDS = 100;
|
||||
|
||||
/** Lines Java writes after one of its 100 ms looks. */
|
||||
const POLLED = new Set(['Verbindung hergestellt', 'Server-Socket geschlossen']);
|
||||
export const isPolled = (text: string) => POLLED.has(text) || /^Verbindung zu .* hergestellt$/.test(text);
|
||||
|
||||
/** sw_tcpsocket_msg6, msg7, msg8. */
|
||||
export const SEND_FAILURES: Record<SendFailure, string> = {
|
||||
notConnected: 'Verbindung nicht aufgebaut!',
|
||||
connectionLost: 'Verbindung wurde unterbrochen',
|
||||
timedOut: 'Zeitüberschreitung während des Versands einer Nachricht',
|
||||
};
|
||||
|
||||
/** A server's line as Java's window shows it (German message bundle). */
|
||||
export function serverText(entry: EchoServerLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'acceptingStarted':
|
||||
return 'Annahme von Verbindungsanfragen gestartet';
|
||||
case 'acceptingStopped':
|
||||
return 'Annahme von Verbindungsanfragen beendet';
|
||||
case 'serverSocketClosed':
|
||||
return 'Server-Socket geschlossen';
|
||||
case 'serverSocketFailed':
|
||||
return 'Server-Socket konnte nicht geöffnet werden.';
|
||||
case 'connectionAccepted':
|
||||
return `Verbindung zu ${entry.address}:${entry.port} hergestellt`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} geschlossen`;
|
||||
}
|
||||
}
|
||||
|
||||
export const CONNECT_FAILURES = {
|
||||
unresolved: 'Ziel konnte nicht bestimmt werden!',
|
||||
timedOut: 'Zeitüberschreitung beim Verbindungsaufbau',
|
||||
failed: 'Aktives Öffnen des Sockets fehlgeschlagen',
|
||||
};
|
||||
|
||||
/** A client's line as Java's window shows it. */
|
||||
export function clientText(entry: SimpleClientLogEntry): string {
|
||||
switch (entry.kind) {
|
||||
case 'connected':
|
||||
return 'Verbindung hergestellt';
|
||||
case 'connectFailed':
|
||||
return `Fehler beim Verbindungsaufbau\n${CONNECT_FAILURES[entry.failure]}`;
|
||||
case 'disconnected':
|
||||
return 'Verbindung getrennt';
|
||||
case 'notConnected':
|
||||
return 'Fehler beim Verbindungsaufbau';
|
||||
case 'sent':
|
||||
return `<<${entry.text}`;
|
||||
case 'received':
|
||||
return `>>${entry.text}`;
|
||||
case 'sendFailed':
|
||||
return SEND_FAILURES[entry.failure];
|
||||
case 'connectionClosed':
|
||||
return `Socket zu ${entry.address}:${entry.port} getrennt`;
|
||||
case 'receiveFailed':
|
||||
return 'Keine Verbindung hergestellt!';
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"seed": 61,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000201",
|
||||
"kind": "pc",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000211",
|
||||
"label": "eth0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000202",
|
||||
"kind": "pc",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000212",
|
||||
"label": "eth0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000203",
|
||||
"kind": "pc",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000213",
|
||||
"label": "eth0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000204",
|
||||
"kind": "pc",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000214",
|
||||
"label": "eth0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000205",
|
||||
"kind": "networkSwitch",
|
||||
"ports": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000221",
|
||||
"label": "sw1"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000222",
|
||||
"label": "sw2"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000223",
|
||||
"label": "sw3"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000224",
|
||||
"label": "sw4"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000F201",
|
||||
"sourceNode": "00000000-0000-0000-0000-000000000201",
|
||||
"sourcePort": "00000000-0000-0000-0000-000000000211",
|
||||
"targetNode": "00000000-0000-0000-0000-000000000205",
|
||||
"targetPort": "00000000-0000-0000-0000-000000000221"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000F202",
|
||||
"sourceNode": "00000000-0000-0000-0000-000000000202",
|
||||
"sourcePort": "00000000-0000-0000-0000-000000000212",
|
||||
"targetNode": "00000000-0000-0000-0000-000000000205",
|
||||
"targetPort": "00000000-0000-0000-0000-000000000222"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000F203",
|
||||
"sourceNode": "00000000-0000-0000-0000-000000000203",
|
||||
"sourcePort": "00000000-0000-0000-0000-000000000213",
|
||||
"targetNode": "00000000-0000-0000-0000-000000000205",
|
||||
"targetPort": "00000000-0000-0000-0000-000000000223"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-00000000F204",
|
||||
"sourceNode": "00000000-0000-0000-0000-000000000204",
|
||||
"sourcePort": "00000000-0000-0000-0000-000000000214",
|
||||
"targetNode": "00000000-0000-0000-0000-000000000205",
|
||||
"targetPort": "00000000-0000-0000-0000-000000000224"
|
||||
}
|
||||
],
|
||||
"devices": {
|
||||
"00000000-0000-0000-0000-000000000201": {
|
||||
"ip": "192.168.0.20",
|
||||
"mask": "255.255.255.0",
|
||||
"dns": "192.168.0.10"
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000202": {
|
||||
"ip": "192.168.0.10",
|
||||
"mask": "255.255.255.0"
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000203": {
|
||||
"ip": "192.168.0.30",
|
||||
"mask": "255.255.255.0"
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000204": {
|
||||
"ip": "192.168.0.31",
|
||||
"mask": "255.255.255.0"
|
||||
}
|
||||
},
|
||||
"dnsServers": {
|
||||
"00000000-0000-0000-0000-000000000202": {
|
||||
"records": [
|
||||
"www.filius.de A 192.168.0.30",
|
||||
"seite.filius.de A 192.168.0.30",
|
||||
"andere.filius.de A 192.168.0.30",
|
||||
"leer.filius.de A 192.168.0.31"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// HTTP as FILIUS writes it (http.ts), and the web server and browser
|
||||
// (apps/web.ts) where webJava.test.ts's stories do not go.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeUriPath,
|
||||
deviceConfiguration,
|
||||
encodeUriPath,
|
||||
formatHttpRequest,
|
||||
formatHttpResponse,
|
||||
imageSources,
|
||||
javaBase64,
|
||||
javaBase64Decode,
|
||||
parseHttpRequest,
|
||||
parseHttpResponse,
|
||||
VirtualFileSystem,
|
||||
webBrowserAddress,
|
||||
type NetworkRuntimeEngine,
|
||||
type TopologySnapshotInput,
|
||||
} from '../src/index.js';
|
||||
import { link, pc, startedEngine, uuid } from './fixtures.js';
|
||||
|
||||
describe('HTTP as FILIUS writes it', () => {
|
||||
it('writes a request with the path encoded and without its query, and a POST with its data', () => {
|
||||
expect(formatHttpRequest({ method: 'GET', path: '/mein bild.html?x=1', host: 'www.filius.de' })).toBe('GET /mein%20bild.html HTTP/1.1\nHost: www.filius.de\n');
|
||||
expect(formatHttpRequest({ method: 'POST', path: '', host: undefined, data: 'a=b' })).toBe('POST / HTTP/1.1\n\na=b');
|
||||
});
|
||||
|
||||
it('reads a request: method in any case, Host header, POST data after the empty line', () => {
|
||||
expect(parseHttpRequest('post /form%C3%BC HTTP/1.0\nhost: a.de\n\nx=1\ny=2')).toEqual({
|
||||
type: 'request',
|
||||
request: { method: 'post', path: '/formü', protocolVersion: 'HTTP/1.0', host: 'a.de', data: '\nx=1\ny=2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('answers what it cannot read with 400 or 501, and takes a request without a version as HTTP/1.1 (Java: the thread dies)', () => {
|
||||
expect(parseHttpRequest('')).toEqual({ type: 'invalid', statusCode: 400 });
|
||||
expect(parseHttpRequest('GET')).toEqual({ type: 'invalid', statusCode: 400 });
|
||||
expect(parseHttpRequest('HALLO Welt')).toEqual({ type: 'invalid', statusCode: 501 });
|
||||
expect(parseHttpRequest('GET /%zz HTTP/1.1')).toEqual({ type: 'invalid', statusCode: 400 });
|
||||
expect(parseHttpRequest('GET /')).toMatchObject({ type: 'request', request: { path: '/', protocolVersion: 'HTTP/1.1' } });
|
||||
});
|
||||
|
||||
it('writes and reads a response', () => {
|
||||
const text = formatHttpResponse({ protocolVersion: 'HTTP/1.1', statusCode: 200, contentType: 'text/html', body: '<p>\n</p>' });
|
||||
expect(text).toBe('HTTP/1.1 200 OK\nContent-type: text/html\n\n<p>\n</p>');
|
||||
expect(parseHttpResponse(text)).toEqual({ protocolVersion: 'HTTP/1.1', statusCode: 200, contentType: 'text/html', body: '<p>\n</p>' });
|
||||
expect(parseHttpResponse('HTTP/1.1 404 Not Found\n')).toEqual({ protocolVersion: 'HTTP/1.1', statusCode: 404, contentType: undefined, body: undefined });
|
||||
expect(parseHttpResponse('HTTP/1.1 200 OK\ncontent-TYPE: image/png\n\nx')).toMatchObject({ contentType: 'image/png', body: 'x' });
|
||||
expect(parseHttpResponse('Hallo Welt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('encodes a path as Spring UriUtils.encodePath and decodes it back', () => {
|
||||
expect(encodeUriPath("/a b/ü?#%[]-._~!$&'()*+,;=:@")).toBe("/a%20b/%C3%BC%3F%23%25%5B%5D-._~!$&'()*+,;=:@");
|
||||
expect(decodeUriPath('/a%20b/%C3%BC+')).toBe('/a b/ü+');
|
||||
expect(decodeUriPath('/50%')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads and writes Base64 as FILIUS does, line breaks and all', () => {
|
||||
const bytes = new Uint8Array(100).map((_, index) => index * 7);
|
||||
expect(javaBase64Decode(javaBase64(bytes))).toEqual(bytes);
|
||||
expect(javaBase64Decode('QUJD\nRA==')).toEqual(new TextEncoder().encode('ABCD'));
|
||||
expect(javaBase64Decode('QUJ')).toBeUndefined();
|
||||
expect(javaBase64Decode('QU=D')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the browser's address line (erzeugeURL)", () => {
|
||||
it('takes a host with or without http://, the path without a final slash', () => {
|
||||
expect(webBrowserAddress('www.filius.de')).toEqual({ host: 'www.filius.de', path: '/' });
|
||||
expect(webBrowserAddress('HTTP://192.168.0.30/a/b/')).toEqual({ host: '192.168.0.30', path: '/a/b' });
|
||||
expect(webBrowserAddress('http:///text.txt')).toEqual({ host: '', path: '/text.txt' });
|
||||
expect(webBrowserAddress('/text.txt')).toEqual({ host: '', path: '/text.txt' });
|
||||
});
|
||||
|
||||
it('is no address for what Java does not take as a host', () => {
|
||||
expect(webBrowserAddress('www.filius.de:8080/')).toBeUndefined();
|
||||
expect(webBrowserAddress('1fehler.de')).toBeUndefined();
|
||||
expect(webBrowserAddress('http:')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the images of a page', () => {
|
||||
it('finds each img tag, in any case, its src in any quotes', () => {
|
||||
expect(imageSources('<img src="a.png"><IMG SRC=\'b.png\'> <img alt="x" src=c/d.png><img\nsrc = "e.png" /><imgx src="no.png"><img alt="no src">')).toEqual([
|
||||
'a.png',
|
||||
'b.png',
|
||||
'c/d.png',
|
||||
'e.png',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const SERVER = uuid(0x3001);
|
||||
const CLIENT = uuid(0x3002);
|
||||
|
||||
function network(files: VirtualFileSystem, changes: Partial<TopologySnapshotInput> = {}): TopologySnapshotInput {
|
||||
return {
|
||||
nodes: [pc(SERVER, uuid(0x3011)), pc(CLIENT, uuid(0x3012))],
|
||||
links: [link(SERVER, uuid(0x3011), CLIENT, uuid(0x3012))],
|
||||
deviceConfigurations: new Map([
|
||||
[SERVER, deviceConfiguration('10.0.0.1', '255.255.255.0', '', '')],
|
||||
[CLIENT, deviceConfiguration('10.0.0.2', '255.255.255.0', '', '')],
|
||||
]),
|
||||
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: true }]]),
|
||||
webBrowserNodeIds: new Set([CLIENT]),
|
||||
fileSystemsByNodeId: new Map([[SERVER, files]]),
|
||||
...changes,
|
||||
};
|
||||
}
|
||||
|
||||
function files(entries: Record<string, string>): VirtualFileSystem {
|
||||
const fileSystem = new VirtualFileSystem();
|
||||
fileSystem.createDirectory('/webserver', true);
|
||||
for (const [path, text] of Object.entries(entries)) {
|
||||
const parent = path.slice(0, path.lastIndexOf('/'));
|
||||
if (parent !== '' && !fileSystem.contains(parent)) fileSystem.createDirectory(parent, true);
|
||||
fileSystem.writeTextFile(path, text);
|
||||
}
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
/** The server's answers to the requests, one after the other over one connection. */
|
||||
function ask(engine: NetworkRuntimeEngine, ...requests: string[]): string[] {
|
||||
const socket = engine.openTcpClientSocket(CLIENT, '10.0.0.1', 80)!;
|
||||
expect(engine.connectTcpWithResult(socket)).toBe('connected');
|
||||
return requests.map((request) => {
|
||||
expect(engine.sendTcp(socket, new TextEncoder().encode(request))).toBe(true);
|
||||
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 2_000 });
|
||||
return new TextDecoder().decode(engine.receiveTcp(socket) ?? new Uint8Array());
|
||||
});
|
||||
}
|
||||
|
||||
describe('the web server', () => {
|
||||
it('answers 400 and 501 and stays connected, where Java closes the connection', () => {
|
||||
const engine = startedEngine(network(files({ '/webserver/index.html': 'Start' })));
|
||||
expect(ask(engine, 'HALLO', 'GET', 'GET /')).toEqual(['HTTP/1.1 501 Not Implemented\n', 'HTTP/1.1 400 Bad Request\n', 'HTTP/1.1 200 OK\nContent-type: text/html\n\nStart']);
|
||||
});
|
||||
|
||||
it('serves nothing outside its document root (Java serves any file of the host), and no folder', () => {
|
||||
const engine = startedEngine(network(files({ '/geheim.txt': 'geheim', '/webserver/ordner/a.txt': 'a' })));
|
||||
expect(ask(engine, 'GET /../geheim.txt HTTP/1.1', 'GET /ordner/../../geheim.txt HTTP/1.1', 'GET /ordner HTTP/1.1', 'GET /ordner/./a.txt?x HTTP/1.1')).toEqual([
|
||||
'HTTP/1.1 404 Not Found\n',
|
||||
'HTTP/1.1 404 Not Found\n',
|
||||
'HTTP/1.1 404 Not Found\n',
|
||||
'HTTP/1.1 200 OK\nContent-type: text/plain\n\na',
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the content by the file name: htm, bmp, gif, jpg; jpeg is plain text, as in Java', () => {
|
||||
const fileSystem = files({ '/webserver/a.HTM': 'a' });
|
||||
const image = new Uint8Array([1, 2, 3, 4]);
|
||||
for (const name of ['b.bmp', 'c.gif', 'd.jpg', 'e.jpeg']) fileSystem.writeImageFile(`/webserver/${name}`, image, 'image/png');
|
||||
fileSystem.writeBinaryFile('/webserver/f.bin', image);
|
||||
const engine = startedEngine(network(fileSystem));
|
||||
const types = ask(engine, ...['a.HTM', 'b.bmp', 'c.gif', 'd.jpg', 'e.jpeg', 'f.bin'].map((name) => `GET /${name} HTTP/1.1`)).map(
|
||||
(answer) => answer.split('\n')[1],
|
||||
);
|
||||
expect(types).toEqual(['Content-type: text/html', 'Content-type: image/bmp', 'Content-type: image/gif', 'Content-type: image/jpg', 'Content-type: text/plain', 'Content-type: text/plain']);
|
||||
expect(ask(engine, 'GET /f.bin HTTP/1.1')[0]!.endsWith(`\n\n${javaBase64(image)}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('serves by host name only from the first five rows of www.conf/vhosts, ignoring case, and from its configuration', () => {
|
||||
const vhosts = Array.from({ length: 6 }, (_, index) => `h${index}.de\nv${index}`).join('\n');
|
||||
const entries = Object.fromEntries(Array.from({ length: 6 }, (_, index) => [`/webserver/v${index}/index.html`, `V${index}`]));
|
||||
const engine = startedEngine(network(files({ ...entries, '/webserver/index.html': 'Start', '/www.conf/vhosts': vhosts })));
|
||||
const pages = ask(engine, 'GET / HTTP/1.1\nHost: H4.DE', 'GET / HTTP/1.1\nHost: h5.de', 'GET / HTTP/1.1').map((answer) => answer.split('\n\n')[1]);
|
||||
expect(pages).toEqual(['V4', 'Start', 'Start']);
|
||||
|
||||
// Without the file, the table is empty: the configuration's switch alone serves the root.
|
||||
const plain = startedEngine(
|
||||
network(files({ '/webserver/index.html': 'Start' }), {
|
||||
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: true, isActive: true }]]),
|
||||
}),
|
||||
);
|
||||
expect(ask(plain, 'GET / HTTP/1.1\nHost: h0.de')[0]).toBe('HTTP/1.1 200 OK\nContent-type: text/html\n\nStart');
|
||||
});
|
||||
|
||||
it('serves nothing without files', () => {
|
||||
const engine = startedEngine({ ...network(new VirtualFileSystem()), fileSystemsByNodeId: new Map() });
|
||||
expect(ask(engine, 'GET / HTTP/1.1')).toEqual(['HTTP/1.1 404 Not Found\n']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the web browser', () => {
|
||||
it('keeps the page and its images, and goes to the last host for a link without one', () => {
|
||||
const fileSystem = files({ '/webserver/index.html': '<img src="a.png"><img src="b.png">', '/webserver/seite.html': 'Seite' });
|
||||
fileSystem.writeImageFile('/webserver/a.png', new Uint8Array([9, 8, 7]), 'image/png');
|
||||
const engine = startedEngine(network(fileSystem));
|
||||
const browser = engine.webBrowser(CLIENT)!;
|
||||
expect(browser.open('10.0.0.1')).toBe(true);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
expect(browser.page).toMatchObject({ statusCode: 200, body: '<img src="a.png"><img src="b.png">' });
|
||||
expect([...browser.page!.images]).toEqual([['a.png', new Uint8Array([9, 8, 7])]]);
|
||||
expect(browser.log.all.filter((line) => line.kind === 'image').map((line) => line.kind === 'image' && line.statusCode)).toEqual([200, 404]);
|
||||
|
||||
browser.load('', '/seite.html');
|
||||
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
|
||||
expect(browser.host).toBe('10.0.0.1');
|
||||
expect(browser.page).toMatchObject({ statusCode: 200, body: 'Seite' });
|
||||
expect(browser.page!.images.size).toBe(0);
|
||||
});
|
||||
|
||||
it('fetches images only for an HTML page', () => {
|
||||
const engine = startedEngine(network(files({ '/webserver/text.txt': '<img src="a.png">' })));
|
||||
const browser = engine.webBrowser(CLIENT)!;
|
||||
browser.open('10.0.0.1/text.txt');
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
expect(browser.log.all.map((line) => line.kind)).toEqual(['page']);
|
||||
});
|
||||
|
||||
it('does nothing for what is no address', () => {
|
||||
const engine = startedEngine(network(files({})));
|
||||
expect(engine.webBrowser(CLIENT)!.open('kein host:80')).toBe(false);
|
||||
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
|
||||
expect(engine.webBrowser(CLIENT)!.log.all).toEqual([]);
|
||||
expect(engine.capture.frames(uuid(0x3012))).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows "server not reachable" when the server closes without an answer (Java shows nothing)', () => {
|
||||
const engine = startedEngine(network(files({}), { webServersByNodeId: new Map() }));
|
||||
const listener = engine.openTcpServerSocket(SERVER, 80)!;
|
||||
const browser = engine.webBrowser(CLIENT)!;
|
||||
browser.open('10.0.0.1');
|
||||
for (let time = 100; time < 20_000; time += 100) {
|
||||
engine.handle({ type: 'advance', toMilliseconds: time });
|
||||
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listener)?.[0];
|
||||
if (accepted !== undefined && engine.state.socketsById.get(accepted)?.tcpState === 'ESTABLISHED') engine.closeTcpSocket(accepted);
|
||||
}
|
||||
expect(browser.log.all.map((line) => line.kind)).toEqual(['unreachable']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user