From 993af91d650b19a03caa80b49d635e27fa6e7fd3 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 13 May 2026 17:13:36 +0200 Subject: [PATCH 1/8] Experimental posix-kernel (web): nginx + PHP-FPM VFS image Build the SAB-backed VFS image consumed by the kernel worker: binaries, /etc files, nginx + php-fpm configs, fpm-router, wp-config.php, wasm-optimizations mu-plugin, dinit service tree, streaming zip extraction. Co-locates host-bridge re-exports from the wasm-posix-kernel submodule and the playground-defines.php mu-plugin that backs KernelLimitedPHPApi.defineConstant. --- .../src/lib/posix-kernel/host-bridge.ts | 33 + .../src/lib/posix-kernel/vfs-builder.ts | 828 ++++++++++++++++++ .../wp-templates/playground-defines.php | 21 + 3 files changed, 882 insertions(+) create mode 100644 packages/playground/remote/src/lib/posix-kernel/host-bridge.ts create mode 100644 packages/playground/remote/src/lib/posix-kernel/vfs-builder.ts create mode 100644 packages/playground/remote/src/lib/posix-kernel/wp-templates/playground-defines.php 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..75c62ee900b --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/host-bridge.ts @@ -0,0 +1,33 @@ +/** + * 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`. + */ + +// eslint-disable-next-line @nx/enforce-module-boundaries +export { BrowserKernel } from '../../../../../../wasm-posix-kernel/examples/browser/lib/browser-kernel'; + +// eslint-disable-next-line @nx/enforce-module-boundaries +export { HttpBridgeHost } from '../../../../../../wasm-posix-kernel/examples/browser/lib/http-bridge'; +// eslint-disable-next-line @nx/enforce-module-boundaries +export type { + HttpRequest, + HttpResponse, +} from '../../../../../../wasm-posix-kernel/examples/browser/lib/http-bridge'; + +// eslint-disable-next-line @nx/enforce-module-boundaries +export { MemoryFileSystem } from '../../../../../../wasm-posix-kernel/host/src/vfs/memory-fs'; diff --git a/packages/playground/remote/src/lib/posix-kernel/vfs-builder.ts b/packages/playground/remote/src/lib/posix-kernel/vfs-builder.ts new file mode 100644 index 00000000000..34978f77f34 --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/vfs-builder.ts @@ -0,0 +1,828 @@ +/** + * Build a fully-bootable VFS image for the browser `--experimental-posix- + * kernel` mode. + * + * Browser port of `wasm-posix-kernel/examples/browser/scripts/build-wp- + * vfs-image.ts`. The Node script reads binaries from disk with + * `readFileSync` and walks a pre-extracted WordPress checkout with + * `walkAndWrite`. In the browser we: + * + * - Pull every wasm binary over `fetch()` using Vite's `?url` imports + * wired up by `resolveKernelBinariesPlugin` in + * `vite.posix-kernel.config.ts`. + * - Stream-decode the WordPress + SQLite-integration zips with + * `@php-wasm/stream-compression`'s `decodeZip()` (same helper the + * CLI's `prepare-wordpress.ts` uses) and write each entry into the + * in-memory VFS directly — no Node FS intermediary. + * + * dinit (PID 1) starts `php-fpm` → `nginx`. nginx binds to + * `127.0.0.1:8080`; `HttpBridgeHost` is wired against the same port in + * `boot.ts`. wp-config.php is materialized at VFS build time (every + * request carries `x-playground-absolute-url`, so the wp-config + * template needs no runtime substitution). + * + * Output: bytes from `MemoryFileSystem.saveImage()` — pass straight to + * `BrowserKernel.boot({ vfsImage })`. + */ + +import { decodeZip } from '@php-wasm/stream-compression'; +import { dirname, joinPaths } from '@php-wasm/util'; +import { MemoryFileSystem } from './host-bridge'; + +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + writeVfsFile, + writeVfsBinary, + ensureDir, + ensureDirRecursive, + symlink, +} from '../../../../../../wasm-posix-kernel/host/src/vfs/image-helpers'; + +// `?url` imports resolved by `resolveKernelBinariesPlugin` in +// `vite.posix-kernel.config.ts`. The plugin walks +// `/local-binaries/` first, then `binaries/`. +// Binaries must be present at one of those paths for `npm run +// dev:experimental-posix-kernel` to start. +import nginxUrl from '@kernel-binary/programs/wasm32/nginx.wasm?url'; +import phpFpmUrl from '@kernel-binary/programs/wasm32/php/php-fpm.wasm?url'; +import dashUrl from '@kernel-binary/programs/wasm32/dash.wasm?url'; +import coreutilsUrl from '@kernel-binary/programs/wasm32/coreutils.wasm?url'; +import dinitUrl from '@kernel-binary/programs/wasm32/dinit/dinit.wasm?url'; +import dinitctlUrl from '@kernel-binary/programs/wasm32/dinit/dinitctl.wasm?url'; + +/** + * Initial / max sizes for the SharedArrayBuffer that backs the VFS. + * WordPress core + SQLite drop-in totals ~80 MiB on disk; 128 MiB + * initial / 256 MiB ceiling matches the demo's working sizes. The + * worker-side kernel can grow the SAB up to 1 GiB at runtime for + * larger workloads. + */ +const VFS_INITIAL_BYTES = 128 * 1024 * 1024; +const VFS_MAX_BYTES = 256 * 1024 * 1024; + +export interface BuildVfsImageOptions { + /** WordPress core zip (e.g. wordpress-6.x.zip downloaded from wp.org). */ + wpZipBytes: Uint8Array; + /** sqlite-database-integration zip (e.g. v2.1.16). */ + sqliteZipBytes: Uint8Array; + /** Status callback (download progress, populate steps). */ + onStatus?: (message: string) => void; +} + +export async function buildVfsImage( + options: BuildVfsImageOptions +): Promise { + const onStatus = options.onStatus ?? (() => undefined); + + const sab = new SharedArrayBuffer(VFS_INITIAL_BYTES, { + maxByteLength: VFS_MAX_BYTES, + }); + const fs = MemoryFileSystem.create(sab, VFS_MAX_BYTES); + + onStatus('Populating system directories and configs'); + populateSystem(fs); + await populateServerBinaries(fs); + populateShellSymlinks(fs); + populateNginxConfig(fs); + populatePhpFpmConfig(fs); + + onStatus('Writing wp-config.php'); + ensureDirRecursive(fs, '/var/www/html'); + writeVfsFile(fs, '/var/www/html/wp-config.php', WP_CONFIG_PHP); + + ensureDirRecursive(fs, '/var/www/html/wp-content/database'); + ensureDirRecursive(fs, '/var/www/html/wp-content/mu-plugins'); + writeVfsFile( + fs, + '/var/www/html/wp-content/mu-plugins/wasm-optimizations.php', + WASM_OPTIMIZATIONS_MU_PLUGIN + ); + + onStatus('Extracting WordPress core into VFS'); + await extractZipIntoVfs(fs, '/var/www/html', options.wpZipBytes, { + stripLeadingDir: 'wordpress', + exclude: (rel) => rel.endsWith('.db'), + }); + + onStatus('Extracting SQLite plugin into VFS'); + const sqliteMountPrefix = + '/var/www/html/wp-content/plugins/sqlite-database-integration'; + let dbCopyBytes: Uint8Array | null = null; + await extractZipIntoVfs(fs, sqliteMountPrefix, options.sqliteZipBytes, { + stripLeadingDir: 'sqlite-database-integration', + exclude: (rel) => rel.endsWith('.db'), + onEntry: (relPath, bytes) => { + if (relPath === 'db.copy') { + dbCopyBytes = bytes; + } + }, + }); + if (dbCopyBytes) { + writeVfsBinary( + fs, + '/var/www/html/wp-content/db.php', + dbCopyBytes, + 0o644 + ); + } + + onStatus('Installing dinit + service tree'); + await addDinitInit(fs, buildServices()); + + onStatus('Serializing VFS image'); + return await fs.saveImage(); +} + +// --- System setup ----------------------------------------------------- + +/** + * Top-level directories + /etc baseline files. POSIX programs expect + * these layouts even if the values are never read by our daemons. + */ +function populateSystem(fs: MemoryFileSystem): void { + for (const dir of [ + '/tmp', + '/home', + '/dev', + '/etc', + '/bin', + '/usr', + '/usr/bin', + '/usr/local', + '/usr/local/bin', + '/usr/share', + '/usr/share/misc', + '/usr/share/file', + '/root', + '/usr/sbin', + '/var', + '/var/log', + '/var/www', + ]) { + ensureDir(fs, dir); + } + fs.chmod('/tmp', 0o777); + + writeVfsFile(fs, '/etc/services', ETC_SERVICES); +} + +/** + * Fetch every wasm binary in parallel and write into the VFS at the + * paths the dinit service tree expects. Symlinks for shell utilities + * are added in {@link populateShellSymlinks}; here we only place the + * underlying multicall binary at each canonical path. + */ +async function populateServerBinaries(fs: MemoryFileSystem): Promise { + const [dashBytes, nginxBytes, phpFpmBytes, coreutilsBytes] = + await Promise.all([ + fetchBinary(dashUrl), + fetchBinary(nginxUrl), + fetchBinary(phpFpmUrl), + fetchBinary(coreutilsUrl), + ]); + + writeVfsBinary(fs, '/bin/dash', dashBytes); + symlink(fs, '/bin/dash', '/bin/sh'); + symlink(fs, '/bin/dash', '/usr/bin/dash'); + symlink(fs, '/bin/dash', '/usr/bin/sh'); + + writeVfsBinary(fs, '/usr/sbin/nginx', nginxBytes); + writeVfsBinary(fs, '/usr/sbin/php-fpm', phpFpmBytes); + writeVfsBinary(fs, '/bin/coreutils', coreutilsBytes); +} + +/** + * Per-utility symlinks pointing at the coreutils multicall binary, + * plus grep aliases. Matches the demo's `populateShellSymlinks`. + */ +function populateShellSymlinks(fs: MemoryFileSystem): void { + for (const name of [...COREUTILS_NAMES, '[']) { + symlink(fs, '/bin/coreutils', `/bin/${name}`); + symlink(fs, '/bin/coreutils', `/usr/bin/${name}`); + } + + symlink(fs, '/usr/bin/grep', '/bin/grep'); + symlink(fs, '/usr/bin/grep', '/usr/bin/egrep'); + symlink(fs, '/usr/bin/grep', '/bin/egrep'); + symlink(fs, '/usr/bin/grep', '/usr/bin/fgrep'); + symlink(fs, '/usr/bin/grep', '/bin/fgrep'); +} + +/** + * nginx config — verbatim copy of the demo's `populateNginxConfig`. + * Listens on `127.0.0.1:8080` (the port `HttpBridgeHost` connects to). + * Static-asset directories under wp-includes/wp-admin/wp-content are + * served by nginx directly; everything else routes through the FPM + * front controller at `/var/www/fpm-router.php`. + */ +function populateNginxConfig(fs: MemoryFileSystem): void { + for (const dir of [ + '/etc/nginx', + '/var/www/html', + '/var/log/nginx', + '/tmp/nginx_client_temp', + ]) { + ensureDirRecursive(fs, dir); + } + + writeVfsFile(fs, '/etc/nginx/nginx.conf', NGINX_CONF); +} + +/** + * php-fpm pool config + FPM front controller. The controller mirrors + * the demo's: serve static files directly, resolve directory URLs to + * `index.php`, otherwise fall back to `index.php` (front-controller). + */ +function populatePhpFpmConfig(fs: MemoryFileSystem): void { + ensureDirRecursive(fs, '/etc/php-fpm.d'); + ensureDirRecursive(fs, '/var/log'); + ensureDirRecursive(fs, '/tmp/nginx_fastcgi_temp'); + ensureDirRecursive(fs, '/var/www'); + + writeVfsFile(fs, '/etc/php-fpm.conf', PHP_FPM_CONF); + writeVfsFile(fs, '/var/www/fpm-router.php', FPM_ROUTER_PHP); +} + +// --- dinit init system ----------------------------------------------- + +interface DinitService { + name: string; + type?: 'process' | 'scripted' | 'internal'; + command?: string; + dependsOn?: string[]; + restart?: boolean; + logfile?: string; +} + +/** + * Boot order: php-fpm → nginx. wp-config.php is materialized at VFS + * build time, so there's no runtime substitution step. + */ +function buildServices(): DinitService[] { + return [ + { + name: 'php-fpm', + type: 'process', + // -c /dev/null suppresses default php.ini lookup (which lands + // on /usr/local/lib/php/php.ini-development by default and + // trips unsupported-config errors on the wasm port). + command: + '/usr/sbin/php-fpm -y /etc/php-fpm.conf -c /dev/null --nodaemonize', + logfile: '/var/log/php-fpm.log', + restart: false, + }, + { + name: 'nginx', + type: 'process', + command: '/usr/sbin/nginx -c /etc/nginx/nginx.conf', + dependsOn: ['php-fpm'], + logfile: '/var/log/nginx.log', + restart: false, + }, + ]; +} + +/** + * Browser port of `dinit-image-helpers.ts:addDinitInit`. The Node + * helper reads dinit/dinitctl off disk via `readFileSync`; here we + * fetch through the same `?url` indirection used for the server + * binaries. The rest (passwd/group/hosts baseline, /etc/dinit.d/boot + * implicit service, per-service files) is straight from the helper. + */ +async function addDinitInit( + fs: MemoryFileSystem, + services: DinitService[] +): Promise { + ensureDirRecursive(fs, '/sbin'); + const [dinitBytes, dinitctlBytes] = await Promise.all([ + fetchBinary(dinitUrl), + fetchBinary(dinitctlUrl), + ]); + writeVfsBinary(fs, '/sbin/dinit', dinitBytes); + writeVfsBinary(fs, '/sbin/dinitctl', dinitctlBytes); + + ensureDirRecursive(fs, '/etc'); + writeVfsFile(fs, '/etc/passwd', ETC_PASSWD); + writeVfsFile(fs, '/etc/group', ETC_GROUP); + writeVfsFile(fs, '/etc/hosts', ETC_HOSTS); + + ensureDirRecursive(fs, '/var/log'); + fs.chmod('/var/log', 0o755); + ensureDirRecursive(fs, '/run'); + fs.chmod('/run', 0o755); + + ensureDirRecursive(fs, '/etc/dinit.d'); + + // Implicit `boot` service that depends on every supplied service. + // Matches the demo's default — `argv=['/sbin/dinit', '--container', + // ...]` in `boot.ts` boots the whole tree. + const boot: DinitService = { + name: 'boot', + type: 'internal', + dependsOn: services.map((s) => s.name), + }; + writeVfsFile(fs, '/etc/dinit.d/boot', renderDinitService(boot)); + for (const svc of services) { + writeVfsFile(fs, `/etc/dinit.d/${svc.name}`, renderDinitService(svc)); + } +} + +function renderDinitService(svc: DinitService): string { + const lines: string[] = []; + lines.push(`type = ${svc.type ?? 'process'}`); + if (svc.command) lines.push(`command = ${svc.command}`); + for (const dep of svc.dependsOn ?? []) lines.push(`depends-on = ${dep}`); + // dinit defaults `restart` to ON_FAILURE — always emit explicitly so + // a missing field doesn't silently flip into a restart loop. + lines.push(svc.restart ? 'restart = true' : 'restart = false'); + if (svc.logfile !== undefined) lines.push(`logfile = ${svc.logfile}`); + lines.push(''); + return lines.join('\n'); +} + +// --- Helpers --------------------------------------------------------- + +async function fetchBinary(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch kernel binary ${url}: HTTP ` + + `${response.status} ${response.statusText}` + ); + } + return new Uint8Array(await response.arrayBuffer()); +} + +interface ExtractZipOptions { + /** + * If set, only entries whose path starts with `/` + * (or `-/` for versioned plugin zips) are + * kept; the prefix is removed from every output path before + * mounting under `mountPrefix`. + */ + stripLeadingDir?: string; + /** Skip entries by relative path (post-strip). */ + exclude?: (relPath: string) => boolean; + /** Observe each materialized file (post-strip) — used to fish out + * db.copy from the SQLite zip without a second pass. */ + onEntry?: (relPath: string, bytes: Uint8Array) => void; +} + +/** + * Stream-decode a zip and write entries under `mountPrefix` in the + * VFS. Same shape as the CLI's `extractZipToDir` but writes through + * `MemoryFileSystem` helpers instead of `node:fs`. + */ +async function extractZipIntoVfs( + fs: MemoryFileSystem, + mountPrefix: string, + zipBytes: Uint8Array, + options: ExtractZipOptions = {} +): Promise { + // Use `Blob([bytes]).stream()` instead of a hand-rolled byte stream + // with a single pre-enqueued chunk. Chrome's `ReadableStream({type: + // 'bytes'})` with one large queued chunk does not drain reliably + // through `limitBytes`' BYOB reader — the body stream closes short + // and `DecompressionStream('gzip')` throws "Compressed input was + // truncated." `Blob.stream()` returns a natively-chunked byte stream + // that handles BYOB reads correctly. (CLI uses the manual byte-stream + // pattern successfully on Node because Node's implementation drains + // the queue differently.) + const stream = new Blob([zipBytes as BlobPart]).stream(); + const reader = decodeZip(stream).getReader(); + ensureDirRecursive(fs, mountPrefix); + + let entriesProcessed = 0; + let lastEntryName: string | null = null; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + lastEntryName = value.name; + + let relPath = value.name; + if (options.stripLeadingDir !== undefined) { + const stripped = stripLeadingDirPrefix( + relPath, + options.stripLeadingDir + ); + if (stripped === null) continue; + relPath = stripped; + } + if (relPath === '' || relPath === '/') continue; + if (options.exclude?.(relPath)) continue; + + const targetPath = joinPaths(mountPrefix, relPath); + if (value.type === 'directory') { + ensureDirRecursive(fs, targetPath); + continue; + } + ensureDirRecursive(fs, dirname(targetPath)); + const bytes = new Uint8Array(await value.arrayBuffer()); + writeVfsBinary(fs, targetPath, bytes, 0o644); + options.onEntry?.(relPath, bytes); + entriesProcessed += 1; + } + } catch (err) { + throw new Error( + `extractZipIntoVfs: failed after ${entriesProcessed} entries, ` + + `last entry name="${lastEntryName ?? ''}" — ` + + `${(err as Error).message}`, + { cause: err as Error } + ); + } +} + +function stripLeadingDirPrefix(path: string, dirName: string): string | null { + const exactPrefix = `${dirName}/`; + if (path === exactPrefix) return ''; + if (path.startsWith(exactPrefix)) return path.slice(exactPrefix.length); + const versionedPrefix = `${dirName}-`; + if (path.startsWith(versionedPrefix)) { + const slash = path.indexOf('/'); + if (slash > -1) return path.slice(slash + 1); + } + return null; +} + +// --- Inlined constants (mirror build-wp-vfs-image.ts) ----------------- + +/** + * GNU coreutils multicall command names (91 entries). Each becomes a + * symlink under /bin and /usr/bin pointing at `/bin/coreutils`. Kept + * inline rather than importing from + * `wasm-posix-kernel/examples/browser/lib/init/shell-binaries` so the + * dependency graph stays narrow (that module also pulls in BrowserKernel + * type-level — fine here, but inlining keeps the worker entry hermetic). + */ +const COREUTILS_NAMES = [ + 'arch', + 'b2sum', + 'base32', + 'base64', + 'basename', + 'basenc', + 'cat', + 'chcon', + 'chgrp', + 'chmod', + 'chown', + 'chroot', + 'cksum', + 'comm', + 'cp', + 'csplit', + 'cut', + 'date', + 'dd', + 'df', + 'dir', + 'dircolors', + 'dirname', + 'du', + 'echo', + 'env', + 'expand', + 'expr', + 'factor', + 'false', + 'fmt', + 'fold', + 'groups', + 'head', + 'hostid', + 'id', + 'install', + 'join', + 'link', + 'ln', + 'logname', + 'ls', + 'md5sum', + 'mkdir', + 'mkfifo', + 'mknod', + 'mktemp', + 'mv', + 'nice', + 'nl', + 'nohup', + 'nproc', + 'numfmt', + 'od', + 'paste', + 'pathchk', + 'pr', + 'printenv', + 'printf', + 'ptx', + 'pwd', + 'readlink', + 'realpath', + 'rm', + 'rmdir', + 'runcon', + 'seq', + 'sha1sum', + 'sha224sum', + 'sha256sum', + 'sha384sum', + 'sha512sum', + 'shred', + 'shuf', + 'sleep', + 'sort', + 'split', + 'stat', + 'stty', + 'sum', + 'sync', + 'tac', + 'tail', + 'tee', + 'test', + 'timeout', + 'touch', + 'tr', + 'true', + 'truncate', + 'tsort', + 'tty', + 'uname', + 'unexpand', + 'uniq', + 'unlink', + 'vdir', + 'wc', + 'whoami', + 'yes', +] as const; + +const ETC_SERVICES = + [ + 'tcpmux\t\t1/tcp', + 'echo\t\t7/tcp', + 'echo\t\t7/udp', + 'discard\t\t9/tcp\t\tsink null', + 'discard\t\t9/udp\t\tsink null', + 'ftp-data\t20/tcp', + 'ftp\t\t21/tcp', + 'ssh\t\t22/tcp', + 'telnet\t\t23/tcp', + 'smtp\t\t25/tcp\t\tmail', + 'domain\t\t53/tcp', + 'domain\t\t53/udp', + 'http\t\t80/tcp\t\twww', + 'pop3\t\t110/tcp\t\tpop-3', + 'nntp\t\t119/tcp\t\treadnews untp', + 'ntp\t\t123/udp', + 'imap\t\t143/tcp\t\timap2', + 'snmp\t\t161/udp', + 'https\t\t443/tcp', + 'imaps\t\t993/tcp', + 'pop3s\t\t995/tcp', + ].join('\n') + '\n'; + +const ETC_PASSWD = [ + 'root:x:0:0:root:/root:/bin/sh', + 'daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin', + 'nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin', + 'www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin', + 'redis:x:100:100:redis:/var/lib/redis:/usr/sbin/nologin', + 'mysql:x:101:101:mysql:/var/lib/mysql:/usr/sbin/nologin', + 'user:x:1000:1000:user:/home/user:/bin/sh', + '', +].join('\n'); + +const ETC_GROUP = [ + 'root:x:0:', + 'daemon:x:1:', + 'nogroup:x:65534:', + 'www-data:x:33:', + 'redis:x:100:', + 'mysql:x:101:', + 'user:x:1000:', + '', +].join('\n'); + +const ETC_HOSTS = ['127.0.0.1\tlocalhost', '::1\tlocalhost', ''].join('\n'); + +const NGINX_CONF = `user root; +daemon off; +master_process on; +worker_processes 2; +error_log stderr info; +pid /tmp/nginx.pid; + +events { + worker_connections 64; + use poll; +} + +http { + access_log /dev/stderr; + client_body_temp_path /tmp/nginx_client_temp; + + types { + text/html html htm; + text/css css; + text/javascript js; + application/json json; + image/png png; + image/svg+xml svg; + } + default_type application/octet-stream; + + server { + listen 8080; + server_name localhost; + root /var/www/html; + index index.html; + + # Static asset directories — served directly by nginx + location /wp-includes/css/ { } + location /wp-includes/js/ { } + location /wp-includes/fonts/ { } + location /wp-includes/images/ { } + location /wp-admin/css/ { } + location /wp-admin/js/ { } + location /wp-admin/images/ { } + location /wp-content/ { + try_files $uri @fpm; + } + + # Everything else through PHP-FPM (PHP pages, front controller) + location @fpm { + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME /var/www/fpm-router.php; + fastcgi_param DOCUMENT_ROOT $document_root; + fastcgi_param DOCUMENT_URI $document_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param SERVER_PROTOCOL $server_protocol; + fastcgi_param SERVER_PORT $server_port; + fastcgi_param SERVER_NAME $server_name; + fastcgi_param HTTP_HOST $http_host; + # nginx doesn't auto-forward arbitrary headers to fastcgi — + # only the params enumerated here reach PHP. The wp-config + # template reads HTTP_X_PLAYGROUND_ABSOLUTE_URL to derive + # WP_HOME / WP_SITEURL for the scoped iframe origin; without + # this line WP falls back to http://localhost/app and the + # iframe loads HTML pointing at a port nothing listens on. + fastcgi_param HTTP_X_PLAYGROUND_ABSOLUTE_URL $http_x_playground_absolute_url; + fastcgi_param REDIRECT_STATUS 200; + } + + location / { + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME /var/www/fpm-router.php; + fastcgi_param DOCUMENT_ROOT $document_root; + fastcgi_param DOCUMENT_URI $document_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param SERVER_PROTOCOL $server_protocol; + fastcgi_param SERVER_PORT $server_port; + fastcgi_param SERVER_NAME $server_name; + fastcgi_param HTTP_HOST $http_host; + fastcgi_param HTTP_X_PLAYGROUND_ABSOLUTE_URL $http_x_playground_absolute_url; + fastcgi_param REDIRECT_STATUS 200; + } + } +} +`; + +const PHP_FPM_CONF = `[global] +daemonize = no +error_log = /dev/stderr +log_level = notice + +[www] +user = nobody +group = nobody +listen = 127.0.0.1:9000 +pm = static +pm.max_children = 2 +clear_env = no +slowlog = /dev/null +request_slowlog_trace_depth = 0 +`; + +const FPM_ROUTER_PHP = ` 'text/css', + 'js' => 'text/javascript', + 'json' => 'application/json', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'eot' => 'application/vnd.ms-fontobject', + 'map' => 'application/json', + 'xml' => 'application/xml', + 'txt' => 'text/plain', +]; + +// Resolve directory URLs to index.php (e.g. /wp-admin/ -> /wp-admin/index.php) +if (is_dir($file)) { + $idx = rtrim($file, '/') . '/index.php'; + if (is_file($idx)) { + $file = $idx; + $uri = rtrim($uri, '/') . '/index.php'; + } +} + +if ($uri !== '/' && is_file($file)) { + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + if (isset($staticTypes[$ext])) { + header('Content-Type: ' . $staticTypes[$ext]); + header('Content-Length: ' . filesize($file)); + readfile($file); + exit; + } + if ($ext === 'php') { + chdir(dirname($file)); + include $file; + exit; + } +} + +chdir($docRoot); +include $docRoot . '/index.php'; +`; + +const WP_CONFIG_PHP = ` $value) { + if (defined($name)) { + continue; + } + define($name, $value); +} From bd5e063b60b93b4f56d9b9a2bb3a04379d39dea8 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 13 May 2026 17:13:44 +0200 Subject: [PATCH 2/8] Experimental posix-kernel (web): WordPress preparation Download WordPress + SQLite zips through the playground CORS proxy. Broadens the proxy allowlist to wordpress.org, downloads.w.org and the GitHub release host so zip fetches succeed under the iframe's cross-origin isolation regime. --- .../src/lib/posix-kernel/prepare-wordpress.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 packages/playground/remote/src/lib/posix-kernel/prepare-wordpress.ts diff --git a/packages/playground/remote/src/lib/posix-kernel/prepare-wordpress.ts b/packages/playground/remote/src/lib/posix-kernel/prepare-wordpress.ts new file mode 100644 index 00000000000..cfd299f30f4 --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/prepare-wordpress.ts @@ -0,0 +1,130 @@ +/** + * Browser counterpart of `packages/playground/cli/src/posix-kernel/ + * prepare-wordpress.ts`. + * + * The CLI version downloads WordPress + the SQLite drop-in to disk and + * lays out a document root for nginx to serve. The browser version + * returns the raw zip bytes so {@link buildVfsImage} can stream them + * directly into the in-memory VFS — no disk intermediary, no Node FS. + * + * Two zips come back: + * + * 1. WordPress core — resolved via `resolveWordPressRelease` from + * `@wp-playground/wordpress`. Falls through a CORS proxy for + * GitHub-hosted archives (wordpress.org sets CORS so it doesn't + * need one). + * 2. sqlite-database-integration — pre-bundled inside + * `@wp-playground/wordpress-builds`'s assets and resolved at + * build time via `getSqliteDriverModuleDetails`. Same source the + * classic worker uses. + */ +import type { EmscriptenDownloadMonitor } from '@php-wasm/progress'; +import { resolveWordPressRelease } from '@wp-playground/wordpress'; +import { getSqliteDriverModuleDetails } from '@wp-playground/wordpress-builds'; + +/** + * The SQLite driver bundle versions shipped by `@wp-playground/wordpress- + * builds`. `v2.1.16` is the version the CLI's `prepare-wordpress.ts` + * pins to for the posix-kernel path, so we default to the same here for + * parity. Callers needing PHP 5.2 should pass `v3.0.0-rc.3-php52`. + */ +export type SqliteDriverVersion = 'trunk' | 'v2.1.16' | 'v3.0.0-rc.3-php52'; + +export interface PrepareWordPressZipsOptions { + /** Forwarded to `resolveWordPressRelease`. Default `'latest'`. */ + wpVersionQuery?: string; + /** Default `'v2.1.16'` — matches the CLI posix-kernel default. */ + sqliteVersion?: SqliteDriverVersion; + /** + * Same CORS proxy URL the classic worker uses (the `virtual:cors- + * proxy-url` module is the usual source). Applied only to URLs + * that need it; passed through as-is for wordpress.org. + */ + corsProxyUrl?: string; + /** Optional progress monitor — wires download bytes to `onDownloadProgress`. */ + monitor?: EmscriptenDownloadMonitor; + /** Status callback (resolved version, "Downloading WP X.Y", etc.). */ + onStatus?: (message: string) => void; +} + +export interface PrepareWordPressZipsResult { + wpZipBytes: Uint8Array; + sqliteZipBytes: Uint8Array; + /** Concrete version returned by `resolveWordPressRelease` (e.g. `6.8.0`). */ + wpVersion: string; +} + +export async function prepareWordPressZips( + options: PrepareWordPressZipsOptions = {} +): Promise { + const wpVersionQuery = options.wpVersionQuery ?? 'latest'; + const sqliteVersion = options.sqliteVersion ?? 'v2.1.16'; + const onStatus = options.onStatus ?? (() => undefined); + const monitor = options.monitor; + + onStatus(`Resolving WordPress ${wpVersionQuery}`); + const release = await resolveWordPressRelease(wpVersionQuery); + + onStatus(`Downloading WordPress ${release.version}`); + const wpZipBytes = await fetchZipBytes( + maybeProxyUrl(release.releaseUrl, options.corsProxyUrl), + monitor + ); + + onStatus(`Downloading sqlite-database-integration ${sqliteVersion}`); + const sqliteDetails = getSqliteDriverModuleDetails(sqliteVersion); + const sqliteZipBytes = await fetchZipBytes( + sqliteDetails.url, + monitor, + sqliteDetails.size + ); + + return { + wpZipBytes, + sqliteZipBytes, + wpVersion: release.version, + }; +} + +async function fetchZipBytes( + url: string, + monitor?: EmscriptenDownloadMonitor, + expectedSize?: number +): Promise { + if (monitor && expectedSize !== undefined) { + monitor.expectAssets({ [url]: expectedSize }); + } + const response = monitor + ? await monitor.monitorFetch(fetch(url)) + : await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download ${url}: HTTP ${response.status} ` + + `${response.statusText}` + ); + } + return new Uint8Array(await response.arrayBuffer()); +} + +/** + * Route WP-archive URLs through a CORS proxy when they target a host + * that doesn't serve `Access-Control-Allow-Origin`. The classic v1 + * worker proxies its own `https://wordpress.org/wordpress-X.Y.Z.zip` + * URLs unconditionally (see + * `playground-worker-endpoint-blueprints-v1.ts:137-140`) — we mirror + * that for the canonical-release host (`downloads.w.org`) plus the + * other wordpress.org family hostnames `resolveWordPressRelease` might + * return, and for GitHub archives. + */ +function maybeProxyUrl(url: string, corsProxyUrl?: string): string { + if (!corsProxyUrl) return url; + if ( + url.startsWith('https://github.com/') || + url.startsWith('https://downloads.w.org/') || + url.startsWith('https://downloads.wordpress.org/') || + url.startsWith('https://wordpress.org/') + ) { + return `${corsProxyUrl}${url}`; + } + return url; +} From 869845e8db53c79d125f92f6cc2f54ebf4c6f836 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 13 May 2026 17:13:50 +0200 Subject: [PATCH 3/8] Experimental posix-kernel (web): kernel boot orchestrator Construct BrowserKernel + HttpBridgeHost from the prebuilt VFS image, inject the Host header on every bridge request (nginx returns 400 without one), poll waitForNginx until any HTTP status comes back (dinit returns before its children bind their sockets), and bump nextPid past the kernel-reserved range to avoid an EEXIST on the first host-side spawn. --- .../remote/src/lib/posix-kernel/boot.ts | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 packages/playground/remote/src/lib/posix-kernel/boot.ts 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..f8cb6251096 --- /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) => routeChunk(data, 'stdout'), + onStderr: (data) => routeChunk(data, 'stderr'), + onListenTcp: (_pid, _fd, port) => { + 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) => + logger.debug(`[posix-kernel] dinit exited with status ${status}`), + (error) => 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; +} From 57edf3846a48703ff10c49c7d133c8bc4b5dc654 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 13 May 2026 17:13:56 +0200 Subject: [PATCH 4/8] Experimental posix-kernel (web): KernelLimitedPHPApi shim KernelSpawnAdapter wraps kernel.spawn(coreutils|php, ...) behind a natural FS facade, serializing spawns through inFlight because BrowserKernel's onStdout/onStderr are constructor-time singletons with no per-pid routing. KernelLimitedPHPApi mirrors the CLI's shape (mkdir/writeFile/run/request/defineConstant + cookie jar), backed by the spawn adapter and the bridge sendRequest. --- .../lib/posix-kernel/kernel-spawn-adapter.ts | 280 +++++++++++ .../remote/src/lib/posix-kernel/php-api.ts | 463 ++++++++++++++++++ 2 files changed, 743 insertions(+) create mode 100644 packages/playground/remote/src/lib/posix-kernel/kernel-spawn-adapter.ts create mode 100644 packages/playground/remote/src/lib/posix-kernel/php-api.ts 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); +} From ad93171500dfe2b4c6192192ba12c3eb92493597 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 13 May 2026 17:14:05 +0200 Subject: [PATCH 5/8] Experimental posix-kernel (web): Comlink worker endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KernelPlaygroundWorkerEndpoint orchestrates boot (prepareWordPressZips → buildVfsImage → bootKernelWordPress → ensureWordPressInstalled), forwards every iframe HTTP request to the in-kernel nginx via requestStreamed (scope strip → origin- form → Host + x-playground-absolute-url injection → COEP/COOP/ CORP injection), and exposes LimitedPHPApi over Comlink via the late-binding stubs pattern so the iframe sees methods the moment KernelLimitedPHPApi is ready. --- .../playground-worker-endpoint.ts | 678 ++++++++++++++++++ 1 file changed, 678 insertions(+) create mode 100644 packages/playground/remote/src/lib/posix-kernel/playground-worker-endpoint.ts 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..42d767a8c46 --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/playground-worker-endpoint.ts @@ -0,0 +1,678 @@ +/** + * 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'; +/* @ts-ignore */ +import kernelWasmUrl from '@kernel-wasm?url'; +/* @ts-ignore */ +import coreutilsUrl from '@kernel-binary/programs/wasm32/coreutils.wasm?url'; +/* @ts-ignore */ +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 / + + 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..5c87500d542 --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/boot-playground-remote.ts @@ -0,0 +1,312 @@ +/** + * 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'; + +// @ts-ignore +import serviceWorkerPath from '../../../service-worker.ts?worker&url'; +// @ts-ignore +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 keyof KernelPlaygroundWorkerEndpoint; + + 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 { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const result = await ( + kernelWorkerApi[method] as Function + )(...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/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'; From 5abc1c4914560d2a97d0f244876fc22f9eafc145 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 13 May 2026 17:14:21 +0200 Subject: [PATCH 7/8] Experimental posix-kernel (web): Vite configs + nx target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vite.posix-kernel.config.ts (remote) aliases the kernel binary URLs, rewrites /remote.html to /remote-posix-kernel.html, and serves the iframe with COEP require-corp / COOP same-origin / DIP / Service-Worker-Allowed so SharedArrayBuffer is available to the worker. The website wrapper config overlays COEP credentialless + COOP same-origin on the parent document so the iframe can be cross-origin isolated. Wired through nx targets and a `dev:experimental-posix-kernel` script — additive only, the classic `npm run dev` path is untouched. --- package.json | 1 + packages/playground/remote/project.json | 8 + .../remote/vite.posix-kernel.config.ts | 228 ++++++++++++++++++ packages/playground/website/project.json | 14 ++ .../website/vite.posix-kernel.config.ts | 59 +++++ 5 files changed, 310 insertions(+) create mode 100644 packages/playground/remote/vite.posix-kernel.config.ts create mode 100644 packages/playground/website/vite.posix-kernel.config.ts 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/vite.posix-kernel.config.ts b/packages/playground/remote/vite.posix-kernel.config.ts new file mode 100644 index 00000000000..5097cfecbdd --- /dev/null +++ b/packages/playground/remote/vite.posix-kernel.config.ts @@ -0,0 +1,228 @@ +/** + * Vite dev-server config for the `--experimental-posix-kernel` browser + * mode. + * + * Sibling of `vite.config.ts` — the original config is kept untouched + * so `npm run dev` continues to boot the classic Playground unchanged. + * Invoked exclusively as `vite --config vite.posix-kernel.config.ts` + * (see `packages/playground/remote/project.json`:`dev-experimental- + * posix-kernel`). Build / preview / test targets remain owned by + * `vite.config.ts`, so this file is dev-server-only. + * + * Key differences from `vite.config.ts`: + * 1. A `resolveKernelBinariesPlugin` resolves `@kernel-wasm` and + * `@kernel-binary/?url` imports against a wasm-posix-kernel + * checkout (defaults to the bundled `wasm-posix-kernel/` submodule; + * override with `WASM_POSIX_KERNEL_DIR` to point elsewhere). + * 2. `server.fs.allow` is extended to the repo root so the worker + * can pull `BrowserKernel`, the nested kernel-worker entry, and + * binary assets from the neighbouring submodule. + * 3. A small dev-server middleware aliases `/remote.html` to the + * kernel-mode HTML entry so the unchanged website-side iframe URL + * (`/remote.html?v=`) keeps working. + * 4. Cross-origin isolation headers (COEP / COOP / DIP) are set on + * every response so the kernel worker can allocate + * `SharedArrayBuffer`. + */ +import { defineConfig } from 'vite'; +import type { Plugin } from 'vite'; +import { existsSync } from 'fs'; +import { join, resolve } from 'path'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { remoteDevServerHost, remoteDevServerPort } from '../build-config'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { viteTsConfigPaths } from '../../vite-extensions/vite-ts-config-paths'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { buildVersionPlugin } from '../../vite-extensions/vite-build-version'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import virtualModule from '../../vite-extensions/vite-virtual-module'; + +/** + * Resolve the wasm-posix-kernel checkout. Mirrors the CLI's + * `host-bridge.ts` precedence: env var wins, falls back to the bundled + * submodule. Vite needs an absolute path because resolution happens + * here at config load, before the worker exists. + */ +function resolveKernelDir(): string { + const env = process.env['WASM_POSIX_KERNEL_DIR']; + if (env && existsSync(join(env, 'host'))) { + return env; + } + return resolve(__dirname, '../../../wasm-posix-kernel'); +} + +/** + * Resolve `@kernel-wasm?url` and `@kernel-binary/?url` imports + * against the kernel checkout. Mirrors the alias scheme used by + * `wasm-posix-kernel/examples/browser/vite.config.ts` so the demo's + * `BrowserKernel` / `kernel-worker-entry.ts` imports work unchanged + * when re-exported from `posix-kernel/host-bridge.ts`. + * + * Lookup order, first hit wins: + * 1. `/local-binaries/` — local `bash build.sh` output. + * 2. `/binaries/` — release-mirrored artifacts. + */ +function resolveKernelBinariesPlugin(): Plugin { + const kernelDir = resolveKernelDir(); + const KERNEL_WASM_ALIAS = '@kernel-wasm'; + const BINARIES_PREFIX = '@kernel-binary/'; + const tryRoots = ['local-binaries', 'binaries']; + + function findUnder(rel: string): string | null { + for (const root of tryRoots) { + const candidate = join(kernelDir, root, rel); + if (existsSync(candidate)) { + return candidate; + } + } + return null; + } + + return { + name: 'wasm-posix-kernel-binaries', + enforce: 'pre', + resolveId(source) { + // Vite passes the suffix (`?url`, `?worker&url`, …) through + // to `resolveId`. Strip it before matching the alias, then + // re-append so downstream plugins (notably `?url`) still see + // the query and emit an asset URL rather than ESM bindings. + const queryIdx = source.indexOf('?'); + const pathPart = + queryIdx === -1 ? source : source.slice(0, queryIdx); + const query = queryIdx === -1 ? '' : source.slice(queryIdx); + let resolved: string | null = null; + if (pathPart === KERNEL_WASM_ALIAS) { + resolved = findUnder('kernel.wasm'); + } else if (pathPart.startsWith(BINARIES_PREFIX)) { + resolved = findUnder(pathPart.slice(BINARIES_PREFIX.length)); + } + return resolved ? resolved + query : null; + }, + }; +} + +/** + * Serve `remote-posix-kernel.html` at `/remote.html` in dev so the + * unchanged website-side iframe loads the kernel-mode entry without + * any client-side flag plumbing. + */ +function aliasRemoteHtmlPlugin(): Plugin { + return { + name: 'wasm-posix-kernel-remote-html-alias', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // The website constructs the iframe URL as + // `/remote.html?v=` (see + // packages/playground/website/src/lib/config.ts:7-8), + // so the request reaches us with a query string. Match + // on the pathname only and preserve the query so Vite's + // HMR cache-bust keeps working. + const raw = req.url || ''; + const queryIdx = raw.indexOf('?'); + const pathname = queryIdx === -1 ? raw : raw.slice(0, queryIdx); + if (pathname === '/remote.html') { + const query = queryIdx === -1 ? '' : raw.slice(queryIdx); + req.url = '/remote-posix-kernel.html' + query; + } + next(); + }); + }, + }; +} + +const plugins = [ + viteTsConfigPaths({ + root: '../../../', + }), + resolveKernelBinariesPlugin(), + aliasRemoteHtmlPlugin(), + buildVersionPlugin('remote-config'), +]; + +export default defineConfig(() => { + /** + * The dev-mode classic config uses `/cors-proxy/?` which the SW's + * `shouldCacheUrl` matches and tries to put through `cacheFirstFetch`. + * Chrome's CacheStorage truncates the 28 MiB WordPress core zip + * around 19 MiB, producing a `Compressed input was truncated` deep + * inside the zip decoder. `shouldCacheUrl` returns false for URL + * pathnames ending with `.php`, so route directly to + * `/cors-proxy.php?` — Vite's `/cors-proxy` proxy forwards it because + * the prefix matches. + */ + const corsProxyUrl = + 'CORS_PROXY_URL' in process.env + ? process.env['CORS_PROXY_URL'] + : '/cors-proxy.php?'; + + plugins.push( + virtualModule({ + name: 'cors-proxy-url', + content: ` + export const corsProxyUrl = ${JSON.stringify(corsProxyUrl || undefined)};`, + }) + ); + + return { + root: __dirname, + assetsInclude: [ + '**/*.wasm', + '**/*.so', + '**/*.dat', + '**/*.phar', + '*.zip', + ], + cacheDir: '../../../node_modules/.vite/playground-posix-kernel', + publicDir: new URL('../wordpress-builds/public', import.meta.url) + .pathname, + + css: { + modules: { + localsConvention: 'camelCaseOnly', + }, + }, + + server: { + port: remoteDevServerPort, + host: remoteDevServerHost, + allowedHosts: ['playground.test', 'playground-preview.test'], + // Cross-origin isolation is required for `SharedArrayBuffer`, + // which the kernel worker's `MemoryFileSystem` allocates. + // COEP `require-corp` + COOP `same-origin` on the iframe + // pair with COEP `credentialless` + COOP `same-origin` on + // the parent (see `packages/playground/website/ + // vite.posix-kernel.config.ts`). `Document-Isolation-Policy` + // is preserved by the service worker + // (`service-worker.ts:applyCrossOriginIsolationHeaders`) on + // Chrome 137+ as the modern path. `Service-Worker-Allowed: + // /` widens SW scope so the classic playground service + // worker (registered from the kernel-mode iframe) controls + // the full origin. + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Document-Isolation-Policy': 'isolate-and-require-corp', + 'Service-Worker-Allowed': '/', + }, + proxy: { + '/cors-proxy': { + target: 'http://127.0.0.1:5263', + changeOrigin: true, + }, + }, + fs: { + // Extend to the repo root so the worker can resolve + // `wasm-posix-kernel/host/src/**` and + // `wasm-posix-kernel/examples/browser/lib/**`. + allow: ['../../../'], + }, + }, + + plugins, + + worker: { + format: 'es', + plugins: () => plugins, + }, + }; +}); diff --git a/packages/playground/website/project.json b/packages/playground/website/project.json index a22f4a0c409..d28a9f00f2d 100644 --- a/packages/playground/website/project.json +++ b/packages/playground/website/project.json @@ -64,6 +64,20 @@ "color": true } }, + "dev-experimental-posix-kernel": { + "executor": "nx:run-commands", + "continuous": true, + "options": { + "commands": [ + "nx run playground-php-cors-proxy:start", + "nx run playground-remote:dev-experimental-posix-kernel", + "nx dev playground-website-extras --configuration=development-for-website", + "sleep 1; vite --config packages/playground/website/vite.posix-kernel.config.ts" + ], + "parallel": true, + "color": true + } + }, "dev:standalone": { "executor": "@nx/vite:dev-server", "defaultConfiguration": "development", diff --git a/packages/playground/website/vite.posix-kernel.config.ts b/packages/playground/website/vite.posix-kernel.config.ts new file mode 100644 index 00000000000..d0308480ca4 --- /dev/null +++ b/packages/playground/website/vite.posix-kernel.config.ts @@ -0,0 +1,59 @@ +/// +/** + * Vite config for the website server in + * `npm run dev:experimental-posix-kernel` mode. + * + * Sibling of `vite.config.ts` — the original is kept untouched so + * `npm run dev` continues to boot the classic Playground unchanged. + * This wrapper imports the base config function, invokes it with the + * same Vite env, and overlays the headers required to make the + * iframe at `/remote.html?v=...` cross-origin isolated. + * + * ## Why this wrapper exists + * + * The kernel worker (`packages/playground/remote/src/lib/posix-kernel/ + * playground-worker-endpoint.ts`) builds the VFS image in a + * `SharedArrayBuffer`. SAB is only available in cross-origin + * isolated documents. For an embedded iframe to be COI'd, the spec + * requires *both* the iframe response AND the parent (this website) + * to set `Cross-Origin-Embedder-Policy`. The kernel-mode remote + * already sets COEP on every response (see + * `packages/playground/remote/vite.posix-kernel.config.ts`); this + * wrapper adds the matching pair on the website server so the + * embedder opts in too. + * + * Why `credentialless` instead of `require-corp`: the website at + * `:5400` loads cross-origin sub-resources (analytics, Octokit, etc.) + * that don't send `Cross-Origin-Resource-Policy`. `require-corp` + * would block them; `credentialless` strips credentials from no-CORS + * cross-origin fetches and lets them through. Both COEP variants + * enable cross-origin isolation. + * + * Document-Isolation-Policy would have been preferable (no embedder + * opt-in needed), but it is not currently active by default in + * stable Chrome — verified with Chrome 148, which doesn't even + * expose the `chrome://flags/#document-isolation-policy` toggle. + */ +import { defineConfig } from 'vite'; +import type { ConfigEnv, UserConfig, UserConfigExport } from 'vite'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import baseConfigExport from './vite.config'; + +export default defineConfig(async (env: ConfigEnv): Promise => { + const baseConfig = baseConfigExport as UserConfigExport; + const resolved = + typeof baseConfig === 'function' + ? await baseConfig(env) + : await baseConfig; + return { + ...resolved, + server: { + ...resolved.server, + headers: { + ...(resolved.server?.headers ?? {}), + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'credentialless', + }, + }, + }; +}); From 1fa02a30cb5be2f86c1fbb1c0abc30313cebe2dc Mon Sep 17 00:00:00 2001 From: mho22 Date: Thu, 14 May 2026 10:52:25 +0200 Subject: [PATCH 8/8] Experimental posix-kernel (web): fix lint + typecheck for CI - Switch submodule re-exports in `host-bridge.ts` / `vfs-builder.ts` to a `@wasm-posix-kernel/*` alias so TS doesn't descend into the submodule source (strict tsconfig vs. submodule's looser config). - Vite alias in `vite.posix-kernel.config.ts` resolves the same specifiers at runtime against `WASM_POSIX_KERNEL_DIR` or the bundled submodule. - New `wasm-posix-kernel.d.ts` declares opaque `any` shims for the alias plus Vite asset-suffix wildcards (`?url`, `?raw`, `?worker&url`) and the growable `SharedArrayBuffer` 2-arg form. - Drop now-unneeded `@ts-ignore` / `eslint-disable` comments at the kernel-mode import sites. - Replace the `Function` cast in `boot-playground-remote.ts` with an `as any` indexer; the dynamically-bound Comlink methods aren't in `keyof KernelPlaygroundWorkerEndpoint`. - Annotate `BrowserKernelOptions` callback params locally in `boot.ts` since the alias surface is `any`. --- .../posix-kernel/boot-playground-remote.ts | 12 ++--- .../remote/src/lib/posix-kernel/boot.ts | 10 ++-- .../src/lib/posix-kernel/host-bridge.ts | 12 ++--- .../playground-worker-endpoint.ts | 3 -- .../src/lib/posix-kernel/vfs-builder.ts | 3 +- .../lib/posix-kernel/wasm-posix-kernel.d.ts | 46 +++++++++++++++++++ .../remote/vite.posix-kernel.config.ts | 13 ++++++ 7 files changed, 73 insertions(+), 26 deletions(-) create mode 100644 packages/playground/remote/src/lib/posix-kernel/wasm-posix-kernel.d.ts 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 index 5c87500d542..7d8fc1b3366 100644 --- a/packages/playground/remote/src/lib/posix-kernel/boot-playground-remote.ts +++ b/packages/playground/remote/src/lib/posix-kernel/boot-playground-remote.ts @@ -34,9 +34,7 @@ import { logger } from '@php-wasm/logger'; import { PhpWasmError } from '@php-wasm/util'; import { responseTo } from '@php-wasm/web-service-worker'; -// @ts-ignore import serviceWorkerPath from '../../../service-worker.ts?worker&url'; -// @ts-ignore import kernelWorkerUrl from './playground-worker-endpoint.ts?worker&url'; import type { KernelPlaygroundWorkerEndpoint } from './playground-worker-endpoint'; @@ -233,8 +231,7 @@ export async function bootPlaygroundRemote() { } const args = event.data.args || []; - const method = event.data - .method as keyof KernelPlaygroundWorkerEndpoint; + const method = event.data.method as string; if (method === 'request') { const streamedResponse = await ( @@ -253,10 +250,9 @@ export async function bootPlaygroundRemote() { [bodyPort] ); } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - const result = await ( - kernelWorkerApi[method] as Function - )(...args); + const result = await (kernelWorkerApi as any)[method]( + ...args + ); event.source!.postMessage( responseTo(event.data.requestId, result) ); diff --git a/packages/playground/remote/src/lib/posix-kernel/boot.ts b/packages/playground/remote/src/lib/posix-kernel/boot.ts index f8cb6251096..51ec2b2afb9 100644 --- a/packages/playground/remote/src/lib/posix-kernel/boot.ts +++ b/packages/playground/remote/src/lib/posix-kernel/boot.ts @@ -111,9 +111,9 @@ export async function bootKernelWordPress( kernelOwnedFs: true, maxWorkers: 8, maxMemoryPages: 4096, - onStdout: (data) => routeChunk(data, 'stdout'), - onStderr: (data) => routeChunk(data, 'stderr'), - onListenTcp: (_pid, _fd, port) => { + 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}`); }, }); @@ -143,9 +143,9 @@ export async function bootKernelWordPress( kernel.sendBridgePort(bridge.detachHostPort(), KERNEL_HTTP_PORT); exit.then( - (status) => + (status: number) => logger.debug(`[posix-kernel] dinit exited with status ${status}`), - (error) => logger.error('[posix-kernel] dinit failed:', error) + (error: unknown) => logger.error('[posix-kernel] dinit failed:', error) ); const sendRequest = createRequestSender(bridge); diff --git a/packages/playground/remote/src/lib/posix-kernel/host-bridge.ts b/packages/playground/remote/src/lib/posix-kernel/host-bridge.ts index 75c62ee900b..7225693119f 100644 --- a/packages/playground/remote/src/lib/posix-kernel/host-bridge.ts +++ b/packages/playground/remote/src/lib/posix-kernel/host-bridge.ts @@ -18,16 +18,12 @@ * by `resolveKernelBinariesPlugin` in `remote/vite.posix-kernel.config.ts`. */ -// eslint-disable-next-line @nx/enforce-module-boundaries -export { BrowserKernel } from '../../../../../../wasm-posix-kernel/examples/browser/lib/browser-kernel'; +export { BrowserKernel } from '@wasm-posix-kernel/examples/browser/lib/browser-kernel'; -// eslint-disable-next-line @nx/enforce-module-boundaries -export { HttpBridgeHost } from '../../../../../../wasm-posix-kernel/examples/browser/lib/http-bridge'; -// eslint-disable-next-line @nx/enforce-module-boundaries +export { HttpBridgeHost } from '@wasm-posix-kernel/examples/browser/lib/http-bridge'; export type { HttpRequest, HttpResponse, -} from '../../../../../../wasm-posix-kernel/examples/browser/lib/http-bridge'; +} from '@wasm-posix-kernel/examples/browser/lib/http-bridge'; -// eslint-disable-next-line @nx/enforce-module-boundaries -export { MemoryFileSystem } from '../../../../../../wasm-posix-kernel/host/src/vfs/memory-fs'; +export { MemoryFileSystem } from '@wasm-posix-kernel/host/src/vfs/memory-fs'; 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 index 42d767a8c46..4abc4da879e 100644 --- a/packages/playground/remote/src/lib/posix-kernel/playground-worker-endpoint.ts +++ b/packages/playground/remote/src/lib/posix-kernel/playground-worker-endpoint.ts @@ -58,11 +58,8 @@ import type { HttpRequest, HttpResponse } from './host-bridge'; /* @ts-ignore */ import { corsProxyUrl as defaultCorsProxyUrl } from 'virtual:cors-proxy-url'; -/* @ts-ignore */ import kernelWasmUrl from '@kernel-wasm?url'; -/* @ts-ignore */ import coreutilsUrl from '@kernel-binary/programs/wasm32/coreutils.wasm?url'; -/* @ts-ignore */ import phpWasmUrl from '@kernel-binary/programs/wasm32/php/php.wasm?url'; import { wordPressSiteUrl } from '../config'; diff --git a/packages/playground/remote/src/lib/posix-kernel/vfs-builder.ts b/packages/playground/remote/src/lib/posix-kernel/vfs-builder.ts index 34978f77f34..1231f64e5ea 100644 --- a/packages/playground/remote/src/lib/posix-kernel/vfs-builder.ts +++ b/packages/playground/remote/src/lib/posix-kernel/vfs-builder.ts @@ -29,14 +29,13 @@ import { decodeZip } from '@php-wasm/stream-compression'; import { dirname, joinPaths } from '@php-wasm/util'; import { MemoryFileSystem } from './host-bridge'; -// eslint-disable-next-line @nx/enforce-module-boundaries import { writeVfsFile, writeVfsBinary, ensureDir, ensureDirRecursive, symlink, -} from '../../../../../../wasm-posix-kernel/host/src/vfs/image-helpers'; +} from '@wasm-posix-kernel/host/src/vfs/image-helpers'; // `?url` imports resolved by `resolveKernelBinariesPlugin` in // `vite.posix-kernel.config.ts`. The plugin walks diff --git a/packages/playground/remote/src/lib/posix-kernel/wasm-posix-kernel.d.ts b/packages/playground/remote/src/lib/posix-kernel/wasm-posix-kernel.d.ts new file mode 100644 index 00000000000..3ab33dbef18 --- /dev/null +++ b/packages/playground/remote/src/lib/posix-kernel/wasm-posix-kernel.d.ts @@ -0,0 +1,46 @@ +declare module '*?url' { + const url: string; + export default url; +} + +declare module '*?raw' { + const text: string; + export default text; +} + +declare module '*?worker&url' { + const url: string; + export default url; +} + +interface SharedArrayBufferConstructor { + new ( + byteLength: number, + options?: { maxByteLength?: number } + ): SharedArrayBuffer; +} + +declare module '@wasm-posix-kernel/*' { + export const BrowserKernel: any; + export type BrowserKernel = any; + export const HttpBridgeHost: any; + export type HttpBridgeHost = any; + export const MemoryFileSystem: any; + export type MemoryFileSystem = any; + export interface HttpRequest { + method: string; + url: string; + headers: Record; + body: Uint8Array | null; + } + export interface HttpResponse { + status: number; + headers: Record; + body: Uint8Array; + } + export const writeVfsFile: any; + export const writeVfsBinary: any; + export const ensureDir: any; + export const ensureDirRecursive: any; + export const symlink: any; +} diff --git a/packages/playground/remote/vite.posix-kernel.config.ts b/packages/playground/remote/vite.posix-kernel.config.ts index 5097cfecbdd..6fcf2838707 100644 --- a/packages/playground/remote/vite.posix-kernel.config.ts +++ b/packages/playground/remote/vite.posix-kernel.config.ts @@ -176,6 +176,19 @@ export default defineConfig(() => { publicDir: new URL('../wordpress-builds/public', import.meta.url) .pathname, + // Runtime side of the `@wasm-posix-kernel/*` alias whose TS + // counterpart lives in `src/lib/posix-kernel/wasm-posix-kernel.d.ts`. + // The shim keeps the submodule out of our strict typecheck; this + // alias resolves the same specifiers to the actual files. + resolve: { + alias: [ + { + find: /^@wasm-posix-kernel\/(.*)$/, + replacement: resolve(resolveKernelDir(), '$1'), + }, + ], + }, + css: { modules: { localsConvention: 'camelCaseOnly',