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);
|
||||
}
|
||||
Reference in New Issue
Block a user