Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/browser-demos/pages/test-runner/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ let grepBytes: ArrayBuffer | null = null;
let sedBytes: ArrayBuffer | null = null;
let genCatBytes: ArrayBuffer | null = null;

const corsProxyUrl = new URL(
`${import.meta.env.BASE_URL}__kandelo_cors_proxy?url=`,
window.location.href,
).href;

const COREUTILS_NAMES = [
"arch", "b2sum", "base32", "base64", "basename", "basenc", "cat",
"chcon", "chgrp", "chmod", "chown", "chroot", "cksum", "comm", "cp",
Expand Down Expand Up @@ -182,6 +187,7 @@ async function init() {

const kernel = new BrowserKernel({
kernelOwnedFs: true,
corsProxyUrl,
onStdout: (data: Uint8Array) => {
const text = new TextDecoder().decode(data);
stdout += text;
Expand Down
82 changes: 82 additions & 0 deletions apps/browser-demos/test/browser-cors-proxy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { readFile } from "node:fs/promises";
import { resolveBinary } from "../../../host/src/binary-resolver";

const wgetPath = resolveBinary("programs/wget.wasm");

type TestResult = {
exitCode: number;
stdout: string;
stderr: string;
combined: string;
};

type TestRunnerWindow = Window & {
__testRunnerReady: boolean;
__runTest(
wasmBytes: ArrayBuffer,
argv: string[],
timeoutMs: number,
): Promise<TestResult>;
};

test("guest HTTP uses the test runner's same-origin CORS proxy", async ({
page,
}) => {
const upstreamRequests: string[] = [];
const upstream = createServer((request, response) => {
upstreamRequests.push(request.url ?? "");
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Kandelo CORS proxy regression\n");
});
await new Promise<void>((resolve, reject) => {
upstream.once("error", reject);
upstream.listen(0, "::1", () => {
upstream.off("error", reject);
resolve();
});
});

try {
const { port } = upstream.address() as AddressInfo;
// The trailing root dot avoids the guest's /etc/hosts localhost entry, so
// Kandelo delegates the connection to its browser backend. Node still
// resolves the proxy's upstream target to this test-only ::1 listener.
const targetUrl = `http://localhost.:${port}/probe`;
const wgetBytes = Array.from(await readFile(wgetPath));

await page.goto("/pages/test-runner/", {
waitUntil: "domcontentloaded",
});
await page.waitForFunction(
() => (window as unknown as TestRunnerWindow).__testRunnerReady === true,
);
expect(
await page.evaluate(() => navigator.serviceWorker.controller),
"the regression must exercise explicit BrowserKernel proxy configuration",
).toBeNull();

const result = await page.evaluate(
async ({ bytes, url }) =>
(window as unknown as TestRunnerWindow).__runTest(
new Uint8Array(bytes).buffer,
["wget", "-qO-", url],
60_000,
),
{ bytes: wgetBytes, url: targetUrl },
);

expect(
result.exitCode,
JSON.stringify({ result, upstreamRequests }, null, 2),
).toBe(0);
expect(result.stdout).toBe("Kandelo CORS proxy regression\n");
expect(upstreamRequests).toEqual(["/probe"]);
} finally {
await new Promise<void>((resolve, reject) => {
upstream.close((error) => (error ? reject(error) : resolve()));
});
}
});
4 changes: 3 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,9 @@ Browsers cannot create external raw TCP or UDP sockets. Local loopback and `Loca

1. **FetchNetworkBackend**: Buffers an entire HTTP request from the Wasm process, sends it via `fetch()`, and returns the raw HTTP response bytes. Works for simple HTTP clients.

2. **Service Worker HTTP Bridge**: For server demos (nginx, WordPress), a service worker intercepts browser `fetch()` requests to a configurable URL prefix (e.g., `/app/`) and forwards them to the kernel via a MessagePort connection pump. The kernel injects the request as a TCP connection to nginx's listening socket, and nginx's response flows back through the pipe to the service worker.
2. **TlsNetworkBackend**: Terminates the guest's TLS connection with a generated, in-VFS CA and sends the decoded HTTP request through browser `fetch()`. Service-worker-controlled apps may proxy cross-origin fetches transparently. Other embedders set `BrowserKernelOptions.corsProxyUrl`; the option crosses the main-thread/worker protocol and routes backend fetches through the application's CORS proxy.

3. **Service Worker HTTP Bridge**: For server demos (nginx, WordPress), a service worker intercepts browser `fetch()` requests to a configurable URL prefix (e.g., `/app/`) and forwards them to the kernel via a MessagePort connection pump. The kernel injects the request as a TCP connection to nginx's listening socket, and nginx's response flows back through the pipe to the service worker.

`TcpNetworkBackend`, `FetchNetworkBackend`, `TlsNetworkBackend`, and `LocalVirtualNetwork` share one numeric-address and hostname validator. It accepts decimal one-, two-, three-, and four-component IPv4 forms within their component widths, rejects malformed or overflowing numeric forms, enforces ASCII host-label syntax and DNS length limits, and preserves one trailing root dot. The Node TCP backend resolves validated names through the host resolver. The browser HTTP fetch/TLS bridges synthesize IPv4 mappings for syntactically acceptable DNS names; `LocalVirtualNetwork` resolves only aliases registered by attached machines. None of the browser paths adds browser DNS resolution or AF_INET6 transport.

Expand Down
6 changes: 5 additions & 1 deletion host/test/browser-kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ describe("BrowserKernel", () => {

it("boot() spawns a worker, sends init, and resolves on `ready`", async () => {
const BrowserKernel = await loadBrowserKernel();
const kernel = new BrowserKernel({ kernelOwnedFs: true });
const kernel = new BrowserKernel({
kernelOwnedFs: true,
corsProxyUrl: "https://proxy.example/?url=",
});

const bootPromise = kernel.boot({
kernelWasm: new ArrayBuffer(8),
Expand All @@ -123,6 +126,7 @@ describe("BrowserKernel", () => {
expect(init).toBeDefined();
expect(init.argv).toBeUndefined(); // argv goes in the spawn message
expect(init.kernelWasmBytes).toBeInstanceOf(ArrayBuffer);
expect(init.config.corsProxyUrl).toBe("https://proxy.example/?url=");

// Simulate the worker becoming ready, then reply to the spawn request.
w.simulateMessage({ type: "ready" });
Expand Down
Loading