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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 16:53:00 +00:00
co-authored by Claude Opus 5.5
parent de3a1d4dfe
commit 4cd5749350
33 changed files with 53601 additions and 216 deletions
+213
View File
@@ -0,0 +1,213 @@
// HTTP as FILIUS writes and reads it (filius.software.www.HTTPNachricht):
// lines ended by "\n", a request line, a Host header, for POST an empty line
// and the form's data; a response line with Java's status text, a
// Content-type header, an empty line and the body. Paths go encoded as
// Spring's UriUtils.encodePath encodes them (UTF-8, every character a path
// segment may not hold) and are decoded by the server.
//
// Better than Java: a request Java cannot read (a first line without path,
// or an unknown method) gets 400 or 501 (HTTPNachricht throws, the server's
// thread closes the connection without a word); a request without a version
// is HTTP/1.1, the version HTTPNachricht starts with.
export const HTTP_GET = 'GET';
export const HTTP_POST = 'POST';
export type HttpContentType = 'text/html' | 'image/png' | 'image/bmp' | 'image/gif' | 'image/jpg' | 'text/plain';
export interface HttpRequest {
readonly method: string;
/** Decoded, with its query; `httpPath` gives it without. */
readonly path: string;
readonly protocolVersion: string;
readonly host: string | undefined;
/** POST only: what follows the empty line. */
readonly data: string | undefined;
}
export interface HttpResponse {
readonly protocolVersion: string;
readonly statusCode: number;
readonly contentType: string | undefined;
readonly body: string | undefined;
}
/** A first line FILIUS' server cannot read: no method, or a method without path (400), or another method (501). */
export type HttpRequestParse = { readonly type: 'request'; readonly request: HttpRequest } | { readonly type: 'invalid'; readonly statusCode: 400 | 501 };
/** Java's StringTokenizer on spaces: the words, without empty ones. */
function words(line: string): string[] {
return line.split(' ').filter((word) => word !== '');
}
/** Java's String.trim. */
function javaTrim(text: string): string {
let start = 0;
let end = text.length;
while (start < end && text.charCodeAt(start) <= 0x20) start += 1;
while (end > start && text.charCodeAt(end - 1) <= 0x20) end -= 1;
return text.slice(start, end);
}
/** Java's String.split("\n"): trailing empty lines dropped. */
function javaLines(text: string): string[] {
const lines = text.split('\n');
while (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
return lines;
}
/** HTTPNachricht(String) for a request, as the server reads it. */
export function parseHttpRequest(message: string): HttpRequestParse {
const lines = javaLines(message);
const [method, rawPath, version] = words(lines[0] ?? '').map(javaTrim);
if (method === undefined) return { type: 'invalid', statusCode: 400 };
const upper = method.toUpperCase();
if (upper !== HTTP_GET && upper !== HTTP_POST) return { type: 'invalid', statusCode: 501 };
if (rawPath === undefined) return { type: 'invalid', statusCode: 400 };
const path = decodeUriPath(rawPath);
if (path === undefined) return { type: 'invalid', statusCode: 400 };
let host: string | undefined;
let data: string | undefined;
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index]!;
if (line === '' && upper === HTTP_POST) {
data = lines.slice(index).join('\n');
break;
}
const [name, value] = words(line).map(javaTrim);
if (name !== undefined && name.toLowerCase() === 'host:' && value !== undefined) host = value;
}
return { type: 'request', request: { method, path, protocolVersion: version ?? 'HTTP/1.1', host, data } };
}
/** HTTPNachricht.getPfad: "/" for none, else without the query. */
export function httpPath(path: string): string {
if (javaTrim(path) === '') return '/';
const query = path.indexOf('?');
return query > 0 ? path.slice(0, query) : path;
}
/** HTTPNachricht.toString for a request (CLIENT): the path without its query, encoded. */
export function formatHttpRequest(request: { method: string; path: string; host: string | undefined; data?: string | undefined }): string {
let text = `${request.method} ${encodeUriPath(httpPath(request.path))} HTTP/1.1\n`;
if (request.host !== undefined) text += `Host: ${request.host}\n`;
if (request.method === HTTP_POST && request.data !== undefined) text += `\n${request.data}`;
return text;
}
/** HTTPNachricht.toString for a response (SERVER). */
export function formatHttpResponse(response: HttpResponse): string {
let text = `${response.protocolVersion} ${response.statusCode} ${httpStatusText(response.statusCode)}\n`;
if (response.contentType !== undefined) text += `Content-type: ${response.contentType}\n`;
if (response.body !== undefined) text += `\n${response.body}`;
return text;
}
/** HTTPNachricht(String) for a response, as the browser reads it; undefined when its status is no number. */
export function parseHttpResponse(message: string): HttpResponse | undefined {
const lines = javaLines(message);
const [version, status] = words(lines[0] ?? '').map(javaTrim);
if (version === undefined || status === undefined || !/^[+-]?\d+$/.test(status)) return undefined;
let contentType: string | undefined;
let body: string | undefined;
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index]!;
if (line === '') {
body = lines.slice(index + 1).join('\n');
break;
}
const [name, value] = words(line).map(javaTrim);
if (name !== undefined && name.toLowerCase() === 'content-type:' && value !== undefined) contentType = value;
}
return { protocolVersion: version, statusCode: Number(status), contentType, body };
}
const STATUS_TEXTS: Record<number, string> = {
100: 'Continue',
101: 'Switching Protocols',
102: 'Processing',
200: 'OK',
201: 'Created',
202: 'Accepted',
203: 'Non-Authoritative Information',
204: 'No Content',
205: 'Reset Content',
206: 'Partial Content',
207: 'Multi-Status',
300: 'Multiple Choice',
301: 'Moved Permanently',
302: 'Found',
303: 'See Other',
304: 'Not Modified',
305: 'Use Proxy',
307: 'Temporary Redirect',
400: 'Bad Request',
401: 'Unauthorized',
402: 'Payment Required',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
406: 'Not Acceptable',
407: 'Proxy Authentication Required',
408: 'Request Time-out',
409: 'Conflict',
410: 'Gone',
411: 'Length Required',
412: 'Precondition Failed',
413: 'Request Entity Too Large',
414: 'Request-URI Too Long',
415: 'Unsupported Media Type',
416: 'Requested range not satisfiable',
417: 'Expectation Failed',
422: 'Unprocessable Entity',
423: 'Locked',
424: 'Failed Dependency',
500: 'Internal Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Time-out',
505: 'HTTP Version not supported',
507: 'Insufficient Storage',
509: 'Bandwidth Limit Exceeded',
};
/** HTTPNachricht.holeStatusNachricht for the codes FILIUS' server sends (Java's texts for others are its message bundle's). */
export function httpStatusText(code: number): string {
return STATUS_TEXTS[code] ?? '';
}
/** Characters a path may hold as they are (RFC 3986 pchar and "/"), as Spring's UriUtils.encodePath keeps them. */
const PATH_CHARACTERS = /[A-Za-z0-9\-._~!$&'()*+,;=:@/]/;
/** UriUtils.encodePath(path, "utf8"). */
export function encodeUriPath(path: string): string {
let text = '';
for (const character of path) {
if (PATH_CHARACTERS.test(character)) {
text += character;
continue;
}
for (const byte of new TextEncoder().encode(character)) text += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
}
return text;
}
/** UriUtils.decode(path, "utf8"): undefined where Java throws (a "%" without two hex digits). */
export function decodeUriPath(path: string): string | undefined {
const bytes: number[] = [];
const encoder = new TextEncoder();
for (let index = 0; index < path.length; index += 1) {
if (path[index] === '%') {
const hex = path.slice(index + 1, index + 3);
if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return undefined;
bytes.push(Number.parseInt(hex, 16));
index += 2;
} else {
const code = path.codePointAt(index)!;
if (code > 0xffff) index += 1;
bytes.push(...encoder.encode(String.fromCodePoint(code)));
}
}
return new TextDecoder().decode(new Uint8Array(bytes));
}