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 /