diff --git a/package.json b/package.json index 05cdef8d5e3..eb15323757a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "changelog": "bun ./packages/meta/bin/update-changelog.ts", "deploy:docs": "gh-pages -d dist/docs/build -t true", "dev": "nx dev playground-website", + "dev:experimental-posix-kernel": "nx dev-experimental-posix-kernel playground-website", "format": "nx format", "format:uncommitted": "nx format --fix --parallel --uncommitted", "lint": "nx run-many --all --target=lint", diff --git a/packages/playground/remote/project.json b/packages/playground/remote/project.json index 98bd9c85eb3..18173f515db 100644 --- a/packages/playground/remote/project.json +++ b/packages/playground/remote/project.json @@ -49,6 +49,14 @@ } } }, + "dev-experimental-posix-kernel": { + "executor": "nx:run-commands", + "continuous": true, + "options": { + "command": "vite --config vite.posix-kernel.config.ts", + "cwd": "packages/playground/remote" + } + }, "preview": { "executor": "@nx/vite:preview-server", "defaultConfiguration": "production", diff --git a/packages/playground/remote/remote-posix-kernel.html b/packages/playground/remote/remote-posix-kernel.html new file mode 100644 index 00000000000..9e2964a791b --- /dev/null +++ b/packages/playground/remote/remote-posix-kernel.html @@ -0,0 +1,94 @@ + + + + WordPress Playground — Experimental POSIX Kernel + + + + + + + diff --git a/packages/playground/remote/src/lib/posix-kernel/boot-playground-remote.ts b/packages/playground/remote/src/lib/posix-kernel/boot-playground-remote.ts new file mode 100644 index 00000000000..7d8fc1b3366 --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/boot-playground-remote.ts @@ -0,0 +1,308 @@ +/** + * Bootstrap for the `--experimental-posix-kernel` browser mode. + * + * Parallel to `boot-playground-remote.ts`, but loaded by + * `remote-posix-kernel.html`. Differences: + * + * - Spawns the kernel-mode Comlink worker + * (`playground-worker-endpoint.ts?worker&url`) instead of the + * v1/v2 PHP workers. + * - Skips the `mountOpfs` / journal / `cli` plumbing that the + * classic worker exposes; the kernel-mode handler delivers + * WordPress via nginx + php-fpm, so those APIs aren't relevant + * for the first cut. + * - Service worker reuse: still registers + * `packages/playground/remote/service-worker.ts` so scoped URL + * routing, COOP/COEP headers and the existing dispatch keep + * working. The `request` message the service worker sends is + * answered by the kernel-mode worker's `requestStreamed` + * implementation. + * + * Everything that doesn't differ from the classic boot is documented + * inline in `../boot-playground-remote.ts`; refer there before + * extending behaviour here. + */ +import type { MessageListener } from '@php-wasm/universal'; +import { streamToPort } from '@php-wasm/universal'; +import { + spawnPHPWorkerThread, + exposeAPI, + consumeAPI, + setupPostMessageRelay, +} from '@php-wasm/web'; +import { logger } from '@php-wasm/logger'; +import { PhpWasmError } from '@php-wasm/util'; +import { responseTo } from '@php-wasm/web-service-worker'; + +import serviceWorkerPath from '../../../service-worker.ts?worker&url'; +import kernelWorkerUrl from './playground-worker-endpoint.ts?worker&url'; + +import type { KernelPlaygroundWorkerEndpoint } from './playground-worker-endpoint'; + +const origin = new URL('/', (import.meta || {}).url).origin; +const serviceWorkerUrl = new URL(serviceWorkerPath, origin); + +// @ts-ignore +if (import.meta.hot) { + // @ts-ignore + import.meta.hot.accept(() => {}); +} + +export async function bootPlaygroundRemote() { + assertNotInfiniteLoadingLoop(); + + const sw = navigator.serviceWorker; + if (!sw) { + if (window.isSecureContext) { + throw new PhpWasmError( + 'Service workers are not supported in your browser.' + ); + } + throw new PhpWasmError( + 'WordPress Playground uses service workers and may only work on HTTPS and http://localhost/ sites, but the current site is neither.' + ); + } + + const registration = await sw.register(serviceWorkerUrl + '', { + type: 'module', + updateViaCache: 'none', + }); + + try { + await registration.update(); + } catch (e) { + logger.error('Failed to update service worker.', e); + } + + const workerUrl = new URL(kernelWorkerUrl, origin) + ''; + const kernelWorkerApi = consumeAPI( + await spawnPHPWorkerThread(workerUrl) + ); + + const wpFrame = document.querySelector('#wp') as HTMLIFrameElement; + + const playgroundApi = { + async onDownloadProgress(fn: (event: any) => void) { + return kernelWorkerApi.onDownloadProgress(fn); + }, + async onNavigation(fn: (path: string) => void) { + let lastPath: string | undefined; + wpFrame.addEventListener('load', async (e: any) => { + try { + const contentWindow = e.currentTarget!.contentWindow; + await new Promise((resolve) => setTimeout(resolve, 0)); + const path = await playground.internalUrlToPath( + contentWindow.location.href + ); + if (path !== lastPath) { + lastPath = path; + fn(path); + } + } catch { + /* ignore */ + } + }); + setInterval(async () => { + try { + let href = ''; + if (wpFrame.contentWindow) { + href = wpFrame.contentWindow.location.href; + } else { + href = wpFrame.src; + } + const path = await playground.internalUrlToPath(href); + if (path !== lastPath) { + lastPath = path; + fn(path); + } + } catch { + /* ignore */ + } + }, 500); + }, + async goTo(requestedPath: string) { + if (!requestedPath.startsWith('/')) { + requestedPath = '/' + requestedPath; + } + if (requestedPath === '/wp-admin') { + requestedPath = '/wp-admin/'; + } + // The V1 blueprint runner wraps every landing-page navigation + // in `/index.php?playground-redirection-handler&next=` so the classic playground's auto-login + // mu-plugin can finalize cookies before the redirect + // (`packages/playground/blueprints/src/lib/v1/compile.ts: + // 460`). Inside the posix-kernel WP stack that exact URL + // hangs the FPM worker indefinitely (req has been observed + // at 30s+ with no FCGI End-Of-Request), even when a + // short-circuit mu-plugin is installed — whatever stalls + // runs *before* mu-plugins load. We don't (yet) wire + // auto-login in kernel mode, so the cookie-ordering + // rationale doesn't apply; unwrap to the bare `next` URL + // instead. If/when auto-login lands, this needs to drop + // back to the wrapper and the underlying hang must be + // fixed. + const REDIRECT_HANDLER_PATH = + '/index.php?playground-redirection-handler'; + if (requestedPath.startsWith(REDIRECT_HANDLER_PATH)) { + const params = new URLSearchParams( + requestedPath.slice(requestedPath.indexOf('?') + 1) + ); + const next = params.get('next'); + if (next) { + try { + requestedPath = + await playground.internalUrlToPath(next); + } catch { + /* fall through to original path */ + } + } + } + const newUrl = await playground.pathToInternalUrl(requestedPath); + const oldUrl = wpFrame.src; + const navigationComplete = new Promise((resolve) => { + wpFrame.addEventListener('load', () => resolve(), { + once: true, + }); + }); + if (newUrl === oldUrl && wpFrame.contentWindow) { + try { + wpFrame.contentWindow.location.href = newUrl; + await navigationComplete; + return; + } catch { + /* ignore */ + } + } + wpFrame.src = newUrl; + await navigationComplete; + }, + async getCurrentURL() { + let url = ''; + try { + url = wpFrame.contentWindow!.location.href; + } catch { + /* ignore */ + } + if (!url) { + url = wpFrame.src; + } + return await playground.internalUrlToPath(url); + }, + async setIframeSandboxFlags(flags: string[]) { + wpFrame.setAttribute('sandbox', flags.join(' ')); + }, + // The website's `progressTracker.pipe(playground)` calls + // `setProgress` / `setLoaded` on the iframe-side `playgroundApi` + // (see `packages/php-wasm/progress/src/lib/progress-tracker.ts` + // `pipe()`). The classic boot wires these to its in-iframe + // progress bar; kernel mode has no progress bar yet, so these + // are no-ops. Without them, Comlink's worker-side dispatch hits + // `undefined.apply(...)` and the whole boot promise rejects + // before our kernel logic gets a chance to run. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async setProgress(_options: unknown) { + /* no-op for the first cut */ + }, + async setLoaded() { + /* no-op for the first cut */ + }, + async onMessage(callback: MessageListener) { + return await kernelWorkerApi.onMessage(callback); + }, + async addEventListener(event: any, listener: any) { + return await kernelWorkerApi.addEventListener(event, listener); + }, + async removeEventListener(event: any, listener: any) { + return await kernelWorkerApi.removeEventListener(event, listener); + }, + async boot(options: any) { + await kernelWorkerApi.boot(options); + + // Same shape as the classic boot: pipe service-worker `request` + // messages to the worker's request handler. The kernel worker + // implements `requestStreamed` so the existing service-worker + // dispatch needs no changes. + navigator.serviceWorker.addEventListener( + 'message', + async function onMessage(event) { + if (options.scope && event.data.scope !== options.scope) { + return; + } + + const args = event.data.args || []; + const method = event.data.method as string; + + if (method === 'request') { + const streamedResponse = await ( + kernelWorkerApi.requestStreamed as any + )(...args); + const httpStatusCode = + await streamedResponse.httpStatusCode; + const headers = await streamedResponse.headers; + const bodyPort = streamToPort(streamedResponse.stdout); + (event.source! as ServiceWorker).postMessage( + responseTo(event.data.requestId, { + httpStatusCode, + headers, + bodyPort, + }), + [bodyPort] + ); + } else { + const result = await (kernelWorkerApi as any)[method]( + ...args + ); + event.source!.postMessage( + responseTo(event.data.requestId, result) + ); + } + } + ); + sw.startMessages(); + + try { + await kernelWorkerApi.isReady(); + setupPostMessageRelay( + wpFrame, + getOrigin((await playground.absoluteUrl)!) + ); + setAPIReady(); + } catch (e) { + setAPIError(e as Error); + throw e; + } + }, + }; + + await kernelWorkerApi.isConnected(); + + const [setAPIReady, setAPIError, playground] = exposeAPI( + playgroundApi, + kernelWorkerApi + ); + + return playground; +} + +function getOrigin(url: string) { + return new URL(url, 'https://example.com').origin; +} + +function assertNotInfiniteLoadingLoop() { + let isBrowserInABrowser = false; + try { + isBrowserInABrowser = + window.parent !== window && + (window as any).parent.IS_WASM_WORDPRESS; + } catch { + /* ignore */ + } + if (isBrowserInABrowser) { + throw new Error( + `The service worker did not load correctly. This is a bug, ` + + `please report it on https://github.com/WordPress/wordpress-playground/issues` + ); + } + (window as any).IS_WASM_WORDPRESS = true; +} diff --git a/packages/playground/remote/src/lib/posix-kernel/boot.ts b/packages/playground/remote/src/lib/posix-kernel/boot.ts new file mode 100644 index 00000000000..51ec2b2afb9 --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/boot.ts @@ -0,0 +1,299 @@ +/** + * Boot WordPress backed by wasm-posix-kernel in the browser. + * + * Browser counterpart to `playground/cli/src/posix-kernel/boot.ts`. + * Where the CLI spawns a Node worker thread that owns the kernel and + * uses native sockets, the browser path: + * + * 1. Builds a `MemoryFileSystem` VFS image at boot time, populated + * with WordPress + the SQLite drop-in + nginx/php-fpm configs + * (see `vfs-builder.ts`). + * 2. Spawns a `BrowserKernel` (web worker hosting the kernel Wasm) + * and hands it the VFS image and the kernel binary bytes. + * 3. Wires an in-worker `HttpBridgeHost` so the kernel's TCP + * listener on port 8080 (nginx) becomes the request path for + * the `request()` API exposed by the Comlink worker. + * + * Returns a `KernelBootResult` containing the bridge endpoint and a + * disposer so the Comlink worker can: + * + * - Forward `request()` calls into the bridge and resolve with the + * full HTTP response (mirrors how nginx+fpm serves WP requests in + * the CLI). + * - Tear the kernel down cleanly on `destroy()`. + */ + +import { logger } from '@php-wasm/logger'; +import { BrowserKernel, HttpBridgeHost } from './host-bridge'; +import type { HttpRequest, HttpResponse } from './host-bridge'; + +/** + * Per-spawn output capture handler installed by + * {@link KernelBootResult.setCapture}. When a handler is set, every + * stdout/stderr chunk from the kernel goes to it instead of the default + * logger sink — so service-side output stops surfacing in the console + * while a blueprint step is running. + */ +export type CaptureHandler = ( + chunk: Uint8Array, + stream: 'stdout' | 'stderr' +) => void; + +/** + * Default nginx listen port inside the kernel. nginx.conf in the VFS + * baked by `vfs-builder.ts` binds to `127.0.0.1:8080`; the bridge + * connects to that port to route `request()` calls. + */ +const KERNEL_HTTP_PORT = 8080; +const NGINX_READY_TIMEOUT_MS = 15_000; + +export interface KernelBootOptions { + /** Pre-built VFS image bytes (from `MemoryFileSystem.saveImage()`). */ + vfsImage: Uint8Array; + /** Kernel `kernel.wasm` bytes. */ + kernelWasm: ArrayBuffer; +} + +export interface KernelBootResult { + /** + * Send an HTTP request to the kernel-resident nginx. Used by the + * Comlink worker to implement `request()` / `requestStreamed()`. + */ + sendRequest: (request: HttpRequest) => Promise; + /** + * The live `BrowserKernel` instance. `KernelSpawnAdapter` uses this + * to spawn `coreutils.wasm` / `php.wasm` against the kernel-resident + * VFS — the only way to mutate the VFS from the host once + * `kernelOwnedFs: true` is set. + */ + kernel: BrowserKernel; + /** + * Install or remove a per-spawn capture handler. `BrowserKernel`'s + * `onStdout` / `onStderr` are constructor-time singletons (no + * per-pid routing), so capturing a spawned program's output requires + * coordinating with the global handler set up below. Passing `null` + * reverts to the default logger sink. The contract: only one + * capture is active at a time; the adapter serializes spawn calls + * so this is sufficient. + */ + setCapture: (handler: CaptureHandler | null) => void; +} + +export async function bootKernelWordPress( + options: KernelBootOptions +): Promise { + // One capture slot — the adapter serializes spawn calls so a stack + // isn't needed. Service-side stdout/stderr that race in while a + // capture is active gets attributed to the captured spawn, which is + // why nginx/php-fpm in `vfs-builder.ts` are configured to log to + // files rather than stdout. + let activeCapture: CaptureHandler | null = null; + const setCapture = (handler: CaptureHandler | null): void => { + activeCapture = handler; + }; + const routeChunk = ( + data: Uint8Array, + stream: 'stdout' | 'stderr' + ): void => { + if (activeCapture) { + activeCapture(data, stream); + return; + } + const text = new TextDecoder().decode(data); + if (stream === 'stderr') { + logger.warn('[posix-kernel]', text); + } else { + logger.debug('[posix-kernel]', text); + } + }; + + const kernel = new BrowserKernel({ + kernelOwnedFs: true, + maxWorkers: 8, + maxMemoryPages: 4096, + onStdout: (data: Uint8Array) => routeChunk(data, 'stdout'), + onStderr: (data: Uint8Array) => routeChunk(data, 'stderr'), + onListenTcp: (_pid: number, _fd: number, port: number) => { + logger.debug(`[posix-kernel] service listening on :${port}`); + }, + }); + + // Bridge between this worker and the kernel worker. The + // `HttpBridgeHost` produces a MessageChannel; we send `port2` to + // the kernel worker via `sendBridgePort()` and keep `port1` here + // for `sendRequest()`. + const bridge = new HttpBridgeHost(); + + // Boot. dinit (PID 1) starts php-fpm → nginx inside the VFS. + // See `vfs-builder.ts` for the service tree. + const { exit } = await kernel.boot({ + kernelWasm: options.kernelWasm, + vfsImage: options.vfsImage, + argv: ['/sbin/dinit', '--container', '-p', '/tmp/dinitctl'], + env: [ + 'HOME=/root', + 'TERM=xterm-256color', + 'PATH=/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin', + ], + }); + + // Wire the kernel worker to consume requests from `bridge.port` + // (port2 of the channel). After this call, sending a request on + // the host-side port is routed to the kernel's TCP listener. + kernel.sendBridgePort(bridge.detachHostPort(), KERNEL_HTTP_PORT); + + exit.then( + (status: number) => + logger.debug(`[posix-kernel] dinit exited with status ${status}`), + (error: unknown) => logger.error('[posix-kernel] dinit failed:', error) + ); + + const sendRequest = createRequestSender(bridge); + + // `kernel.boot()` resolves as soon as the kernel itself is ready — + // not when dinit's child services (php-fpm, nginx) have bound their + // sockets. Until nginx is listening on :8080 the bridge rejects + // every request with "No listener target available" (emitted from + // `wasm-posix-kernel/host/src/kernel-worker-entry.ts:1018`). Poll + // `GET /` through the bridge until any HTTP status comes back. + await waitForNginx(sendRequest, NGINX_READY_TIMEOUT_MS); + + // `BrowserKernel.nextPid` is initialized to 100 + // (`wasm-posix-kernel/examples/browser/lib/browser-kernel.ts:104`), + // but the kernel's internal process table is already populated past + // that mark: dinit (PID 1), php-fpm, nginx, plus every php-fpm + // worker forked under the load php-fpm hits while serving the + // install probe. The bridge log we observed showed kernel-side + // PIDs 104 and 107 by the time `waitForNginx` returned. + // When `KernelSpawnAdapter` makes its first `kernel.spawn()` call, + // `nextPid++` hands out 100, which the kernel rejects with EEXIST. + // Bump the host counter past the kernel's reserved range. The + // kernel's own forks won't reach 10000 in practice (php-fpm static + // pool stays at 2 workers, and other services don't reproduce), + // so this avoids any collision without coordinating with the + // kernel's PID allocator. + (kernel as { nextPid: number }).nextPid = 10000; + + return { + sendRequest, + kernel, + setCapture, + }; +} + +async function waitForNginx( + send: (request: HttpRequest) => Promise, + timeoutMs: number, + intervalMs = 100 +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const response = await send({ + method: 'GET', + url: '/', + headers: {}, + body: null, + }); + if (response && typeof response.status === 'number') { + return; + } + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error( + `[posix-kernel] nginx did not become ready within ${timeoutMs}ms` + + (lastError ? `; last error: ${String(lastError)}` : '') + ); +} + +/** + * Wrap `HttpBridgeHost` request/response handling into a request- + * promise interface. The bridge surface exposes raw MessagePort + * messages; the Comlink worker needs a `(request) => response` + * function. + */ +function createRequestSender(bridge: HttpBridgeHost) { + let nextId = 1; + const pending = new Map< + number, + { resolve: (r: HttpResponse) => void; reject: (e: Error) => void } + >(); + + // HttpBridgeHost was designed for service-worker → main thread + // dispatch. Here we use it in reverse: `detachHostPort()` returns + // `port1` and we ship it to the kernel worker via + // `sendBridgePort(...)`, so the KERNEL ends up owning port1. The + // still-live host-side port is `port2`, accessible via + // `getSwPort()` — we use that to SEND requests rather than + // receive. We piggyback on the bridge protocol by listening for + // `http-response` / `http-error` and sending `http-request` + // frames in the same shape. + const hostPort = bridge.getSwPort(); + hostPort.onmessage = (event: MessageEvent) => { + const msg = event.data; + if (msg?.type === 'http-response') { + const entry = pending.get(msg.requestId); + if (entry) { + pending.delete(msg.requestId); + entry.resolve({ + status: msg.status, + headers: msg.headers, + body: msg.body, + }); + } + } else if (msg?.type === 'http-error') { + const entry = pending.get(msg.requestId); + if (entry) { + pending.delete(msg.requestId); + entry.reject(new Error(msg.error || 'Bridge request failed')); + } + } + }; + + return function sendRequest(request: HttpRequest): Promise { + const requestId = nextId++; + // nginx's vhost (`vfs-builder.ts:697` — `server_name localhost`) + // is HTTP/1.1 and rejects any request without a `Host:` header + // with a 400 (RFC 7230 §5.4). The bridge's + // `buildRawHttpRequest` (`wasm-posix-kernel/examples/browser/ + // lib/kernel-worker-entry.ts:1129`) does not synthesize a Host + // of its own — it writes exactly the headers we hand it. The + // CLI doesn't trip this because it calls Node's `fetch()`, + // which adds Host from the URL automatically. Inject it here so + // every bridge request (waitForNginx polling, install probe, + // blueprint `request()` calls, and so on) goes out well-formed. + const headers = withDefaultHeaders(request.headers); + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }); + hostPort.postMessage({ + type: 'http-request', + requestId, + method: request.method, + url: request.url, + headers, + body: request.body, + }); + }); + }; +} + +/** + * Ensure every bridge request carries a `Host:` header. nginx returns + * 400 Bad Request without it on HTTP/1.1 — see the rationale in + * `createRequestSender`. `localhost` matches the nginx `server_name` + * baked into the VFS. + */ +function withDefaultHeaders( + headers: Record | undefined +): Record { + const out: Record = { ...(headers ?? {}) }; + const hasHost = Object.keys(out).some((k) => k.toLowerCase() === 'host'); + if (!hasHost) { + out['Host'] = 'localhost'; + } + return out; +} diff --git a/packages/playground/remote/src/lib/posix-kernel/host-bridge.ts b/packages/playground/remote/src/lib/posix-kernel/host-bridge.ts new file mode 100644 index 00000000000..7225693119f --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/host-bridge.ts @@ -0,0 +1,29 @@ +/** + * Browser counterpart to `playground/cli/src/posix-kernel/host-bridge.ts`. + * + * The CLI dynamic-imports `host/dist/index.js` from `WASM_POSIX_KERNEL_DIR` + * because the kernel isn't an npm dependency and we don't want + * Vite/esbuild to bundle Node-only paths. In the browser worker we + * need actual `import` statements so Vite can follow them and emit a + * proper worker bundle — so we import the demo-level `BrowserKernel` + * and the nested kernel-worker entry directly from the bundled + * `wasm-posix-kernel/` submodule via relative paths. + * + * Indirection through this module isolates playground call sites from + * the submodule layout. If the kernel project moves `BrowserKernel` + * into its published `host` package later, only this file needs to + * change. + * + * The `@kernel-wasm` and `@kernel-binary/` aliases are resolved + * by `resolveKernelBinariesPlugin` in `remote/vite.posix-kernel.config.ts`. + */ + +export { BrowserKernel } from '@wasm-posix-kernel/examples/browser/lib/browser-kernel'; + +export { HttpBridgeHost } from '@wasm-posix-kernel/examples/browser/lib/http-bridge'; +export type { + HttpRequest, + HttpResponse, +} from '@wasm-posix-kernel/examples/browser/lib/http-bridge'; + +export { MemoryFileSystem } from '@wasm-posix-kernel/host/src/vfs/memory-fs'; diff --git a/packages/playground/remote/src/lib/posix-kernel/index.ts b/packages/playground/remote/src/lib/posix-kernel/index.ts new file mode 100644 index 00000000000..992202bd029 --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/index.ts @@ -0,0 +1 @@ +export { bootPlaygroundRemote } from './boot-playground-remote'; diff --git a/packages/playground/remote/src/lib/posix-kernel/kernel-spawn-adapter.ts b/packages/playground/remote/src/lib/posix-kernel/kernel-spawn-adapter.ts new file mode 100644 index 00000000000..6a65d9a4ae2 --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/kernel-spawn-adapter.ts @@ -0,0 +1,280 @@ +/** + * Natural FS-shaped facade over the `BrowserKernel` spawn API. + * + * `BrowserKernel` exposes process spawn (`kernel.spawn(programBytes, + * argv, options)`) and an HTTP bridge, but the host cannot touch the + * VFS once `kernelOwnedFs: true` is set in `boot.ts`. Every filesystem + * mutation has to round-trip through a spawned `coreutils.wasm` + * multicall binary inside the kernel — the same binary `vfs-builder.ts` + * symlinks under `/bin/{cat,mkdir,rm,mv,ls,test,tee,...}`. + * + * Wrapping every kernel.spawn invocation behind a method named for the + * underlying intent keeps `php-api.ts` readable: it calls + * `adapter.writeFile(path, data)` rather than constructing argv and + * juggling stdin/stdout buffers inline. The CLI's `KernelLimitedPHPApi` + * sits on Node `fs`; the browser version sits on this adapter. Same + * shape, different storage. + * + * Why this lives in its own module: + * 1. `BrowserKernel.onStdout` / `onStderr` are constructor-time + * singletons — there is no per-pid routing. Coordinating with the + * capture hook from `boot.ts` is intricate enough that hiding it + * here is worth it. + * 2. Spawns are serialized through `inFlight` so a second + * `adapter.writeFile` waits for the first to finish before + * installing its capture. Concurrent capture would corrupt both + * results. + * 3. The wasm bytes for `coreutils` and `php` are heavy (megabytes); + * caching the `ArrayBuffer` slice on the adapter keeps every spawn + * from re-fetching them. + */ + +import type { BrowserKernel } from './host-bridge'; +import type { CaptureHandler } from './boot'; + +/** + * Result of a single captured spawn. `stdout` / `stderr` are the + * concatenated bytes the program emitted; `exitCode` is the kernel + * exit status as reported by `kernel.spawn`'s resolved promise. + */ +export interface SpawnResult { + exitCode: number; + stdout: Uint8Array; + stderr: Uint8Array; +} + +/** + * Options accepted by {@link KernelSpawnAdapter.runPhpCli} and the + * private `spawnCapturing` helper. Mirrors the subset of the CLI's + * `runtime.spawnCapturing` options reachable from blueprint v1's + * `run` step. + */ +export interface RunPhpOptions { + argv: string[]; + stdin?: Uint8Array; + env?: string[]; + cwd?: string; +} + +export interface KernelSpawnAdapterOptions { + kernel: BrowserKernel; + coreutilsBytes: ArrayBuffer; + phpWasmBytes: ArrayBuffer; + setCapture: (handler: CaptureHandler | null) => void; +} + +export class KernelSpawnAdapter { + private readonly kernel: BrowserKernel; + private readonly coreutilsBytes: ArrayBuffer; + private readonly phpWasmBytes: ArrayBuffer; + private readonly setCapture: (h: CaptureHandler | null) => void; + /** + * Serializes spawns. `BrowserKernel`'s `onStdout` / `onStderr` are + * constructor-time singletons, so two concurrent spawns would + * cross-contaminate captures. Every public method awaits this + * promise before installing its handler. + */ + private inFlight: Promise = Promise.resolve(); + + constructor(options: KernelSpawnAdapterOptions) { + this.kernel = options.kernel; + this.coreutilsBytes = options.coreutilsBytes; + this.phpWasmBytes = options.phpWasmBytes; + this.setCapture = options.setCapture; + } + + async writeFile(path: string, data: string | Uint8Array): Promise { + const bytes = + typeof data === 'string' ? new TextEncoder().encode(data) : data; + const result = await this.runCoreutils(['tee', path], { stdin: bytes }); + if (result.exitCode !== 0) { + throw new Error( + `writeFile(${path}) failed (exit=${result.exitCode}): ` + + decodeText(result.stderr) + ); + } + } + + async readFileAsBuffer(path: string): Promise { + const result = await this.runCoreutils(['cat', path]); + if (result.exitCode !== 0) { + throw new Error( + `readFileAsBuffer(${path}) failed (exit=${result.exitCode}): ` + + decodeText(result.stderr) + ); + } + return result.stdout; + } + + async readFileAsText(path: string): Promise { + return decodeText(await this.readFileAsBuffer(path)); + } + + /** `mkdir -p` — also satisfies `mkdirTree`. */ + async mkdir(path: string): Promise { + const result = await this.runCoreutils(['mkdir', '-p', path]); + if (result.exitCode !== 0) { + throw new Error( + `mkdir(${path}) failed (exit=${result.exitCode}): ` + + decodeText(result.stderr) + ); + } + } + + async unlink(path: string): Promise { + const result = await this.runCoreutils(['rm', '-f', path]); + if (result.exitCode !== 0) { + throw new Error( + `unlink(${path}) failed (exit=${result.exitCode}): ` + + decodeText(result.stderr) + ); + } + } + + async mv(fromPath: string, toPath: string): Promise { + const result = await this.runCoreutils(['mv', fromPath, toPath]); + if (result.exitCode !== 0) { + throw new Error( + `mv(${fromPath} → ${toPath}) failed (exit=${result.exitCode}): ` + + decodeText(result.stderr) + ); + } + } + + async rmdir(path: string, recursive: boolean): Promise { + const argv = recursive ? ['rm', '-rf', path] : ['rmdir', path]; + const result = await this.runCoreutils(argv); + if (result.exitCode !== 0) { + throw new Error( + `rmdir(${path}, recursive=${recursive}) failed ` + + `(exit=${result.exitCode}): ${decodeText(result.stderr)}` + ); + } + } + + /** + * `ls -1` returns one entry per line. Trailing newline is trimmed; + * empty lines (which only happen for an empty directory listing) + * are filtered out so the caller never sees `['']`. + */ + async listFiles(path: string): Promise { + const result = await this.runCoreutils(['ls', '-1', path]); + if (result.exitCode !== 0) { + throw new Error( + `listFiles(${path}) failed (exit=${result.exitCode}): ` + + decodeText(result.stderr) + ); + } + return decodeText(result.stdout) + .split('\n') + .filter((line) => line.length > 0); + } + + /** `test -d` returns 0 if `path` is a directory, 1 otherwise. */ + async isDir(path: string): Promise { + const result = await this.runCoreutils(['test', '-d', path]); + return result.exitCode === 0; + } + + /** `test -e` returns 0 if `path` exists, 1 otherwise. */ + async fileExists(path: string): Promise { + const result = await this.runCoreutils(['test', '-e', path]); + return result.exitCode === 0; + } + + /** + * Spawn `php` inside the kernel and capture stdout/stderr/exit. The + * CLI's `KernelLimitedPHPApi.run()` calls `runtime.spawnCapturing` + * with the same shape; this is the browser equivalent. + */ + async runPhpCli(options: RunPhpOptions): Promise { + return this.spawnCapturing(this.phpWasmBytes, options); + } + + private async runCoreutils( + argv: string[], + options: { stdin?: Uint8Array } = {} + ): Promise { + return this.spawnCapturing(this.coreutilsBytes, { + argv, + stdin: options.stdin, + }); + } + + /** + * Core spawn-and-capture primitive. Installs a capture handler that + * accumulates stdout/stderr chunks until `kernel.spawn` resolves + * with the exit code, then uninstalls and returns the captured + * buffers. Serialized through `inFlight` because the capture slot + * is global. + * + * `programBytes` is sliced inside `BrowserKernel.spawn` (see + * `browser-kernel.ts:388`) so the same buffer can be reused for + * the next spawn — no need to slice here. + */ + private async spawnCapturing( + programBytes: ArrayBuffer, + options: RunPhpOptions + ): Promise { + const previous = this.inFlight; + let release: () => void = () => { + /* replaced below */ + }; + this.inFlight = new Promise((resolve) => { + release = resolve; + }); + try { + await previous.catch(() => { + /* prior spawn's failure shouldn't poison the queue */ + }); + + const stdoutChunks: Uint8Array[] = []; + const stderrChunks: Uint8Array[] = []; + this.setCapture((chunk, stream) => { + if (stream === 'stdout') { + stdoutChunks.push(chunk); + } else { + stderrChunks.push(chunk); + } + }); + try { + const exitCode = await this.kernel.spawn( + programBytes, + options.argv, + { + env: options.env, + cwd: options.cwd, + stdin: options.stdin, + } + ); + return { + exitCode, + stdout: concatChunks(stdoutChunks), + stderr: concatChunks(stderrChunks), + }; + } finally { + this.setCapture(null); + } + } finally { + release(); + } + } +} + +function decodeText(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +function concatChunks(chunks: Uint8Array[]): Uint8Array { + let total = 0; + for (const chunk of chunks) { + total += chunk.length; + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} diff --git a/packages/playground/remote/src/lib/posix-kernel/php-api.ts b/packages/playground/remote/src/lib/posix-kernel/php-api.ts new file mode 100644 index 00000000000..bd3b2cb717d --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/php-api.ts @@ -0,0 +1,463 @@ +/** + * `LimitedPHPApi`-shaped surface against the kernel-resident WordPress + * in the browser. Mirror of the CLI's + * `packages/playground/cli/src/posix-kernel/php-api.ts` — same public + * methods, same `defineConstant` mu-plugin scheme, same cookie jar. + * Storage is the difference: + * + * - CLI: Node `fs` + `runtime.spawnCapturing(php.wasm, …)`. + * - Browser: `KernelSpawnAdapter` (coreutils-spawn for FS ops, + * `php.wasm` spawn for `run()`) + the `HttpBridgeHost` request + * sender for `request()`. + * + * The browser methods are async because every spawn is a round-trip + * through `kernel.spawn`. Comlink already wraps the consumer-facing + * Promise either way, so callers (blueprint v1's step runners) see no + * difference between the CLI's sync methods and our async ones. + */ + +import type { + ListFilesOptions, + PHPRequest, + PHPRunOptions, + RmDirOptions, +} from '@php-wasm/universal'; +import { PHPResponse } from '@php-wasm/universal'; +import { removeURLScope } from '@php-wasm/scopes'; +import { dirname, joinPaths } from '@php-wasm/util'; + +import type { HttpRequest, HttpResponse } from './host-bridge'; +import type { KernelSpawnAdapter } from './kernel-spawn-adapter'; + +import DEFINES_MU_PLUGIN_PHP from './wp-templates/playground-defines.php?raw'; + +/** + * Where WordPress lives inside the kernel VFS — set by + * `vfs-builder.ts`'s `extractZipInto(fs, '/var/www/html', wpZip, …)`. + * Blueprint v1 step source typically embeds the literal `/wordpress` + * (carried over from classic Playground), so {@link translateVfsPathsInCode} + * rewrites those occurrences to this constant before the code is fed + * to `php -r`. + */ +const VFS_DOCUMENT_ROOT = '/var/www/html'; + +/** + * The classic Playground convention: blueprint v1 step source addresses + * the WordPress root as `/wordpress`. We rewrite to {@link VFS_DOCUMENT_ROOT} + * only when the literal sits on a fresh path token (lookbehind guards + * against double-rewriting paths already terminated by `/wordpress`). + * Same pattern as the CLI's `php-api.ts`. + */ +const VFS_DOCROOT_IN_CODE = /(? Promise; +} + +export class KernelLimitedPHPApi { + readonly absoluteUrl: string; + private readonly adapter: KernelSpawnAdapter; + private readonly sendRequest: ( + request: HttpRequest + ) => Promise; + private readonly cookieJar = new Map(); + private readonly constants = new Map< + string, + string | number | boolean | null + >(); + private readonly definesPluginPath = joinPaths( + VFS_DOCUMENT_ROOT, + 'wp-content/mu-plugins/0-playground-defines.php' + ); + private readonly definesStorePath = joinPaths( + VFS_DOCUMENT_ROOT, + 'wp-content/mu-plugins/0-playground-defines.json' + ); + + constructor(options: KernelLimitedPHPApiOptions) { + this.absoluteUrl = options.absoluteUrl; + this.adapter = options.adapter; + this.sendRequest = options.sendRequest; + } + + async mkdir(path: string): Promise { + await this.adapter.mkdir(this.toVfs(path)); + } + + /** + * Identical to {@link mkdir} — `coreutils mkdir -p` is already + * recursive and no-fail on existing dirs. Mirrors the CLI shim, + * which collapses `mkdir` / `mkdirTree` to the same `mkdirSync` + * with `recursive: true`. + */ + async mkdirTree(path: string): Promise { + await this.adapter.mkdir(this.toVfs(path)); + } + + async readFileAsText(path: string): Promise { + return this.adapter.readFileAsText(this.toVfs(path)); + } + + async readFileAsBuffer(path: string): Promise { + return this.adapter.readFileAsBuffer(this.toVfs(path)); + } + + async writeFile(path: string, data: string | Uint8Array): Promise { + const target = this.toVfs(path); + // `tee` doesn't create parent dirs; blueprint steps frequently + // write into freshly-created paths. Match the CLI shim's + // implicit `mkdir -p` before `writeFileSync`. + await this.adapter.mkdir(dirname(target)); + await this.adapter.writeFile(target, data); + } + + async unlink(path: string): Promise { + await this.adapter.unlink(this.toVfs(path)); + } + + async mv(fromPath: string, toPath: string): Promise { + await this.adapter.mv(this.toVfs(fromPath), this.toVfs(toPath)); + } + + async rmdir(path: string, options?: RmDirOptions): Promise { + const recursive = options?.recursive !== false; + await this.adapter.rmdir(this.toVfs(path), recursive); + } + + async listFiles( + path: string, + options?: ListFilesOptions + ): Promise { + const entries = await this.adapter.listFiles(this.toVfs(path)); + if (options?.prependPath) { + return entries.map((name) => joinPaths(path, name)); + } + return entries; + } + + async isDir(path: string): Promise { + return this.adapter.isDir(this.toVfs(path)); + } + + async fileExists(path: string): Promise { + return this.adapter.fileExists(this.toVfs(path)); + } + + /** + * No-op. Blueprint v1 callers expect `chdir` to be present but + * kernel-resident processes each get their own cwd through + * {@link run}'s `options.cwd`. The CLI shim doesn't implement it + * either; matching that omission keeps the surfaces aligned. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async chdir(_path: string): Promise { + /* intentionally empty — see docstring */ + } + + async defineConstant( + key: string, + value: string | number | boolean | null + ): Promise { + this.constants.set(key, value); + await this.regenerateDefinesPlugin(); + } + + async run(request: PHPRunOptions): Promise { + const env = this.buildEnv(request.env); + let argv: string[]; + let stdin: Uint8Array | undefined; + + if (request.code !== undefined) { + let code = request.code; + if (code.startsWith(' { + // Blueprint steps build URLs by appending paths to + // `this.absoluteUrl`, which is the scoped iframe URL. The + // kernel-resident nginx has no notion of scopes, so strip + // `/scope:/` before forwarding — same logic as the worker + // endpoint's `requestStreamed`. + const resolved = new URL(request.url, this.absoluteUrl); + const unscoped = removeURLScope(resolved).toString(); + + const headers = flattenAndLowercase(request.headers); + const cookieHeader = this.serializeCookies(); + if (cookieHeader && !('cookie' in headers)) { + headers['cookie'] = cookieHeader; + } + + const body = encodeBody(request.body); + + const bridgeResponse = await this.sendRequest({ + method: request.method ?? 'GET', + url: unscoped, + headers, + body, + }); + + // `set-cookie` arrives as a single comma-joined string from the + // bridge (HttpResponse uses `Record`). Split it + // the same way Node 24's `Headers.getSetCookie()` does so each + // Set-Cookie ends up in its own jar entry. + const setCookies = splitSetCookieHeader( + bridgeResponse.headers['set-cookie'] ?? + bridgeResponse.headers['Set-Cookie'] + ); + for (const raw of setCookies) { + this.ingestSetCookie(raw); + } + + const responseHeaders: Record = {}; + for (const [name, value] of Object.entries(bridgeResponse.headers)) { + const key = name.toLowerCase(); + if (key === 'set-cookie' && setCookies.length > 0) { + continue; // populated below + } + if (!(key in responseHeaders)) { + responseHeaders[key] = []; + } + responseHeaders[key].push(value); + } + if (setCookies.length > 0) { + responseHeaders['set-cookie'] = setCookies; + } + + return new PHPResponse( + bridgeResponse.status, + responseHeaders, + bridgeResponse.body, + '', + 0 + ); + } + + private async regenerateDefinesPlugin(): Promise { + const entries: Record = {}; + for (const [k, v] of this.constants.entries()) { + entries[k] = v; + } + await this.adapter.mkdir(dirname(this.definesPluginPath)); + await this.adapter.writeFile( + this.definesStorePath, + JSON.stringify(entries, null, 2) + ); + await this.adapter.writeFile( + this.definesPluginPath, + DEFINES_MU_PLUGIN_PHP + ); + } + + /** + * Prepend `define(...)` calls for every tracked constant to PHP + * code passed to `run()`. Without this, blueprint steps that read + * a constant set earlier (e.g. `WP_AUTO_LOGIN_USER` from `login`) + * see an undefined symbol — `php -r` spawns a fresh process every + * time and the mu-plugin only runs for HTTP requests through + * php-fpm, not for one-off CLI invocations. + */ + private serializeConstantsForEval(): string { + if (this.constants.size === 0) { + return ''; + } + const lines: string[] = []; + for (const [k, v] of this.constants.entries()) { + lines.push( + `if (!defined(${phpString(k)})) { define(${phpString( + k + )}, ${phpLiteral(v)}); }` + ); + } + return lines.join('\n') + '\n'; + } + + private buildEnv(extra?: Record): string[] { + const env: Record = { + HOME: '/tmp', + PATH: '/usr/local/bin:/usr/bin:/bin', + DOCROOT: VFS_DOCUMENT_ROOT, + }; + if (extra) { + for (const [k, v] of Object.entries(extra)) { + env[k] = this.translateVfsPathsInCode(v); + } + } + return Object.entries(env).map(([k, v]) => `${k}=${v}`); + } + + private translateVfsPathsInCode(code: string): string { + return code.replace(VFS_DOCROOT_IN_CODE, VFS_DOCUMENT_ROOT); + } + + private serializeCookies(): string { + if (this.cookieJar.size === 0) { + return ''; + } + return Array.from(this.cookieJar.entries()) + .map(([k, v]) => `${k}=${v}`) + .join('; '); + } + + private ingestSetCookie(raw: string): void { + const segments = raw.split(';'); + const first = segments[0]?.trim(); + if (!first) { + return; + } + const eq = first.indexOf('='); + if (eq === -1) { + return; + } + const name = first.slice(0, eq).trim(); + const value = first.slice(eq + 1).trim(); + const isExpired = segments.slice(1).some((seg) => { + const trimmed = seg.trim().toLowerCase(); + if (trimmed.startsWith('max-age=')) { + const n = Number(trimmed.slice('max-age='.length)); + return Number.isFinite(n) && n <= 0; + } + if (trimmed.startsWith('expires=')) { + const d = Date.parse(trimmed.slice('expires='.length)); + return Number.isFinite(d) && d <= Date.now(); + } + return false; + }); + if (isExpired) { + this.cookieJar.delete(name); + } else { + this.cookieJar.set(name, value); + } + } + + /** + * Translate from the classic Playground convention (`/wordpress`) + * to the kernel VFS docroot (`/var/www/html`). Paths that don't + * begin with `/wordpress` pass through unchanged so absolute VFS + * paths supplied by blueprint steps still work. + */ + private toVfs(path: string): string { + if (path === '/wordpress') { + return VFS_DOCUMENT_ROOT; + } + if (path.startsWith('/wordpress/')) { + return joinPaths( + VFS_DOCUMENT_ROOT, + path.slice('/wordpress'.length) + ); + } + return path; + } +} + +function flattenAndLowercase( + headers: Record | undefined +): Record { + if (!headers) { + return {}; + } + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + out[k.toLowerCase()] = v; + } + return out; +} + +/** + * Coerce `PHPRequest.body` into the bridge's `Uint8Array | null` shape. + * Mirrors `encodeRequestBody` in `playground-worker-endpoint.ts`; the + * multipart object form (uploads from blueprints v1) is rejected the + * same way — first reachable from `installPlugin` which we'll wire when + * v1 starts running. + */ +function encodeBody(body: PHPRequest['body']): Uint8Array | null { + if (body === undefined) { + return null; + } + if (typeof body === 'string') { + return new TextEncoder().encode(body); + } + if (body instanceof Uint8Array) { + return body; + } + throw new Error( + 'KernelLimitedPHPApi.request: multipart `body` objects are not ' + + 'supported in the kernel-mode worker yet.' + ); +} + +/** + * The bridge collapses duplicate `Set-Cookie` headers into a single + * comma-joined string. Split on the comma that precedes a fresh cookie + * name to recover individual values. Matches the regex Node 24's + * `Headers.getSetCookie()` uses internally. + */ +function splitSetCookieHeader(value: string | undefined): string[] { + if (!value) { + return []; + } + return value.split(/,(?=\s*[A-Za-z0-9!#$%&'*+\-.^_`|~]+=)/); +} + +function phpString(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +function phpLiteral(value: string | number | boolean | null): string { + if (value === null) { + return 'null'; + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error( + `KernelLimitedPHPApi.defineConstant: cannot serialize ` + + `non-finite number ${value}.` + ); + } + return String(value); + } + return phpString(value); +} diff --git a/packages/playground/remote/src/lib/posix-kernel/playground-worker-endpoint.ts b/packages/playground/remote/src/lib/posix-kernel/playground-worker-endpoint.ts new file mode 100644 index 00000000000..4abc4da879e --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/playground-worker-endpoint.ts @@ -0,0 +1,675 @@ +/** + * Comlink worker endpoint for the `--experimental-posix-kernel` + * browser mode. Parallel to `playground-worker-endpoint-blueprints- + * v1.ts`, but the engine behind every method is the wasm-posix-kernel + * (nginx + php-fpm in a Web Worker) instead of the in-process + * `PHPRequestHandler` + `@php-wasm/web` runtime. + * + * Intentionally **does not** extend `PlaygroundWorkerEndpoint` / + * `PHPWorker`. Those base classes are wired to PHP-WASM constructs + * (`PHPRequestHandler`, `PHP`, `proxyFileSystem`, mount plumbing) the + * kernel does not use. We stand up a fresh class that implements only + * the API surface `boot-playground-remote.ts` (kernel-mode) consumes: + * `boot / requestStreamed / onDownloadProgress / onMessage / + * addEventListener / removeEventListener / absoluteUrl`. + * + * `isReady` and `isConnected` are NOT methods on this class — they are + * intercepted by `exposeAPI`'s internal proxy and resolve against + * promises wired to the `setApiReady` / `setApiError` callbacks + * returned from `exposeAPI`. `boot()` calls those callbacks once the + * kernel is up so the iframe-side `await kernelWorkerApi.isReady()` + * unblocks. + * + * `requestStreamed()` round-trips a `PHPRequest` through the kernel's + * MessagePort bridge into nginx, then wraps the bridge's + * `HttpResponse` back into a `StreamedPHPResponse` so the existing + * `streamToPort()` machinery in + * `boot-playground-remote.ts:requestStreamed` keeps working without + * changes. + * + * `boot()` orchestrates the full chain: + * + * 1. Download WordPress + SQLite zips via {@link prepareWordPressZips} + * (the {@link EmscriptenDownloadMonitor} feeds + * `onDownloadProgress`). + * 2. Build an in-memory VFS image with {@link buildVfsImage}. + * 3. Fetch `kernel.wasm` (resolved through the `@kernel-wasm` alias). + * 4. Boot the kernel via {@link bootKernelWordPress}. + * 5. Drive `wp-admin/install.php` once if WordPress isn't installed + * yet (port of the CLI's `ensureWordPressInstalled`). + */ +import { EmscriptenDownloadMonitor } from '@php-wasm/progress'; +import { exposeAPI } from '@php-wasm/web'; +import { + PHPResponse, + StreamedPHPResponse, + type PHPRequest, +} from '@php-wasm/universal'; +import { removeURLScope, setURLScope } from '@php-wasm/scopes'; +import { logger } from '@php-wasm/logger'; +import type { MessageListener } from '@php-wasm/universal'; + +import { bootKernelWordPress, type KernelBootResult } from './boot'; +import { prepareWordPressZips } from './prepare-wordpress'; +import { buildVfsImage } from './vfs-builder'; +import { KernelSpawnAdapter } from './kernel-spawn-adapter'; +import { KernelLimitedPHPApi } from './php-api'; +import type { HttpRequest, HttpResponse } from './host-bridge'; + +/* @ts-ignore */ +import { corsProxyUrl as defaultCorsProxyUrl } from 'virtual:cors-proxy-url'; +import kernelWasmUrl from '@kernel-wasm?url'; +import coreutilsUrl from '@kernel-binary/programs/wasm32/coreutils.wasm?url'; +import phpWasmUrl from '@kernel-binary/programs/wasm32/php/php.wasm?url'; + +import { wordPressSiteUrl } from '../config'; + +// Tell the iframe-side boot we're alive even before Comlink is wired. +// Mirrors the classic worker's first line — `boot-playground-remote.ts` +// blocks on this message in `spawnPHPWorkerThread`. +self.postMessage('worker-script-started'); + +const downloadMonitor = new EmscriptenDownloadMonitor(); + +/** + * Methods on `KernelLimitedPHPApi` that the endpoint forwards to over + * Comlink. The list is derived once and used for both the pre-boot + * stubs (which throw a clear error) and the post-boot rebinding (which + * routes the call into the concrete `KernelLimitedPHPApi` instance). + * + * Mirrors the `LimitedPHPApi` type at + * `packages/php-wasm/universal/src/lib/php-worker.ts:22-49`. If a + * method gets added to that surface, it has to be added here too — the + * TypeScript `keyof` guard makes the omission a compile error rather + * than a runtime "Cannot read 'apply' of undefined" surprise. + */ +const LIMITED_PHP_API_METHODS = [ + 'mkdir', + 'mkdirTree', + 'readFileAsText', + 'readFileAsBuffer', + 'writeFile', + 'unlink', + 'mv', + 'rmdir', + 'listFiles', + 'isDir', + 'fileExists', + 'chdir', + 'defineConstant', + 'run', + 'request', +] as const satisfies ReadonlyArray; + +type LimitedPHPApiMethod = (typeof LIMITED_PHP_API_METHODS)[number]; + +/** + * Boot options accepted by the kernel-mode worker. We only consume a + * subset of the classic `WorkerBootOptions` because the kernel-mode + * handler doesn't take a PHP version, mounts, blueprints or + * networking flags yet — the first cut stands up a default WordPress + * install. + */ +export interface KernelWorkerBootOptions { + scope: string; + /** Forwarded to {@link resolveWordPressRelease}. Default `'latest'`. */ + wpVersion?: string; + /** Pinned to `'v2.1.16'` in `prepareWordPressZips` if omitted. */ + sqliteDriverVersion?: 'trunk' | 'v2.1.16' | 'v3.0.0-rc.3-php52'; + /** Optional override for the build-time CORS proxy URL. */ + corsProxyUrl?: string; +} + +/** + * Kernel-mode Comlink endpoint. Public surface matches what + * `boot-playground-remote.ts` (kernel-mode) destructures from the + * remote proxy. `isReady` / `isConnected` are intercepted by + * `exposeAPI` and are NOT methods on this class — see the file + * preamble for details. + */ +export class KernelPlaygroundWorkerEndpoint { + /** + * Set during {@link boot} after `setURLScope(wordPressSiteUrl, + * scope)`. Read across the Comlink boundary by + * `boot-playground-remote.ts:setupPostMessageRelay` and + * `getOrigin(absoluteUrl)`. + */ + absoluteUrl: string = wordPressSiteUrl; + /** + * Where the kernel-resident WordPress installation lives in the + * VFS. Read across the Comlink boundary by blueprint steps that + * derive paths from `playground.documentRoot` (e.g. + * `activate-plugin`, `install-asset`). Same value as the + * `VFS_DOCUMENT_ROOT` constant in `php-api.ts`. + */ + documentRoot = '/var/www/html'; + + private booted = false; + private kernel: KernelBootResult | undefined; + private readonly downloadMonitor: EmscriptenDownloadMonitor; + + constructor(monitor: EmscriptenDownloadMonitor) { + this.downloadMonitor = monitor; + this.installPreBootApiStubs(); + } + + /** + * Boot the kernel. Calling twice is a programming error — the v1 + * worker raises the same way (see + * `playground-worker-endpoint-blueprints-v1.ts`). Resolves the + * `setApiReady` callback so `kernelWorkerApi.isReady()` unblocks + * on the iframe side. + */ + async boot(options: KernelWorkerBootOptions): Promise { + if (this.booted) { + throw new Error('KernelPlaygroundWorkerEndpoint: already booted'); + } + this.booted = true; + try { + await this.doBoot(options); + setApiReady(); + } catch (e) { + setApiError(e as Error); + throw e; + } + } + + /** + * Forward a `PHPRequest` to the kernel-resident nginx and wrap the + * bridge's buffered `HttpResponse` as a streaming PHP response. + * + * Scope stripping: the service worker rewrites every URL to + * `/scope:/…`; kernel-mode nginx doesn't know about scopes, so + * we strip the prefix at the bridge boundary before forwarding. + */ + async requestStreamed(request: PHPRequest): Promise { + if (!this.kernel) { + throw new Error( + 'KernelPlaygroundWorkerEndpoint.requestStreamed: kernel is not booted.' + ); + } + + // Two URL transforms before the request hits the bridge: + // 1. Strip the scope (`/scope:xxx/…`) so kernel-resident + // nginx, which knows nothing about scopes, sees the + // naked path. + // 2. Reduce to **origin-form** (path + query + fragment). + // `buildRawHttpRequest` in + // `wasm-posix-kernel/examples/browser/lib/ + // kernel-worker-entry.ts:1130` writes the URL verbatim + // onto the request line: `GET HTTP/1.1`. Passing + // a full `http://127.0.0.1:5400/...` (absolute-URI form) + // sends nginx into a non-responsive state — `req#9` + // logged START but never DONE, and the service worker + // timed out at 30s. The `install.php` POST worked + // precisely because `ensureWordPressInstalled` already + // passes an origin-form path. + const unscopedUrl = removeURLScope( + new URL(request.url, this.absoluteUrl) + ); + const originForm = `${unscopedUrl.pathname}${unscopedUrl.search}${unscopedUrl.hash}`; + + // Tell WP what URL it's actually being served at. `absoluteUrl` + // is the scoped origin (`http://127.0.0.1:5400/scope:xxx`); the + // wp-config template reads this header into `WP_HOME` and + // `WP_SITEURL` so every absolute link/asset WP renders points + // back through the service-worker scope. Without it, WP_HOME + // falls back to `http://${HTTP_HOST}/app`, and the iframe loads + // HTML whose /