feat(editor): F4.6d the web browser and web server on a computer's desktop

The oracle BrowserWindowRun uses Java's browser and web server windows as a
person does; the room keeps a browser window per computer and sends a
program window's view, the start names the person's language. The editor
shows the page in a sandboxed frame, links and forms go to the room, the web
server has its button, virtual hosts and lines. Better than Java: relative
images and links count from the page's folder, a mark stays in the page, the
error page from Java's template, the virtual hosts' file with complete rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 16:09:05 +00:00
co-authored by Claude Opus 5.5
parent f0e7c73ec7
commit aa54e5400e
31 changed files with 2773 additions and 55 deletions
+103 -20
View File
@@ -10,7 +10,13 @@
// 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).
// read (Java shows nothing). A page's relative images and links count from
// its folder, as in every browser (Java's window counts them from the
// server's root, so that a page in a folder misses its images and links;
// for a page in the root the requests are Java's). The table of virtual
// hosts is written with its complete rows only (Java writes every row, and
// reading the file again a row with an empty cell takes the next row's
// cell).
//
// A server may make pages itself (Java's plug-ins, WebServerPlugIn): a
// router's or home router's firewall log and routing table (routerPages.ts).
@@ -74,10 +80,13 @@ function contentTypeOf(name: string): string {
const IMAGE_TYPES = new Set(['image/bmp', 'image/gif', 'image/jpg', 'image/png']);
/** WebServer's table of virtual hosts: five rows of host name and folder, "" where empty. */
export type VirtualHostTable = readonly (readonly [string, string])[];
export class WebServer extends TcpServerProgram {
private readonly documentRoot: string;
private readonly virtualHosts: readonly (readonly [string, string])[];
private readonly useVirtualHosts: boolean;
private virtualHosts: [string, string][];
private useVirtualHostsValue: boolean;
constructor(
engine: NetworkRuntimeEngine,
@@ -96,13 +105,49 @@ export class WebServer extends TcpServerProgram {
for (let index = 0; index < lines.length && rows.length < VIRTUAL_HOST_ROWS; index += 2) {
rows.push([lines[index]!, lines[index + 1] ?? '']);
}
while (rows.length < VIRTUAL_HOST_ROWS) rows.push(['', '']);
this.virtualHosts = rows;
this.useVirtualHosts = configuration.virtualHosts || vhosts !== undefined;
this.useVirtualHostsValue = configuration.virtualHosts || vhosts !== undefined;
}
/** Whether the server looks up the request's host in its table (the window's check box). */
get useVirtualHosts(): boolean {
return this.useVirtualHostsValue;
}
/** WebServer.setUseVHost: from the next request on. */
setUseVirtualHosts(use: boolean): void {
this.useVirtualHostsValue = use;
}
get virtualHostTable(): VirtualHostTable {
return this.virtualHosts;
}
/**
* WebServer.changeVHostTable: a cell of the table (column 0 the host name,
* 1 the folder), saved to /www.conf/vhosts — with the complete rows only,
* so that Java reads the file as the table is.
*/
setVirtualHost(row: number, column: 0 | 1, value: string): void {
const cells = this.virtualHosts[row];
if (cells === undefined) return;
cells[column] = value;
const text = this.virtualHosts
.filter(([host, folder]) => host !== '' && folder !== '')
.map(([host, folder]) => `${host}\n${folder}\n`)
.join('');
try {
if (this.fileSystem !== undefined && !this.fileSystem.contains('/www.conf')) this.fileSystem.createDirectory('/www.conf');
this.fileSystem?.writeTextFile('/www.conf/vhosts', text);
} catch {
// A file where the folder should be: the table holds, unsaved, as Java's does when it cannot save.
}
}
/** WebServer.vhostPrefix: the folder of the host name, ignoring case; "" when none. */
private virtualHostFolder(host: string | undefined): string {
if (!this.useVirtualHosts || host === undefined) return '';
if (!this.useVirtualHostsValue || host === undefined) return '';
return this.virtualHosts.find(([name]) => name.toLowerCase() === host.toLowerCase())?.[1] ?? '';
}
@@ -189,6 +234,35 @@ function javaSplit(text: string, separator: string): string[] {
return parts;
}
/**
* A page's reference (an image's src) as the browser requests it: one with
* a leading slash or a scheme as it is, a relative one from the page's
* folder, without a leading slash as Java writes it (for a page in the
* root, Java's request).
*/
export function pageReference(pagePath: string, reference: string): string {
if (reference.startsWith('/') || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(reference)) return reference;
const path = pagePath.split('?')[0]!;
return path.slice(path.startsWith('/') ? 1 : 0, path.lastIndexOf('/') + 1) + reference;
}
/**
* Where a link of a page leads, as the browser window loads it: an http
* address to its host, a path to the page's host (relative from the page's
* folder, with a leading slash as Java's window writes it); undefined for a
* link the browser does not follow (another scheme, a mark in the page).
*/
export function linkTarget(page: { readonly host: string; readonly path: string }, href: string): { readonly host: string; readonly path: string } | undefined {
const reference = href.trim();
if (reference === '' || reference.startsWith('#')) return undefined;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(reference)) {
if (!/^http:/i.test(reference)) return undefined;
return webBrowserAddress(reference.split('#')[0]!);
}
const target = pageReference(page.path, reference.split('#')[0]!);
return { host: page.host, path: target.startsWith('/') ? target : `/${target}` };
}
/** 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}$/;
export const IP_ADDRESS_PATTERN =
@@ -218,15 +292,24 @@ export function imageSources(html: string): string[] {
}
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 }
/** A page came from the host at the path: shown as it is with status 200, else as the browser's error page for the status. */
| {
readonly kind: 'page';
readonly host: string;
readonly path: string;
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 };
/** An image of the page (its src as written) came, with its data — 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; readonly data?: Uint8Array };
/** The page the browser shows: as it came, with the images that came for it. */
/** The page the browser shows: as it came, with the images that came for it (by their src as written). */
export interface WebBrowserPage {
readonly host: string;
readonly path: string;
readonly statusCode: number;
readonly contentType: string | undefined;
readonly body: string | undefined;
@@ -238,7 +321,7 @@ export class WebBrowser {
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 shown: (Omit<WebBrowserPage, 'images'> & { readonly images: Map<string, Uint8Array> }) | undefined;
private readonly commands: CommandThread;
constructor(
@@ -326,22 +409,22 @@ export class WebBrowser {
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 });
const page = { host: this.hostValue ?? '', path, statusCode: response.statusCode, contentType: response.contentType, body: response.body };
this.shown = { ...page, images: new Map() };
this.log.write({ kind: 'page', ...page });
}
/** WebBrowser.retrieveImages: each over the same connection, as long as it lasts. */
/** WebBrowser.retrieveImages: each over the same connection, as long as it lasts, from the page's folder. */
private *retrieveImages(): Steps {
const pagePath = this.shown?.path ?? '/';
for (const source of this.images) {
if (!this.isConnected) return;
const response = yield* this.exchange(source, undefined);
const response = yield* this.exchange(pageReference(pagePath, 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 });
const data = response.statusCode === 200 && isImage ? javaBase64Decode(response.body ?? '') : undefined;
if (data !== undefined) this.shown?.images.set(source, data);
this.log.write({ kind: 'image', source, statusCode: response.statusCode, contentType: response.contentType, ...(data === undefined ? {} : { data }) });
}
}