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:
@@ -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 }) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
formatHttpResponse,
|
||||
imageSources,
|
||||
javaBase64,
|
||||
linkTarget,
|
||||
pageReference,
|
||||
javaBase64Decode,
|
||||
parseHttpRequest,
|
||||
parseHttpResponse,
|
||||
@@ -80,6 +82,29 @@ describe("the browser's address line (erzeugeURL)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a page's references", () => {
|
||||
it('count from the page\'s folder, written as Java writes them for a page in the root (Java counts from the root)', () => {
|
||||
expect(pageReference('/', 'bild.png')).toBe('bild.png');
|
||||
expect(pageReference('/seite.html?x=1/2', 'unter/bild.png')).toBe('unter/bild.png');
|
||||
expect(pageReference('/ordner/seite.html', 'bild.png')).toBe('ordner/bild.png');
|
||||
expect(pageReference('/vhost1/', '../bild.png')).toBe('vhost1/../bild.png');
|
||||
expect(pageReference('ohne/schraegstrich.html', 'a.png')).toBe('ohne/a.png');
|
||||
expect(pageReference('/ordner/seite.html', '/bild.png')).toBe('/bild.png');
|
||||
expect(pageReference('/ordner/seite.html', 'http://h/x.png')).toBe('http://h/x.png');
|
||||
});
|
||||
|
||||
it('lead a link to the page\'s host, an http address to its own, and nowhere for a mark or another scheme', () => {
|
||||
const page = { host: 'www.filius.de', path: '/ordner/seite.html' };
|
||||
expect(linkTarget(page, 'vier.html')).toEqual({ host: 'www.filius.de', path: '/ordner/vier.html' });
|
||||
expect(linkTarget(page, ' /text.txt ')).toEqual({ host: 'www.filius.de', path: '/text.txt' });
|
||||
expect(linkTarget(page, 'ziel.html?a=1#oben')).toEqual({ host: 'www.filius.de', path: '/ordner/ziel.html?a=1' });
|
||||
expect(linkTarget({ host: 'h', path: '/' }, 'a.html')).toEqual({ host: 'h', path: '/a.html' });
|
||||
expect(linkTarget(page, 'HTTP://192.168.0.30/x/y#z')).toEqual({ host: '192.168.0.30', path: '/x/y' });
|
||||
expect(linkTarget(page, 'http://kein host/')).toBeUndefined();
|
||||
for (const href of ['', ' ', '#oben', 'mailto:anna@filius.de', 'javascript:alert(1)', 'ftp://h/']) expect(linkTarget(page, href), href).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([
|
||||
@@ -181,6 +206,42 @@ describe('the web server', () => {
|
||||
expect(ask(plain, 'GET / HTTP/1.1\nHost: h0.de')[0]).toBe('HTTP/1.1 200 OK\nContent-type: text/html\n\nStart');
|
||||
});
|
||||
|
||||
it('switches its virtual hosts and writes their table with the complete rows, which it reads again as written', () => {
|
||||
const fileSystem = files({ '/webserver/index.html': 'Start', '/webserver/v/index.html': 'V' });
|
||||
const engine = startedEngine(network(fileSystem));
|
||||
const server = engine.webServer(SERVER)!;
|
||||
expect([server.useVirtualHosts, server.virtualHostTable]).toEqual([false, [['', ''], ['', ''], ['', ''], ['', ''], ['', '']]]);
|
||||
server.setVirtualHost(0, 0, 'h.de');
|
||||
// An incomplete row is not written: the folder is made, the file empty.
|
||||
expect(fileSystem.textFile('/www.conf/vhosts')).toBe('');
|
||||
server.setVirtualHost(0, 1, 'v');
|
||||
server.setVirtualHost(2, 1, 'nur');
|
||||
server.setVirtualHost(4, 0, 'z.de');
|
||||
server.setVirtualHost(4, 1, 'w');
|
||||
server.setVirtualHost(5, 0, 'nein.de');
|
||||
expect(server.virtualHostTable).toEqual([['h.de', 'v'], ['', ''], ['', 'nur'], ['', ''], ['z.de', 'w']]);
|
||||
expect(fileSystem.textFile('/www.conf/vhosts')).toBe('h.de\nv\nz.de\nw\n');
|
||||
// Off, the table counts for nothing; on, from the next request.
|
||||
expect(ask(engine, 'GET / HTTP/1.1\nHost: h.de')[0]!.endsWith('\n\nStart')).toBe(true);
|
||||
server.setUseVirtualHosts(true);
|
||||
expect(ask(engine, 'GET / HTTP/1.1\nHost: H.DE', 'GET / HTTP/1.1\nHost: x.de')[0]!.endsWith('\n\nV')).toBe(true);
|
||||
const again = startedEngine(network(fileSystem)).webServer(SERVER)!;
|
||||
expect([again.useVirtualHosts, again.virtualHostTable]).toEqual([true, [['h.de', 'v'], ['z.de', 'w'], ['', ''], ['', ''], ['', '']]]);
|
||||
});
|
||||
|
||||
it('keeps its table where /www.conf is a file and the vhosts cannot be written', () => {
|
||||
const fileSystem = files({ '/www.conf': 'kein Ordner' });
|
||||
const server = startedEngine(network(fileSystem)).webServer(SERVER)!;
|
||||
server.setVirtualHost(0, 0, 'h.de');
|
||||
server.setVirtualHost(0, 1, 'v');
|
||||
expect(server.virtualHostTable[0]).toEqual(['h.de', 'v']);
|
||||
expect(fileSystem.textFile('/www.conf')).toBe('kein Ordner');
|
||||
// Without files at all, the table holds too.
|
||||
const bare = startedEngine({ ...network(new VirtualFileSystem()), fileSystemsByNodeId: new Map() }).webServer(SERVER)!;
|
||||
bare.setVirtualHost(1, 1, 'x');
|
||||
expect(bare.virtualHostTable[1]).toEqual(['', 'x']);
|
||||
});
|
||||
|
||||
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']);
|
||||
@@ -206,6 +267,24 @@ describe('the web browser', () => {
|
||||
expect(browser.page!.images.size).toBe(0);
|
||||
});
|
||||
|
||||
it("asks for a page's images from the page's folder and keeps them by their src, with the page's host and path", () => {
|
||||
const fileSystem = files({ '/webserver/ordner/seite.html': '<img src="a.png"><img src="/b.png">' });
|
||||
fileSystem.writeImageFile('/webserver/ordner/a.png', new Uint8Array([1]), 'image/png');
|
||||
fileSystem.writeImageFile('/webserver/b.png', new Uint8Array([2]), 'image/png');
|
||||
const engine = startedEngine(network(fileSystem));
|
||||
const browser = engine.webBrowser(CLIENT)!;
|
||||
browser.open('10.0.0.1/ordner/seite.html?x=1');
|
||||
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
|
||||
expect(browser.page).toMatchObject({ host: '10.0.0.1', path: '/ordner/seite.html?x=1' });
|
||||
expect([...browser.page!.images]).toEqual([['a.png', new Uint8Array([1])], ['/b.png', new Uint8Array([2])]]);
|
||||
const lines = browser.log.all.map(({ timeMilliseconds: _, ...line }) => line);
|
||||
expect(lines).toEqual([
|
||||
{ kind: 'page', host: '10.0.0.1', path: '/ordner/seite.html?x=1', statusCode: 200, contentType: 'text/html', body: '<img src="a.png"><img src="/b.png">' },
|
||||
{ kind: 'image', source: 'a.png', statusCode: 200, contentType: 'image/png', data: new Uint8Array([1]) },
|
||||
{ kind: 'image', source: '/b.png', statusCode: 200, contentType: 'image/png', data: new Uint8Array([2]) },
|
||||
]);
|
||||
});
|
||||
|
||||
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)!;
|
||||
|
||||
Reference in New Issue
Block a user