From c7267e1e94ee0b682172d2d840c32e835ea5d76a Mon Sep 17 00:00:00 2001 From: mho22 Date: Tue, 11 Aug 2026 20:41:32 +0200 Subject: [PATCH 1/3] [Host] Decouple BrowserKernel from Vite-only worker asset imports Move the two `?worker&url` worker-entry imports from browser-kernel-host.ts into the new browser-kernel-assets.ts so a package build can swap that module while the repository keeps consuming it through Vite. The BrowserKernelAssets interface stays in browser-kernel-host.ts so type graphs never load the Vite-coupled module. BrowserKernel gains opt-in exposeHostFs: the kernel worker reports the VFS SharedArrayBuffer at boot and BrowserKernel.hostFs serves a synchronous main-thread filesystem view over the same bytes. It stays off by default and the reference is released in destroy(), preserving the WebKit image-switch reclaim behavior. spawnFromVfs now defaults stdin to an immediate EOF for non-PTY spawns, matching boot(), spawn(), and NodeKernelHost.spawnProgram. Without the default, a VFS-spawned program that reads fd 0 (php does at startup) retried readv(0) forever in the browser. --- host/src/browser-kernel-assets.ts | 34 +++++++ host/src/browser-kernel-host.ts | 115 ++++++++++++++++++++++-- host/src/browser-kernel-protocol.ts | 15 ++++ host/src/browser-kernel-worker-entry.ts | 8 +- host/test/browser-kernel.test.ts | 105 ++++++++++++++++++++++ 5 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 host/src/browser-kernel-assets.ts diff --git a/host/src/browser-kernel-assets.ts b/host/src/browser-kernel-assets.ts new file mode 100644 index 0000000000..22d0a21ccc --- /dev/null +++ b/host/src/browser-kernel-assets.ts @@ -0,0 +1,34 @@ +/** + * Worker-entry URLs for {@link BrowserKernel}. + * + * `BrowserKernel` spawns two `{ type: "module" }` workers, and Vite's + * `?worker&url` import analysis is the only thing that resolves their entry + * points. Isolating both specifiers here keeps `browser-kernel-host.ts` plain + * TypeScript that any bundler can process. + * + * The kernel wasm and rootfs image are NOT here. They are default *product* + * artifacts, loaded lazily by `browser-kernel-default-artifacts.ts` only when a + * caller boots without explicit bytes. + * + * Consumption modes: + * + * - **Source (apps/browser-demos):** imported as-is; Vite resolves both worker + * entries from the repository tree. + * + * - **Packaged (`@kandelo/web`):** the package build aliases this module to + * `web-libs/kandelo-web/src/assets-bundled.ts`, which resolves the same two + * entries against the shipped `dist/` via `new URL(..., import.meta.url)`. + * BrowserKernel never imports the `?worker&url` specifiers directly, so the + * consuming bundler never sees them. + * + * Either way {@link BrowserKernel} reads only {@link BROWSER_KERNEL_ASSETS}, + * and both URLs are overridable per instance via `BrowserKernelOptions.assets`. + */ +import processWorkerUrl from "./worker-entry-browser.ts?worker&url"; +import kernelWorkerUrl from "./browser-kernel-worker-entry.ts?worker&url"; +import type { BrowserKernelAssets } from "./browser-kernel-host"; + +export const BROWSER_KERNEL_ASSETS: BrowserKernelAssets = { + processWorkerUrl, + kernelWorkerUrl, +}; diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 65705c2945..f52bae70a0 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -22,8 +22,7 @@ import type { import type { HttpRequest, HttpResponse } from "./networking/in-kernel-http"; export type { HttpRequest, HttpResponse }; -import workerEntryUrl from "./worker-entry-browser.ts?worker&url"; -import kernelWorkerEntryUrl from "./browser-kernel-worker-entry.ts?worker&url"; +import { BROWSER_KERNEL_ASSETS } from "./browser-kernel-assets"; import { DEFAULT_MAX_PAGES, DEFAULT_MAX_WORKERS, @@ -37,6 +36,24 @@ import { awaitGracefulKernelRealmDestroy } from "./kernel-realm-destroy"; const DESTROY_REQUEST_TIMEOUT_MS = 2_000; +/** Vite injects `import.meta.env`; plain bundlers (tsdown/rolldown) do not. */ +function importMetaBaseUrl(): string | undefined { + return (import.meta as unknown as { env?: { BASE_URL?: string } }).env?.BASE_URL; +} + +/** + * The worker-entry URLs BrowserKernel spawns from. Declared here — not in + * `browser-kernel-assets.ts`, which provides the default value — so importing + * the type never drags that module's Vite-only `?worker&url` imports into a + * type graph (`.d.ts` bundling). + */ +export interface BrowserKernelAssets { + /** Per-process worker entry, spawned as a `{ type: "module" }` Worker. */ + processWorkerUrl: string; + /** Dedicated kernel worker entry, spawned as a `{ type: "module" }` Worker. */ + kernelWorkerUrl: string; +} + export interface BrowserKernelOptions { /** Maximum concurrent workers (default: 4) */ maxWorkers?: number; @@ -100,6 +117,27 @@ export interface BrowserKernelOptions { syscallLogPtrWidth?: 4 | 8; /** Forwarded to TlsNetworkBackendOptions.dnsAliases. */ dnsAliases?: Record; + /** + * Override the URLs BrowserKernel spawns its kernel and process workers + * from. Any subset may be given; the rest fall back to + * {@link BROWSER_KERNEL_ASSETS}. Consumers of `@kandelo/web` normally do not + * need this — the package ships resolved defaults — but it is the escape + * hatch for hosting the worker entries on a custom origin. + */ + assets?: Partial; + /** + * Expose the kernel-owned VFS to the main thread as {@link + * BrowserKernel.hostFs}. Off by default, and deliberately so: it makes the + * main thread a co-owner of the VFS SharedArrayBuffer, which on WebKit is + * then reclaimed only when the page drops it rather than by + * `Worker.terminate()` — the accumulation this kernel avoids by keeping the + * VFS worker-owned (see {@link kernelOwnedFs}). + * + * Enable it for an embedder that must read or write the live filesystem from + * the main thread. {@link BrowserKernel.destroy} releases the reference. A + * host that switches images repeatedly should leave it off. + */ + exposeHostFs?: boolean; /** Browser pages that are not controlled by Kandelo's service worker can * use this to route guest HTTP(S) and external lazy VFS downloads through * a CORS-capable proxy. Same-origin lazy assets remain direct. */ @@ -187,6 +225,16 @@ export class BrowserKernel { * and fixed (1 MiB); the live VFS is owned by the worker, not here. */ private shmSab: SharedArrayBuffer; private maxPages: number; + /** Worker-entry URLs: defaults merged with `options.assets`. */ + private readonly assets: BrowserKernelAssets; + /** + * The VFS SharedArrayBuffer the kernel worker reports in its `ready` + * message. It lets the main thread build a synchronous + * {@link BrowserKernel.hostFs} view over the worker-owned filesystem. + */ + private workerFsSab?: SharedArrayBuffer; + /** Lazily-built main-thread view over {@link workerFsSab}. */ + private hostFsView?: MemoryFileSystem; private options: Required< Pick > & @@ -224,6 +272,7 @@ export class BrowserKernel { constructor(options: BrowserKernelOptions = {}) { this.maxPages = options.maxMemoryPages ?? DEFAULT_MAX_PAGES; + this.assets = { ...BROWSER_KERNEL_ASSETS, ...options.assets }; this.options = { maxWorkers: DEFAULT_MAX_WORKERS, env: [ @@ -246,6 +295,42 @@ export class BrowserKernel { MemoryFileSystem.create(this.shmSab); // format shm SAB for kernel worker } + /** + * Host-side VFS, readable and writable **synchronously from the main + * thread**. + * + * The runtime filesystem is owned by the kernel worker, but it is backed by + * a `SharedArrayBuffer`. The worker reports that SAB at boot, so this view + * operates on the exact same bytes the running processes see, with no + * message round-trip. Kandelo already requires cross-origin isolation + * (COOP/COEP) for `SharedArrayBuffer` + `Atomics`, so the synchronous path + * is always available and there is no async fallback to reason about. + * + * The returned {@link MemoryFileSystem} implements the full host + * `FileSystemBackend` surface. Concurrent access is coordinated by the + * SharedFS lock table, exactly as it is between the kernel worker and its + * process workers. + * + * Requires `exposeHostFs: true` in the constructor options, and is available + * once the kernel is booted. + */ + get hostFs(): MemoryFileSystem { + if (!this.options.exposeHostFs) { + throw new Error( + "hostFs requires exposeHostFs: true in the BrowserKernel options. It is " + + "off by default because it makes the main thread a co-owner of the VFS " + + "SharedArrayBuffer.", + ); + } + if (!this.workerFsSab) { + throw new Error( + "hostFs is unavailable until the kernel is booted. Call boot() first.", + ); + } + this.hostFsView ??= MemoryFileSystem.fromExisting(this.workerFsSab); + return this.hostFsView; + } + /** * Boot the kernel from a pre-built VFS image and spawn the first process. * The worker takes ownership of the FS; the main thread no longer has FS @@ -302,7 +387,7 @@ export class BrowserKernel { await this.bootWorker({ kernelWasmBytes: wasmBytes, vfsImage, - lazyUrlBase: options.lazyUrlBase ?? import.meta.env.BASE_URL, + lazyUrlBase: options.lazyUrlBase ?? importMetaBaseUrl(), closedLazyAssets: options.closedLazyAssets, takeVfsImageOwnership: false, }); @@ -329,7 +414,7 @@ export class BrowserKernel { await this.bootWorker({ kernelWasmBytes: wasmBytes, vfsImage: new Uint8Array(options.vfsImage), - lazyUrlBase: options.lazyUrlBase ?? import.meta.env.BASE_URL, + lazyUrlBase: options.lazyUrlBase ?? importMetaBaseUrl(), closedLazyAssets: options.closedLazyAssets, takeVfsImageOwnership: true, }); @@ -362,7 +447,7 @@ export class BrowserKernel { ? undefined : snapshotClosedLazyAssets(opts.closedLazyAssets); // Create the kernel worker - this.kernelWorkerHandle = new Worker(kernelWorkerEntryUrl, { type: "module" }); + this.kernelWorkerHandle = new Worker(this.assets.kernelWorkerUrl, { type: "module" }); this.workerStarted = true; this.kernelWorkerHandle.onmessage = (e: MessageEvent) => { @@ -413,6 +498,11 @@ export class BrowserKernel { }; const readyHandler = (e: MessageEvent) => { if (e.data?.type === "ready") { + // Present only under `exposeHostFs`: the SAB backing the worker's + // VFS, which backs the synchronous view in {@link hostFs}. + if (e.data.fsSab instanceof SharedArrayBuffer) { + this.workerFsSab = e.data.fsSab; + } settleResolve(); } else if (e.data?.type === "init_error") { settleReject(new Error(`Kernel worker init failed: ${e.data.error}`)); @@ -437,7 +527,8 @@ export class BrowserKernel { lazyUrlBase: opts.lazyUrlBase, closedLazyAssets, shmSab: this.shmSab, - workerEntryUrl, + workerEntryUrl: this.assets.processWorkerUrl, + reportFsSab: this.options.exposeHostFs, config: { maxWorkers: this.options.maxWorkers, maxMemoryPages: this.maxPages, @@ -622,6 +713,11 @@ export class BrowserKernel { ): Promise<{ pid: number; exit: Promise }> { const requestId = this.nextRequestId++; const spawnStartedBeforeExitSequence = this.exitSequence; + // Non-PTY spawns without an explicit stdin get an immediate EOF, matching + // boot(), spawn(), and NodeKernelHost.spawnProgram(). Without it a program + // that reads stdin (php does at startup) retries readv(0) forever. + const stdin = + options?.stdin ?? (!options?.pty ? new Uint8Array() : undefined); const pid = await this.request(requestId, { type: "spawn", requestId, @@ -634,7 +730,7 @@ export class BrowserKernel { pty: options?.pty, ptyCols: options?.ptyCols, ptyRows: options?.ptyRows, - stdin: options?.stdin, + stdin, maxPages: this.maxPages, }) as number; @@ -1156,6 +1252,11 @@ export class BrowserKernel { this.fbGenerationByPid.clear(); this.framebuffers.clear(); this.pendingPtyOutput.clear(); + // `hostFs` (opt-in) makes the main thread a co-owner of the VFS SAB, so it + // belongs to the same release set: without this the VFS would outlive + // Worker.terminate() and accumulate across image switches. + this.workerFsSab = undefined; + this.hostFsView = undefined; if (gracefulDetachFailure || realmTerminationFailure) { const diagnostic: HostDiagnostic = { pid: 0, diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index e4a0953b34..fb501a66f3 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -39,6 +39,13 @@ export interface InitMessage { closedLazyAssets?: ClosedLazyAsset[]; shmSab: SharedArrayBuffer; workerEntryUrl: string; + /** + * Ask the worker to report its VFS SharedArrayBuffer in the `ready` message + * so the main thread can build `BrowserKernel.hostFs`. Off by default: it + * makes the main thread a co-owner of the VFS SAB, which is exactly what the + * kernel-owned VFS avoids (see `BrowserKernelOptions.exposeHostFs`). + */ + reportFsSab?: boolean; bridgePort?: MessagePort; config: { maxWorkers: number; @@ -407,6 +414,14 @@ export type MainToKernelMessage = export interface ReadyMessage { type: "ready"; + /** + * The SharedArrayBuffer backing the kernel-owned VFS, sent only when the + * init message set `reportFsSab`. It lets the main thread build a + * synchronous host-side filesystem view over the same bytes (see + * `BrowserKernel.hostFs`); writes from either side are immediately visible + * to the other. + */ + fsSab?: SharedArrayBuffer; } export interface InitErrorMessage { diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index ea6be65aed..2b9a5f01d0 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -1120,7 +1120,13 @@ async function handleInit(msg: Extract) { // cannot slip past both the pending queue and the serialized live path. initReady = true; - post({ type: "ready" }); + // Only when the host asked for it: report the VFS SAB so the main thread can + // build a synchronous host-side filesystem view (BrowserKernel.hostFs). It is + // the same SharedArrayBuffer the worker reads and writes, so changes are + // mutually visible without a round-trip. Reporting it unconditionally would + // make the main thread a co-owner of every VFS, which is what the + // kernel-owned VFS deliberately avoids. + post(msg.reportFsSab ? { type: "ready", fsSab: memfs.sharedBuffer } : { type: "ready" }); } // ── Spawn ── diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index 1a0305a0ba..07dadd13f1 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -394,6 +394,30 @@ describe("BrowserKernel", () => { ])).toBe(0); }); + it("defaults spawnFromVfs stdin to an immediate EOF unless a PTY is requested", async () => { + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ type: "ready" }); + await initPromise; + + // Without an explicit stdin the process must see EOF, not a stdin that + // never arrives — a program that reads fd 0 would otherwise block forever. + kernel.spawnFromVfs("/bin/true", ["/bin/true"]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(worker.lastMessage("spawn").stdin).toEqual(new Uint8Array()); + + // A PTY spawn keeps stdin open for interactive input. + kernel.spawnFromVfs("/bin/true", ["/bin/true"], { pty: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(worker.lastMessage("spawn").stdin).toBeUndefined(); + }); + it("uses the worker pid when fork has reserved the preceding pid", async () => { const BrowserKernel = await loadBrowserKernel(); const kernel = new BrowserKernel({ kernelOwnedFs: true }); @@ -922,6 +946,87 @@ describe("BrowserKernel", () => { }); }); + describe("hostFs", () => { + /** Boot a kernel and hand it a formatted VFS SAB in the `ready` message. */ + async function bootWithFsSab(options: Record) { + const { MemoryFileSystem } = await import("../src/vfs/memory-fs"); + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel(options); + const bootPromise = kernel.boot({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + argv: ["/init"], + }); + await new Promise((r) => setTimeout(r, 0)); + const w = MockWorker.instances[0]!; + const fsSab = new SharedArrayBuffer(1024 * 1024); + MemoryFileSystem.create(fsSab); + w.simulateMessage({ + type: "ready", + ...(w.lastMessage("init").reportFsSab ? { fsSab } : {}), + }); + await new Promise((r) => setTimeout(r, 0)); + const spawn = w.lastMessage("spawn"); + w.simulateMessage({ type: "response", requestId: spawn.requestId, result: 100 }); + await bootPromise; + return { kernel, worker: w, fsSab }; + } + + it("does not ask the worker for the VFS SAB by default", async () => { + const { kernel, worker } = await bootWithFsSab({}); + // The main thread must not become a co-owner of the VFS unless asked: + // a co-owned SAB outlives Worker.terminate() on WebKit. + expect(worker.lastMessage("init").reportFsSab).toBeFalsy(); + expect(() => kernel.hostFs).toThrow(/exposeHostFs/); + }); + + it("exposes a synchronous view over the worker's VFS SAB when enabled", async () => { + const { kernel, worker } = await bootWithFsSab({ exposeHostFs: true }); + expect(worker.lastMessage("init").reportFsSab).toBe(true); + + const payload = new TextEncoder().encode("hello"); + const fd = kernel.hostFs.open("/x", 0o1101 /* O_WRONLY|O_CREAT|O_TRUNC */, 0o644); + kernel.hostFs.write(fd, payload, 0, payload.length); + kernel.hostFs.close(fd); + + const rfd = kernel.hostFs.open("/x", 0, 0); + const buf = new Uint8Array(16); + const n = kernel.hostFs.read(rfd, buf, null, buf.length); + kernel.hostFs.close(rfd); + expect(new TextDecoder().decode(buf.subarray(0, n))).toBe("hello"); + }); + + it("returns one stable view rather than a fresh one per access", async () => { + const { kernel } = await bootWithFsSab({ exposeHostFs: true }); + expect(kernel.hostFs).toBe(kernel.hostFs); + }); + + it("throws before the kernel is booted", async () => { + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ exposeHostFs: true }); + expect(() => kernel.hostFs).toThrow(/booted/); + }); + + it("releases the VFS SAB reference on destroy", async () => { + const { kernel, worker } = await bootWithFsSab({ exposeHostFs: true }); + expect(kernel.hostFs).toBeDefined(); + + const destroyPromise = kernel.destroy(); + await new Promise((r) => setTimeout(r, 0)); + const destroyMsg = worker.lastMessage("destroy"); + worker.simulateMessage({ + type: "response", + requestId: destroyMsg.requestId, + result: { gracefulDetachComplete: true }, + }); + await destroyPromise; + + // Same release contract as the framebuffer/PTY aliases destroy() clears: + // holding the SAB here would pin a whole VFS per booted image. + expect(() => kernel.hostFs).toThrow(/booted/); + }); + }); + it("destroy() terminates the worker after graceful generation detach", async () => { const BrowserKernel = await loadBrowserKernel(); const kernel = new BrowserKernel({ kernelOwnedFs: true }); From 197749a5fddcd94749409c16fd47b4ccd3de93af Mon Sep 17 00:00:00 2001 From: mho22 Date: Tue, 11 Aug 2026 20:42:00 +0200 Subject: [PATCH 2/3] [Web] Publish the browser host runtime as @kandelo/web A downstream browser project that embeds the Kandelo kernel today has to vendor host/src or carry a git submodule. web-libs/kandelo-web ships BrowserKernel, the VFS backends and image helpers, and a binaries loader as a code-only npm package, so a separate browser IDE can depend on the browser host runtime like any other package. The package ships no Wasm: the loader fetches ABI-verified kernel/rootfs/program binaries from a Kandelo binaries release at runtime and resolves a relative baseUrl (the same-origin proxy pattern for GitHub's CORS-less release CDN) against the page URL. Every ABI constant derives from the generated host/src/generated/abi.ts, so a kernel ABI bump cannot leave the package stale. The tsdown build swaps the Vite-coupled assets module and the lazy default-artifacts module for package-local replacements, emits each worker entry as a single self-contained file, and fails if any import other than the lazy Node-only import("net") survives. dist invariants are enforced by test/dist-contract.test.ts, which builds with the package's own tsdown devDependency, so the vitest suite installs the package's node modules and the suite-runner fixture provisions the directory. The root package.json gains pack:web. That manifest is a declared build input of the VFS-shaped packages, so packages/registry/program-packages.json is regenerated. --- .../2026-06-18-kandelo-web-npm-packaging.md | 157 +++ package.json | 3 +- packages/registry/program-packages.json | 52 +- scripts/ci-run-test-suite.sh | 4 + .../scripts/ci-run-test-suite-groups.test.sh | 1 + web-libs/README.md | 6 + web-libs/kandelo-web/.gitignore | 3 + web-libs/kandelo-web/README.md | 226 ++++ .../examples/scratch-consumer/index.html | 14 + .../scratch-consumer/package-lock.json | 801 ++++++++++++ .../examples/scratch-consumer/package.json | 18 + .../examples/scratch-consumer/src/main.ts | 104 ++ .../examples/scratch-consumer/vite.config.ts | 49 + web-libs/kandelo-web/package-lock.json | 1113 +++++++++++++++++ web-libs/kandelo-web/package.json | 35 + web-libs/kandelo-web/src/assets-bundled.ts | 26 + .../src/default-artifacts-unbundled.ts | 30 + web-libs/kandelo-web/src/fetch-binaries.ts | 467 +++++++ web-libs/kandelo-web/src/index.ts | 140 +++ web-libs/kandelo-web/src/vite-shims.d.ts | 17 + .../kandelo-web/test/dist-contract.test.ts | 91 ++ .../kandelo-web/test/fetch-binaries.test.ts | 33 + web-libs/kandelo-web/tsconfig.json | 19 + web-libs/kandelo-web/tsdown.config.ts | 94 ++ 24 files changed, 3476 insertions(+), 27 deletions(-) create mode 100644 docs/plans/2026-06-18-kandelo-web-npm-packaging.md create mode 100644 web-libs/kandelo-web/.gitignore create mode 100644 web-libs/kandelo-web/README.md create mode 100644 web-libs/kandelo-web/examples/scratch-consumer/index.html create mode 100644 web-libs/kandelo-web/examples/scratch-consumer/package-lock.json create mode 100644 web-libs/kandelo-web/examples/scratch-consumer/package.json create mode 100644 web-libs/kandelo-web/examples/scratch-consumer/src/main.ts create mode 100644 web-libs/kandelo-web/examples/scratch-consumer/vite.config.ts create mode 100644 web-libs/kandelo-web/package-lock.json create mode 100644 web-libs/kandelo-web/package.json create mode 100644 web-libs/kandelo-web/src/assets-bundled.ts create mode 100644 web-libs/kandelo-web/src/default-artifacts-unbundled.ts create mode 100644 web-libs/kandelo-web/src/fetch-binaries.ts create mode 100644 web-libs/kandelo-web/src/index.ts create mode 100644 web-libs/kandelo-web/src/vite-shims.d.ts create mode 100644 web-libs/kandelo-web/test/dist-contract.test.ts create mode 100644 web-libs/kandelo-web/test/fetch-binaries.test.ts create mode 100644 web-libs/kandelo-web/tsconfig.json create mode 100644 web-libs/kandelo-web/tsdown.config.ts diff --git a/docs/plans/2026-06-18-kandelo-web-npm-packaging.md b/docs/plans/2026-06-18-kandelo-web-npm-packaging.md new file mode 100644 index 0000000000..2af3db2eba --- /dev/null +++ b/docs/plans/2026-06-18-kandelo-web-npm-packaging.md @@ -0,0 +1,157 @@ +# `@kandelo/web` — npm Packaging of the Browser Host Runtime + +**Status:** Implemented, rebased onto `main` at ABI 42. The package is +**code-only**: it bundles no Wasm and fetches the kernel, rootfs, and program +binaries at runtime from a Kandelo *binaries release*. The loader path (index +parse → archive fetch → sha256 verify → fzstd → untar) is validated against the +live `binaries-abi-v42` release from Node. Live browser acceptance under +COOP/COEP has not been run. + +**Goal:** Make the browser host runtime installable from npm with a public host +API, so a separate browser-IDE project can depend on `@kandelo/web` instead of +vendoring `host/src` or carrying a git submodule. + +**Branch:** `explore-npm-packaging` + +--- + +## Architecture decisions + +1. **Package location and name:** `web-libs/kandelo-web/`, published as + `@kandelo/web`. Matches the `web-libs/` layout and `host/`'s packaging + conventions. First use of the `@kandelo/*` scope in the repository. + +2. **Self-contained bundle, not a re-export of `wasm-posix-host`:** + `@kandelo/web` tsdown-bundles the browser-safe host modules from `../../host/src` + at build time. Consumers get no runtime dependency on `wasm-posix-host` — + only `fflate` and `fzstd`. + +3. **The crux — Vite-only module resolution.** Two host-runtime modules resolve + specifiers that only Vite understands, so the package build aliases both: + + | Host module | Vite-only specifiers | Package replacement | + |---|---|---| + | `browser-kernel-assets.ts` (new) | `./worker-entry-browser.ts?worker&url`, `./browser-kernel-worker-entry.ts?worker&url` | `src/assets-bundled.ts` — resolves both against the shipped `dist/` with `new URL(..., import.meta.url)`, which rolldown leaves intact for the consumer's bundler | + | `browser-kernel-default-artifacts.ts` (upstream) | `@kernel-wasm?url`, `@rootfs-vfs?url` | `src/default-artifacts-unbundled.ts` — throws, because the package ships no Wasm | + + `BrowserKernel` reads only `this.assets` (defaults merged with + `options.assets`), and `import.meta.env.BASE_URL` is read through a guarded + `importMetaBaseUrl()` because non-Vite bundlers do not inject it. + +4. **Main-thread VFS is the one capability a consumer cannot build itself.** + The kernel worker owns the filesystem, but it is `SharedArrayBuffer`-backed: + - `InitMessage.reportFsSab?` asks the worker to report that SAB. + - `ReadyMessage.fsSab?` carries it back. + - `BrowserKernel.hostFs` returns a `MemoryFileSystem.fromExisting(sab)` view + over the *same bytes* processes see — synchronous, no round-trip. + + **Opt-in via `BrowserKernelOptions.exposeHostFs`, and deliberately so.** + Holding the SAB makes the main thread a co-owner of the VFS. On WebKit a + co-owned buffer is reclaimed only when the page drops it, not by + `Worker.terminate()` — the accumulation the kernel-owned VFS exists to + prevent (the Safari image-switch OOM fix). `destroy()` releases the + reference alongside the framebuffer and PTY aliases. + + **Sync vs async:** Kandelo mandates COOP/COEP for `SharedArrayBuffer` + + `Atomics`, so the synchronous path is always available. There is + deliberately no async-message FS API. + +5. **Binaries are fetched, not bundled.** `fetchKandeloIndex` / + `fetchKandeloPackage` / `fetchKandeloBinaries`. The source is a first-class + parameter: a tag string, `{ repo, tag }` for a fork, `{ baseUrl }` for + self-hosted, or nothing for the ABI-matched default. The release + `abi_version` is verified against `ABI_VERSION` (throws on mismatch; + `allowAbiMismatch` opts out) and `archive_sha256` is verified before + unpacking. Caching is the consumer's choice through an injectable `fetch`. + `fetchKandeloBinaries` returns raw bytes (`kernelWasm: ArrayBuffer`, + `rootfsVfs: Uint8Array`) shaped for `boot()`, so it needs no DOM. + +6. **Dogfood without behavior change:** `apps/browser-demos` resolves + `@kandelo/web` to the package *source* entry through + `browserRepositoryAliases()` in `apps/browser-demos/browser-module-contract.mjs`. + That map is the single alias contract shared by Vite and the required-input + scanner, so a new alias cannot let the product import bytes the scanner + never sees. The source entry re-exports the same host modules, so demo + behavior is unchanged. + +--- + +## Compatibility boundary: the GitHub release under COEP + +GitHub's release CDN sends no `Access-Control-Allow-Origin` or +`Cross-Origin-Resource-Policy` headers, so a COEP `require-corp` page cannot +fetch the release cross-origin. Consumers must serve the binaries same-origin: +mirror them, proxy them, or inject a custom `fetch`. The scratch consumer +demonstrates a Vite dev proxy plus `baseUrl: "/kandelo-binaries/"`. Index +`archive_url` values are bare filenames, so they resolve against whatever base +the loader is pointed at. + +--- + +## Files changed + +**Host runtime (platform):** +- `host/src/browser-kernel-assets.ts` (new) — isolates the two `?worker&url` imports. +- `host/src/browser-kernel-host.ts` — reads `this.assets`; adds `options.assets`, + `options.exposeHostFs`, `get hostFs()`; guards `import.meta.env`; releases the + VFS SAB in `destroy()`. +- `host/src/browser-kernel-protocol.ts` — `InitMessage.reportFsSab?`, `ReadyMessage.fsSab?`. +- `host/src/browser-kernel-worker-entry.ts` — reports `fsSab` when asked. +- `host/test/browser-kernel.test.ts` — `hostFs` gating, round-trip, and release tests. + +**New package `web-libs/kandelo-web/`:** +- `package.json`, `tsdown.config.ts`, `tsconfig.json`, `.gitignore` +- `src/index.ts` (public re-exports + ABI constants), `src/fetch-binaries.ts`, + `src/assets-bundled.ts`, `src/default-artifacts-unbundled.ts`, `src/vite-shims.d.ts` +- `README.md`, `examples/scratch-consumer/` (excluded from the tarball by `files: ["dist"]`) + +**Consumers and glue:** +- `apps/browser-demos/browser-module-contract.mjs` — `@kandelo/web` alias. +- `apps/browser-demos/` — 11 importers repointed; `tsconfig.json` path. +- `package.json` (root) — `pack:web`, added to `pack:packages`. +- `web-libs/README.md` — lists the package. + +The ABI version is not duplicated in `package.json`. `ABI_VERSION`, +`BINARIES_ABI_VERSION`, `BINARIES_RELEASE_TAG`, and `binariesIndexUrl()` all +derive from the generated `host/src/generated/abi.ts`, so an ABI bump cannot +leave them stale. + +--- + +## Validation + +**Ran, passing:** +- `host/test/browser-kernel.test.ts` — 38/38, including 5 new `hostFs` tests + (default-off, SAB round-trip, stable view, pre-boot throw, release on destroy). +- `tests/package-system/browser-binary-dependencies.test.ts` — 26/26, exercising + the shared alias contract. +- Package `tsc --noEmit`, `tsdown` ESM + `.d.ts` build, `npm pack --dry-run` + (code-only tarball: `dist`, `README.md`, `package.json`). +- `dist/index.js` contains both `new URL(..., import.meta.url)` worker refs and + zero `@kernel-wasm` / `@rootfs-vfs` / `?worker&url` specifiers. +- Loader against the live `binaries-abi-v42` release from Node: index parses + (70 packages), `kernel` and `rootfs` archives fetch, sha256-verify, + decompress, and untar; `fetchKandeloBinaries()` returns a valid Wasm + `ArrayBuffer` (610623 bytes) and the rootfs image (16787687 bytes). + Confirmed the `php` archive carries `php.wasm`, `php-fpm.wasm`, `opcache.so`; + `composer` and `npm` are correctly absent as standalone entries. +- `apps/browser-demos` `tsc --noEmit`: 59 errors, byte-identical to upstream + before the change. The repoint introduces none. + +**NOT run:** +- Live browser acceptance under COOP/COEP (`examples/scratch-consumer`) — no + browser run was performed. The worker-spawn and kernel-boot path in a real + cross-origin-isolated page is unproven. +- Full `apps/browser-demos` Vite build (`./run.sh browser`). +- The full `host` Vitest suite. It needs a built `kernel.wasm`, a `sysroot64`, + and fetched package artifacts, none of which are present in this worktree; + the failures observed were all missing-prerequisite errors unrelated to these + changes. + +**Open risks to watch:** +- `hostFs` SAB growth: the worker's memfs SAB is growable, and a main-thread + `fromExisting` view should observe in-place growth. Confirm under a workload + that grows the filesystem. +- `optimizeDeps.exclude: ["@kandelo/web"]` is required in a consumer's Vite + config so the worker URLs resolve against `dist/` rather than esbuild's + prebundle. diff --git a/package.json b/package.json index c9357b57e0..97d06149e8 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "bench:php-mariadb": "npx tsx benchmarks/php-mariadb-transport.ts", "pack:host": "npm --prefix host pack", "pack:sdk": "npm --prefix sdk pack", - "pack:packages": "npm run pack:host && npm run pack:sdk" + "pack:web": "npm --prefix web-libs/kandelo-web pack", + "pack:packages": "npm run pack:host && npm run pack:sdk && npm run pack:web" }, "devDependencies": { "@babel/parser": "^7.29.7", diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 663c9bee3a..e522abed31 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "11ac0a666013acae5406d6dcdd567de3717461c9fba5b55c7f7e7d41134f3544", - "wasm64": "0f6c084e36809595b5e256cf2d3518e7cd59c22965948acc619a7cf4370f7bc6" + "wasm32": "001cd4b74c6a3ba2eae7a5cdf5acbe24cda047e415bfe8572ee086d1475c175c", + "wasm64": "c3cc306ef2174023d673e535199d019c482efe2e72e043426f4fcabeb8f5be71" } }, "less": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "7c0f698bf77a3173ed54ec327f578aa7b01fdfb116da760b377731d917f6ec57", - "wasm64": "77866cec48c08165378108eddf0c10bd8f87e5a98ced87f6bd580dac1ce17a70" + "wasm32": "17eb857ee121e776bbc408e2c4ec5b9938555b32b7363f4c6fe026f05ae56830", + "wasm64": "54eab6e35f4c9419cd6d54e86816ed771955a911f35bdb43b2bd581701ac1a74" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "8710fcfa952dec77d115a5960c848ea592a20863e31460ceeed124916f8a3316", - "wasm64": "ed51ebc72849c53d66654bf04a7bef44b08766ee4032874d6e5f23fa0c36fae5" + "wasm32": "54522ed19dca95b236760ebc1a3a3f033678bdf348cc060c5af4f9712a884b31", + "wasm64": "99f6d7fe281ca7511c60bdc242315b1e20d22e17fd43b56bf0fa3386ec35999e" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "35357c175d952462fedc5938287956ad00820b90e490acf63dc09d614701d6ff", - "wasm64": "863c5dd2f781dc14c1757f62f526c2961a25c0aa8079c19a6f6c211909a4ad76" + "wasm32": "46f54c1dabd432bae08a568a2d639f182c5a9942c1b758d76860f4361b7ae3c6", + "wasm64": "06720f87718d28e8705526ab6b8c6913e9f00ae137ca81ca425370801a54d703" } }, "openssl": { @@ -403,8 +403,8 @@ "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "ff81843d99a2699451a1657b159564926aada23bea704149f4e36aca038bf901", - "wasm64": "8c6e2e897c1e5b283399167fa67b78b0a540056cca82ad70153f0e06e354d87e" + "wasm32": "8ddf4f0e96353122ffccbceb52c43ac4efd0853ab1da891dbdc20490e9ed6ea4", + "wasm64": "5557e69c34e235ab9bd28067147fda3842b9d45ca08684f839551f84c90ca8cb" } }, "ruby": { @@ -424,8 +424,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "af79fcd3d0c8448c5578fc141b2837a579fbd194d054d9d45f9e916bbe66b727", - "wasm64": "4be23045e2a9a036d7cb716a81943d5b41093f4a1c788dd83053b3d59eb9bcac" + "wasm32": "80f0ed55011e8b60f39ebc77e28e779d0ddc3165e61cc53ad1dd454bf59df837", + "wasm64": "fe00c0de3707faed693b217bf4c89bd8d0b623333df6aa6f9ecf1612291a0631" } }, "spidermonkey": { @@ -515,8 +515,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "62141925b065b107685a949523be2d68426bb2bdd10028503afa30547aea959d", - "wasm64": "dad771c77666de103632cac75dbe1d61362e0dac430f8fe09b2486b2f7761631" + "wasm32": "a72974eaa691fd69824e53029c5ccecb82e75375a1abcd4b6b2a378049676ce6", + "wasm64": "8f01862853bd1e7315fe064031d8466a4c426ef22353939816e0a7022eb815be" } }, "xz": { @@ -1093,7 +1093,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "11ac0a666013acae5406d6dcdd567de3717461c9fba5b55c7f7e7d41134f3544" + "wasm32": "001cd4b74c6a3ba2eae7a5cdf5acbe24cda047e415bfe8572ee086d1475c175c" }, "dependencyClosures": { "wasm32": [ @@ -1165,7 +1165,7 @@ { "packageName": "shell", "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "af79fcd3d0c8448c5578fc141b2837a579fbd194d054d9d45f9e916bbe66b727" + "cacheKey": "80f0ed55011e8b60f39ebc77e28e779d0ddc3165e61cc53ad1dd454bf59df837" }, { "packageName": "sqlite", @@ -1718,7 +1718,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7c0f698bf77a3173ed54ec327f578aa7b01fdfb116da760b377731d917f6ec57" + "wasm32": "17eb857ee121e776bbc408e2c4ec5b9938555b32b7363f4c6fe026f05ae56830" }, "dependencyClosures": { "wasm32": [ @@ -1780,7 +1780,7 @@ { "packageName": "shell", "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "af79fcd3d0c8448c5578fc141b2837a579fbd194d054d9d45f9e916bbe66b727" + "cacheKey": "80f0ed55011e8b60f39ebc77e28e779d0ddc3165e61cc53ad1dd454bf59df837" }, { "packageName": "sqlite", @@ -1810,7 +1810,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8710fcfa952dec77d115a5960c848ea592a20863e31460ceeed124916f8a3316" + "wasm32": "54522ed19dca95b236760ebc1a3a3f033678bdf348cc060c5af4f9712a884b31" }, "dependencyClosures": { "wasm32": [ @@ -1832,7 +1832,7 @@ { "packageName": "shell", "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "af79fcd3d0c8448c5578fc141b2837a579fbd194d054d9d45f9e916bbe66b727" + "cacheKey": "80f0ed55011e8b60f39ebc77e28e779d0ddc3165e61cc53ad1dd454bf59df837" } ] }, @@ -1894,7 +1894,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "35357c175d952462fedc5938287956ad00820b90e490acf63dc09d614701d6ff" + "wasm32": "46f54c1dabd432bae08a568a2d639f182c5a9942c1b758d76860f4361b7ae3c6" }, "dependencyClosures": { "wasm32": [ @@ -1916,7 +1916,7 @@ { "packageName": "shell", "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "af79fcd3d0c8448c5578fc141b2837a579fbd194d054d9d45f9e916bbe66b727" + "cacheKey": "80f0ed55011e8b60f39ebc77e28e779d0ddc3165e61cc53ad1dd454bf59df837" }, { "packageName": "spidermonkey", @@ -2487,7 +2487,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ff81843d99a2699451a1657b159564926aada23bea704149f4e36aca038bf901" + "wasm32": "8ddf4f0e96353122ffccbceb52c43ac4efd0853ab1da891dbdc20490e9ed6ea4" }, "dependencyClosures": { "wasm32": [ @@ -2634,7 +2634,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "af79fcd3d0c8448c5578fc141b2837a579fbd194d054d9d45f9e916bbe66b727" + "wasm32": "80f0ed55011e8b60f39ebc77e28e779d0ddc3165e61cc53ad1dd454bf59df837" }, "dependencyClosures": { "wasm32": [] @@ -2926,7 +2926,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "62141925b065b107685a949523be2d68426bb2bdd10028503afa30547aea959d" + "wasm32": "a72974eaa691fd69824e53029c5ccecb82e75375a1abcd4b6b2a378049676ce6" }, "dependencyClosures": { "wasm32": [ @@ -2988,7 +2988,7 @@ { "packageName": "shell", "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "af79fcd3d0c8448c5578fc141b2837a579fbd194d054d9d45f9e916bbe66b727" + "cacheKey": "80f0ed55011e8b60f39ebc77e28e779d0ddc3165e61cc53ad1dd454bf59df837" }, { "packageName": "sqlite", diff --git a/scripts/ci-run-test-suite.sh b/scripts/ci-run-test-suite.sh index 74dbe31d6c..c997fa0227 100755 --- a/scripts/ci-run-test-suite.sh +++ b/scripts/ci-run-test-suite.sh @@ -363,6 +363,10 @@ case "$suite" in *) invalid_group ;; esac install_node_deps + ( + cd web-libs/kandelo-web + npm ci --no-audit --no-fund + ) # The ordinary shards exclude each whole declared file. Prove that the # manifest is a bijection with the file's live Vitest inventory before diff --git a/tests/scripts/ci-run-test-suite-groups.test.sh b/tests/scripts/ci-run-test-suite-groups.test.sh index bea9c3ba0f..2c98325c26 100755 --- a/tests/scripts/ci-run-test-suite-groups.test.sh +++ b/tests/scripts/ci-run-test-suite-groups.test.sh @@ -129,6 +129,7 @@ mkdir -p \ "$FIXTURE/apps/browser-demos" \ "$FIXTURE/apps/browser-demos/public" \ "$FIXTURE/crates/shared/src" \ + "$FIXTURE/web-libs/kandelo-web" \ "$FIXTURE/bin" cp \ "$REPO_ROOT/scripts/activate-ci-test-workspace.sh" \ diff --git a/web-libs/README.md b/web-libs/README.md index 4438d30e7b..8b058c9201 100644 --- a/web-libs/README.md +++ b/web-libs/README.md @@ -4,6 +4,12 @@ Reusable browser-facing libraries live here. - `kandelo-session/` owns boot descriptors, snapshots, and the `KernelHost` contract shared by the Kandelo web app and future embedders. +- `kandelo-web/` is the published **`@kandelo/web`** package — the browser + distribution of the host runtime (`BrowserKernel`, a synchronous host-side + VFS, and the kernel/process worker entries). It ships no Wasm; consumers + fetch ABI-matched binaries from a Kandelo binaries release. Downstream + browser projects depend on it instead of vendoring `host/src`. + `apps/browser-demos` dogfoods it. See `kandelo-web/README.md`. Concrete host runtime implementations stay in `host/`. Demo-only React wiring and fixtures stay in `apps/browser-demos/`. diff --git a/web-libs/kandelo-web/.gitignore b/web-libs/kandelo-web/.gitignore new file mode 100644 index 0000000000..e72899590b --- /dev/null +++ b/web-libs/kandelo-web/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.tgz diff --git a/web-libs/kandelo-web/README.md b/web-libs/kandelo-web/README.md new file mode 100644 index 0000000000..bd3fda7c47 --- /dev/null +++ b/web-libs/kandelo-web/README.md @@ -0,0 +1,226 @@ +# @kandelo/web + +The **browser distribution** of the [Kandelo](../../README.md) POSIX-compatible +WebAssembly kernel runtime. Install this package instead of vendoring `host/src` +or carrying a git submodule. + +It ships, as a Vite-friendly ESM package with `.d.ts` declarations: + +- **`BrowserKernel`** — the main-thread proxy that drives a dedicated kernel + worker. The worker owns the Wasm instance and all process lifecycle + (`fork`/`exec`/`clone`/`exit`); the main thread is a thin proxy for setup, UI, + and I/O routing. +- A **synchronous host-side VFS** reachable from the main thread + (`BrowserKernel.hostFs`) plus the VFS backends/helpers to build images. +- The **kernel & process worker** entries (code), referenced by default — a + consumer needs no `?worker&url` wiring. +- A **binaries loader** (`fetchKandeloBinaries` / `fetchKandeloPackage`). The + package ships **no Wasm artifacts**. The kernel wasm, `rootfs.vfs`, and + program binaries (`php.wasm`, ...) are fetched at runtime from a Kandelo + *binaries release* — the canonical one matching this build's ABI, a fork's + release, or any URL you host yourself. + +## Install + +```sh +npm install @kandelo/web +``` + +Kandelo requires **cross-origin isolation**. Serve your app with: + +``` +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +``` + +without which `SharedArrayBuffer` / `Atomics` are unavailable and the kernel +cannot run. + +In a Vite consumer, exclude the package from dep pre-bundling so Vite resolves +its bundled worker URLs against `dist/`: + +```ts +// vite.config.ts +export default { optimizeDeps: { exclude: ["@kandelo/web"] } }; +``` + +## Quick start + +```ts +import { + BrowserKernel, MemoryFileSystem, writeVfsBinary, ensureDirRecursive, + fetchKandeloBinaries, fetchKandeloPackage, +} from "@kandelo/web"; + +// 1. Fetch kernel + rootfs from the binaries release (see "Binaries" — the +// release must be reachable same-origin under COOP/COEP). +const { kernelWasm } = await fetchKandeloBinaries({ baseUrl: "/kandelo-binaries/" }); + +// 2. Fetch a program and install it into a VFS image. +const php = await fetchKandeloPackage("php", { baseUrl: "/kandelo-binaries/" }); +const fs = MemoryFileSystem.create(new SharedArrayBuffer(16 << 20, { maxByteLength: 512 << 20 }), 512 << 20); +ensureDirRecursive(fs, "/usr/bin"); +writeVfsBinary(fs, "/usr/bin/php", php.artifacts["php.wasm"], 0o755); +const vfsImage = await fs.saveImage(); + +// 3. Boot and run. The worker entries come from the package's own defaults. +const kernel = new BrowserKernel({ + onStdout: (d) => console.log(new TextDecoder().decode(d)), +}); +await kernel.boot({ kernelWasm, vfsImage, argv: ["/usr/bin/php", "--version"] }); + +const { exit } = await kernel.spawnFromVfs("/usr/bin/php", ["-v"]); +await exit; +``` + +A complete, runnable acceptance app lives in +[`examples/scratch-consumer`](./examples/scratch-consumer) (including the Vite +proxy that makes the release reachable same-origin). + +## Host-side filesystem: sync vs. async + +The runtime filesystem lives **inside the kernel worker**, but it is backed by a +`SharedArrayBuffer`. Pass `exposeHostFs: true` and the worker reports that SAB to +the main thread at boot, so **`BrowserKernel.hostFs` operates on the same bytes +the running processes see — synchronously, with no message round-trip**: + +```ts +const kernel = new BrowserKernel({ exposeHostFs: true }); +await kernel.boot({ kernelWasm, vfsImage }); + +const fd = kernel.hostFs.open("/tmp/x", 0o1101 /* O_WRONLY|O_CREAT|O_TRUNC */, 0o644); +kernel.hostFs.write(fd, new TextEncoder().encode("hi"), 0, 2); +kernel.hostFs.close(fd); +``` + +**Why opt-in?** Holding the SAB makes the main thread a co-owner of the VFS. On +WebKit a co-owned buffer is reclaimed only when the page drops it, not by +`Worker.terminate()`, so a host that swaps images repeatedly would accumulate +one VFS per boot. `destroy()` releases the reference. Leave the option off +unless you need main-thread filesystem access. + +`hostFs` is a `MemoryFileSystem`, which implements the full host +`FileSystemBackend`: `open`/`read`/`write`/`close`/`seek`/`fstat`/`stat`/ +`lstat`/`mkdir`/`rmdir`/`unlink`/`rename`/`readlink`/`symlink`/`chmod`/`chown` +plus `opendir`/`readdir`/`closedir`. Concurrent access between the main thread +and the kernel worker is coordinated by the SharedFS lock table, exactly as it +is between the kernel worker and its process workers. Wrap it in your own +ergonomic façade as needed. + +**Why no async API?** Because Kandelo already mandates COOP/COEP for +`SharedArrayBuffer` + `Atomics`, the synchronous SAB path is *always* available. +There is deliberately no async-message FS protocol to reason about — the host FS +is host-owned and synchronous. If a future host runs without cross-origin +isolation, that would be the boundary at which an async API becomes necessary; +today it is not. + +`hostFs` requires `exposeHostFs: true` and a booted kernel. It throws otherwise. + +## Binaries (fetched from a release) + +The package ships no Wasm. Wasm programs are bound to a kernel **ABI version**, +and the loader pulls ABI-matched artifacts from a *binaries release* — an +`index.toml` plus content-addressed `.tar.zst` archives (one per package). For +each requested package it fetches the archive, **verifies its `archive_sha256`**, +`fzstd`-decompresses it, untars it, and returns the `artifacts/*` payloads. + +```ts +import { fetchKandeloIndex, fetchKandeloPackage, fetchKandeloBinaries } from "@kandelo/web"; + +// kernel + rootfs bytes, shaped for boot(): +const { kernelWasm, rootfsVfs } = await fetchKandeloBinaries(source); +// one package's artifacts by basename: +const php = await fetchKandeloPackage("php", source); // php.artifacts["php.wasm"], ["php-fpm.wasm"], ... +// just the parsed index (ABI-checked): +const index = await fetchKandeloIndex(source); // reuse via { index } to avoid re-downloading +``` + +### Pointing at a source + +The binary source is a first-class, overridable parameter. Pass a string (= a +release tag on the canonical repo) or an options object: + +```ts +fetchKandeloBinaries("binaries-abi-v42"); // canonical repo, explicit tag +fetchKandeloBinaries({ repo: "myorg/my-fork" }); // a fork's GitHub release +fetchKandeloBinaries({ baseUrl: "/kandelo-binaries/" }); // self-hosted / proxied (any URL) +fetchKandeloBinaries(); // default repo + ABI-matched tag +``` + +The ABI this build speaks is exported. All three derive from the kernel's +generated `ABI_VERSION`, so they cannot drift from the runtime: + +```ts +import { ABI_VERSION, BINARIES_RELEASE_TAG, binariesIndexUrl } from "@kandelo/web"; +ABI_VERSION; // e.g. 42 +BINARIES_RELEASE_TAG; // "binaries-abi-v42" +binariesIndexUrl(); // canonical index.toml URL +``` + +`fetchKandeloIndex` reads the release's `abi_version` and **throws on mismatch** +with `ABI_VERSION` (a kernel/program built for another ABI cannot run). Pass +`allowAbiMismatch: true` to override. + +### ⚠️ Cross-origin isolation vs. the GitHub release + +Because Kandelo mandates COOP/COEP (`require-corp`) and GitHub's release CDN +sends **no `Access-Control-Allow-Origin` / `Cross-Origin-Resource-Policy`** +headers, a browser **cannot `fetch()` the GitHub release directly**. Serve the +binaries **same-origin** instead: + +- mirror/copy the release behind your own origin, or +- proxy it (see the dev proxy in `examples/scratch-consumer/vite.config.ts`) and + point the loader at the proxy via `baseUrl`, or +- inject a custom `fetch` (the caching/proxy hook) that routes the request. + +### Caching + +The loader does plain `fetch()` and stays cache-agnostic — caching is **your** +choice. Inject a `fetch` to layer CacheStorage / IndexedDB / auth however you +like; it gets the standard `(input, init)` signature: + +```ts +await fetchKandeloBinaries({ baseUrl, fetch: myCachingFetch }); +``` + +### Programs in the release + +Each package archive holds its declared outputs under `artifacts/`: e.g. the +`php` package archive contains `php.wasm`, `php-fpm.wasm`, and `opcache.so`. +Other packages: **node**, **git**, **bash**, **coreutils**, **rootfs** +(`rootfs.vfs`), **kernel** (`kandelo-kernel.wasm`), and the libraries they +depend on. Mapping an artifact to a VFS path (e.g. `/usr/bin/php`) is the +consumer's job — build the VFS image with the helpers above. + +Not standalone Wasm binaries in the release: + +- **composer** — a PHP `.phar` application; ship `composer.phar` and run it with + the `php` binary. +- **npm** — distributed inside the Node VFS image (`node-vfs`), not as its own + `npm.wasm`. + +## Build + +```sh +npm --prefix web-libs/kandelo-web install +npm --prefix web-libs/kandelo-web run build # tsdown (ESM + .d.ts); no binaries staged +npm --prefix web-libs/kandelo-web pack # code-only tarball +``` + +`build` runs `tsdown` only. It swaps the two host-runtime modules that only +Vite can resolve: + +- `browser-kernel-assets` → [`src/assets-bundled.ts`](./src/assets-bundled.ts), + so the published `BrowserKernel` resolves its **worker** entries relative to + the package. +- `browser-kernel-default-artifacts` → + [`src/default-artifacts-unbundled.ts`](./src/default-artifacts-unbundled.ts). + The package ships no Wasm, so booting without explicit bytes throws an + actionable error instead of fetching a default that does not exist. + +## Relationship to the host runtime + +`@kandelo/web` is the browser face of the platform's host runtime +(`host/src`). It bundles the browser-safe host modules at build time. The repo's +`apps/browser-demos` dogfoods this package (aliased to the package source) to +keep the public surface honest. diff --git a/web-libs/kandelo-web/examples/scratch-consumer/index.html b/web-libs/kandelo-web/examples/scratch-consumer/index.html new file mode 100644 index 0000000000..61999e07cc --- /dev/null +++ b/web-libs/kandelo-web/examples/scratch-consumer/index.html @@ -0,0 +1,14 @@ + + + + + @kandelo/web scratch consumer + + +

@kandelo/web scratch consumer

+

Acceptance: boot the kernel, run /usr/bin/php -v, and + read/write a VFS file from the main thread.

+

+    
+  
+
diff --git a/web-libs/kandelo-web/examples/scratch-consumer/package-lock.json b/web-libs/kandelo-web/examples/scratch-consumer/package-lock.json
new file mode 100644
index 0000000000..cd9dc2ac73
--- /dev/null
+++ b/web-libs/kandelo-web/examples/scratch-consumer/package-lock.json
@@ -0,0 +1,801 @@
+{
+  "name": "kandelo-web-scratch-consumer",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "kandelo-web-scratch-consumer",
+      "version": "0.0.0",
+      "dependencies": {
+        "@kandelo/web": "file:../.."
+      },
+      "devDependencies": {
+        "vite": "^8.0.0"
+      }
+    },
+    "../..": {
+      "name": "@kandelo/web",
+      "version": "0.1.0",
+      "license": "GPL-2.0-or-later",
+      "dependencies": {
+        "fflate": "^0.8.2",
+        "fzstd": "^0.1.1"
+      },
+      "devDependencies": {
+        "@types/node": "^25.3.5",
+        "tsdown": "^0.22.14",
+        "typescript": "^5.4.0"
+      }
+    },
+    "node_modules/@kandelo/web": {
+      "resolved": "../..",
+      "link": true
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.143.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
+      "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
+      "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
+      "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
+      "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
+      "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
+      "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
+      "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
+      "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
+      "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
+      "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
+      "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
+      "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
+      "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
+      "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
+      "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.18",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+      "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.26",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.17",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
+      "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.143.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.2.3",
+        "@rolldown/binding-darwin-arm64": "1.2.3",
+        "@rolldown/binding-darwin-x64": "1.2.3",
+        "@rolldown/binding-freebsd-x64": "1.2.3",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
+        "@rolldown/binding-linux-arm64-gnu": "1.2.3",
+        "@rolldown/binding-linux-arm64-musl": "1.2.3",
+        "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
+        "@rolldown/binding-linux-s390x-gnu": "1.2.3",
+        "@rolldown/binding-linux-x64-gnu": "1.2.3",
+        "@rolldown/binding-linux-x64-musl": "1.2.3",
+        "@rolldown/binding-openharmony-arm64": "1.2.3",
+        "@rolldown/binding-win32-arm64-msvc": "1.2.3",
+        "@rolldown/binding-win32-x64-msvc": "1.2.3"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/vite": {
+      "version": "8.2.1",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+      "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "lightningcss": "^1.33.0",
+        "picomatch": "^4.0.5",
+        "postcss": "^8.5.25",
+        "rolldown": "~1.2.1",
+        "tinyglobby": "^0.2.17"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.4.0",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    }
+  }
+}
diff --git a/web-libs/kandelo-web/examples/scratch-consumer/package.json b/web-libs/kandelo-web/examples/scratch-consumer/package.json
new file mode 100644
index 0000000000..517880d16e
--- /dev/null
+++ b/web-libs/kandelo-web/examples/scratch-consumer/package.json
@@ -0,0 +1,18 @@
+{
+  "name": "kandelo-web-scratch-consumer",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "description": "Minimal Vite app proving @kandelo/web installs and runs from a clean project (acceptance harness).",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@kandelo/web": "file:../.."
+  },
+  "devDependencies": {
+    "vite": "^8.0.0"
+  }
+}
diff --git a/web-libs/kandelo-web/examples/scratch-consumer/src/main.ts b/web-libs/kandelo-web/examples/scratch-consumer/src/main.ts
new file mode 100644
index 0000000000..cc9e62d49f
--- /dev/null
+++ b/web-libs/kandelo-web/examples/scratch-consumer/src/main.ts
@@ -0,0 +1,104 @@
+/**
+ * Acceptance harness for @kandelo/web, consumed from a clean Vite project.
+ *
+ * Proves, with nothing imported beyond the published package:
+ *   1. `import { BrowserKernel, fetchKandeloBinaries } from "@kandelo/web"` resolves.
+ *   2. The loader fetches kernel + rootfs + php from the binaries release
+ *      (index.toml -> .tar.zst -> sha256 verify -> fzstd -> untar). NO binaries
+ *      are bundled in the package.
+ *   3. The kernel boots (worker + fetched kernel.wasm) with a VFS image.
+ *   4. `spawnFromVfs("/usr/bin/php", ["-v"])` runs php, printing to onStdout.
+ *   5. A file is written AND read back in the VFS from the MAIN THREAD via the
+ *      public synchronous host FS API (`kernel.hostFs`).
+ *
+ * To point at a fork or self-hosted binaries instead of the canonical release,
+ * pass a source: `fetchKandeloBinaries({ repo: "myorg/fork" })` or
+ * `fetchKandeloBinaries({ baseUrl: "/bins/" })`.
+ */
+import {
+  BrowserKernel,
+  MemoryFileSystem,
+  writeVfsBinary,
+  ensureDirRecursive,
+  fetchKandeloIndex,
+  fetchKandeloBinaries,
+  fetchKandeloPackage,
+  BINARIES_RELEASE_TAG,
+} from "@kandelo/web";
+
+const outEl = document.getElementById("out")!;
+const print = (s: string) => {
+  outEl.textContent += s;
+};
+const line = (s: string) => print(s + "\n");
+
+async function main() {
+  line(`@kandelo/web — fetching binaries release: ${BINARIES_RELEASE_TAG}`);
+
+  // The canonical release lives on GitHub, but its CDN sends no CORS/CORP
+  // headers, so under COEP:require-corp the browser cannot fetch it
+  // cross-origin. This app proxies it same-origin at /kandelo-binaries/
+  // (see vite.config.ts) and points the loader there via `baseUrl`.
+  const source = { baseUrl: "/kandelo-binaries/" };
+
+  // 1. Fetch the release index once, then pull the kernel and the php program
+  //    archive from it. All ABI-verified and sha256-checked.
+  const index = await fetchKandeloIndex(source);
+  line(`index ok (abi ${index.abiVersion}, ${index.packages.size} packages)`);
+
+  const { kernelWasm } = await fetchKandeloBinaries({ index });
+  line(`fetched kernel.wasm (${kernelWasm.byteLength} bytes)`);
+
+  const php = await fetchKandeloPackage("php", { index });
+  const phpWasm = php.artifacts["php.wasm"];
+  if (!phpWasm) throw new Error("php archive missing artifacts/php.wasm");
+  line(`fetched php ${php.version}-rev${php.revision} (php.wasm ${phpWasm.byteLength} bytes)`);
+
+  // 2. Build a VFS image with php installed at /usr/bin/php.
+  const sab = new SharedArrayBuffer(16 * 1024 * 1024, {
+    maxByteLength: 512 * 1024 * 1024,
+  });
+  const fs = MemoryFileSystem.create(sab, 512 * 1024 * 1024);
+  ensureDirRecursive(fs, "/usr/bin");
+  writeVfsBinary(fs, "/usr/bin/php", phpWasm, 0o755);
+  const vfsImage = await fs.saveImage();
+  line(`built VFS image (${vfsImage.byteLength} bytes) with /usr/bin/php`);
+
+  // 3. Boot the kernel worker with the fetched kernel bytes. The worker entries
+  //    come from the package's own bundled defaults — no `?worker&url` wiring.
+  const kernel = new BrowserKernel({
+    // Step 5 reads and writes the live VFS from the main thread. Off by
+    // default because it makes the main thread a co-owner of the VFS SAB.
+    exposeHostFs: true,
+    onStdout: (d) => print(new TextDecoder().decode(d)),
+    onStderr: (d) => print(new TextDecoder().decode(d)),
+  });
+  await kernel.boot({ kernelWasm, vfsImage, argv: ["/usr/bin/php", "--version"] });
+
+  // 4. Run php -v explicitly through spawnFromVfs (reads the binary out of the
+  //    kernel-owned VFS — no bytes shipped across the worker boundary).
+  line("\n--- spawnFromVfs('/usr/bin/php', ['-v']) ---");
+  const { exit } = await kernel.spawnFromVfs("/usr/bin/php", ["-v"]);
+  const code = await exit;
+  line(`\n[php exited ${code}]`);
+
+  // 5. Main-thread VFS round-trip via the public synchronous host FS API.
+  const path = "/tmp/hello-from-main.txt";
+  const payload = new TextEncoder().encode("written from the main thread\n");
+  ensureDirRecursive(kernel.hostFs, "/tmp");
+  const fd = kernel.hostFs.open(path, /* O_WRONLY|O_CREAT|O_TRUNC */ 0o1101, 0o644);
+  kernel.hostFs.write(fd, payload, 0, payload.length);
+  kernel.hostFs.close(fd);
+
+  const rfd = kernel.hostFs.open(path, /* O_RDONLY */ 0, 0);
+  const buf = new Uint8Array(256);
+  const n = kernel.hostFs.read(rfd, buf, null, buf.length);
+  kernel.hostFs.close(rfd);
+  const readBack = new TextDecoder().decode(buf.subarray(0, n));
+  line(`\nhostFs round-trip ${path}: ${JSON.stringify(readBack)}`);
+  line(readBack === "written from the main thread\n" ? "✅ ACCEPTANCE PASSED" : "❌ mismatch");
+}
+
+main().catch((err) => {
+  line("\n❌ " + (err?.stack ?? String(err)));
+});
diff --git a/web-libs/kandelo-web/examples/scratch-consumer/vite.config.ts b/web-libs/kandelo-web/examples/scratch-consumer/vite.config.ts
new file mode 100644
index 0000000000..90013d22bf
--- /dev/null
+++ b/web-libs/kandelo-web/examples/scratch-consumer/vite.config.ts
@@ -0,0 +1,49 @@
+import { defineConfig } from "vite";
+import { ABI_VERSION } from "../../../../host/src/generated/abi";
+
+// SharedArrayBuffer + Atomics require cross-origin isolation. Kandelo cannot
+// run without these headers — they are part of the platform contract, not an
+// optimization.
+const crossOriginIsolation = {
+  "Cross-Origin-Opener-Policy": "same-origin",
+  "Cross-Origin-Embedder-Policy": "require-corp",
+};
+
+// GitHub release assets are served from release-assets.githubusercontent.com
+// with NO `Access-Control-Allow-Origin` / `Cross-Origin-Resource-Policy`
+// headers, so a cross-origin `fetch()` from this COEP:require-corp page is
+// blocked by CORS. The binaries must therefore be reached **same-origin**.
+// We proxy `/kandelo-binaries/*` to the canonical release here; the app points
+// `fetchKandelo*` at this same-origin base. A real consumer would mirror the
+// release behind its own origin (or inject a custom `fetch` that proxies).
+// Derived from the kernel's generated ABI version, the same source the package's
+// BINARIES_RELEASE_TAG uses, so an ABI bump cannot leave this proxy stale.
+const RELEASE_BASE =
+  `https://github.com/Automattic/kandelo/releases/download/binaries-abi-v${ABI_VERSION}`;
+const binariesProxy = {
+  "/kandelo-binaries": {
+    target: RELEASE_BASE,
+    changeOrigin: true,
+    followRedirects: true,
+    rewrite: (p: string) => p.replace(/^\/kandelo-binaries/, ""),
+  },
+};
+
+export default defineConfig({
+  // `fs.allow` is only needed because this example consumes the package via a
+  // `file:../..` symlink outside the Vite root: the worker files are fetched
+  // as raw `/@fs` URLs (not module-graph imports), which the allow list
+  // guards. A consumer installing from npm has them under `node_modules` and
+  // needs none of this.
+  server: {
+    headers: crossOriginIsolation,
+    proxy: binariesProxy,
+    fs: { allow: [".", "../.."] },
+  },
+  preview: { headers: crossOriginIsolation, proxy: binariesProxy },
+  // @kandelo/web ships its own pre-bundled module workers that it references
+  // via `new URL(..., import.meta.url)`. Excluding it from dep-prebundling lets
+  // Vite resolve those worker URLs against the package's dist/ instead of
+  // esbuild's optimized cache.
+  optimizeDeps: { exclude: ["@kandelo/web"] },
+});
diff --git a/web-libs/kandelo-web/package-lock.json b/web-libs/kandelo-web/package-lock.json
new file mode 100644
index 0000000000..19a333a369
--- /dev/null
+++ b/web-libs/kandelo-web/package-lock.json
@@ -0,0 +1,1113 @@
+{
+  "name": "@kandelo/web",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "@kandelo/web",
+      "version": "0.1.0",
+      "license": "GPL-2.0-or-later",
+      "dependencies": {
+        "fflate": "^0.8.2",
+        "fzstd": "^0.1.1"
+      },
+      "devDependencies": {
+        "@types/node": "^25.3.5",
+        "tsdown": "^0.22.14",
+        "typescript": "^5.4.0"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.143.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
+      "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@quansync/fs": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
+      "integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "quansync": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
+      "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
+      "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
+      "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
+      "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
+      "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
+      "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
+      "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
+      "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
+      "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
+      "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
+      "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
+      "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
+      "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
+      "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "25.9.3",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
+      "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": ">=7.24.0 <7.24.7"
+      }
+    },
+    "node_modules/@yuku-codegen/binding-android-arm64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-android-arm64/-/binding-android-arm64-0.8.4.tgz",
+      "integrity": "sha512-rsYkGl2kOkDRsh1mxriYnk1qBS78vjlBJ3+T2XwtwKwqOliy2n+2Ae0EDxJ/uX1DZLm3KkBZarGO5isEnmHchA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-darwin-arm64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-arm64/-/binding-darwin-arm64-0.8.4.tgz",
+      "integrity": "sha512-tNLKzPF3FYmEcHSYvWp/LEpjHHAtDR13hwo6/gdCkYMi9x59CWn2obczKzWNe7kDor4/1AMZuJDEVRpFkTSefw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-darwin-x64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-x64/-/binding-darwin-x64-0.8.4.tgz",
+      "integrity": "sha512-tK7LWzXNb5JbZpnoCNHB0nEhPFss/LwwehM6m/f0oYDan+iZgFZXzdoy80JdE7dVxjTZDIhUNPx8X8xbIdaoxA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-freebsd-x64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-freebsd-x64/-/binding-freebsd-x64-0.8.4.tgz",
+      "integrity": "sha512-5MUV4d7g2p5Hd8GiXW6ynTRgYjm4Dw4eM2gaWRZ4crkGetzNx+HlPxcEfGtVNPqH5Qaa4Z2REtzdsdgvaE6/Ng==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-linux-arm-gnu": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.8.4.tgz",
+      "integrity": "sha512-g6LnHrR0Rfqq5cXs7olwR2+LlVDc876pw7Hh7YXbukVSiBxQkaxqsEO/trO25z2g99zWzgXwoYvQZ1TnwA2wEw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-linux-arm-musl": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-musl/-/binding-linux-arm-musl-0.8.4.tgz",
+      "integrity": "sha512-7XAPHrROPEFuJWXEGZeLZQN4xR8ENQ65+HSCtCHjSzCwGgnX53GUjM9ExVcHopV0a5g4vu57v2wwtyWIM5iNOQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-linux-arm64-gnu": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.8.4.tgz",
+      "integrity": "sha512-fnBm7NLuuwFXy7F1vRIuyOc+RW9ADUjuCKVGY87Dj8jtr9XgESNrwb+B9VLSFY7nZ0rCdK/Sm1fBqJpI7eLdKQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-linux-arm64-musl": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.8.4.tgz",
+      "integrity": "sha512-6vTw4ZHO9nm4SUkw36uG+UE6/qifZ0E8HIef7Mx/U/c2Zxu3JLBfXtU7U/NN2GMkDmcJkgwjXfpQoYw4Ch5Y1w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-linux-x64-gnu": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.8.4.tgz",
+      "integrity": "sha512-+vuC3V3Lw+DB4oJgHV9pVDfQZlsZJnxmbdods7HzxgEALU3P5+czwdAcw3wfh7Ebabt0Ny8eLUb1k9RV5OB/+w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-linux-x64-musl": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-musl/-/binding-linux-x64-musl-0.8.4.tgz",
+      "integrity": "sha512-QH60PE4eZecmgNGa1/T1cKPhrfxt6ANtu4lrQ1FZ50F9b0GS9WjGmIjrfdSUeQa+f2Iqk3oEFSJdgVHBg1KPNg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-win32-arm64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-arm64/-/binding-win32-arm64-0.8.4.tgz",
+      "integrity": "sha512-6r68c0nKZPIBRXZIBiD7zjlEukBR+xRxpTOVj4n1Fsjcdh4YbbJfjFfmIzdbo9jXR1xL+dPueDVdsRGOUH5MoQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@yuku-codegen/binding-win32-x64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-x64/-/binding-win32-x64-0.8.4.tgz",
+      "integrity": "sha512-i+BW77LPjNqe7Apq50J3OeEaVfga3G+eT2bKjb6bj4yO99fil/jnKb4ZDH4JLvby/Q7hRa6VicL2EZ7iS+ifzA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-android-arm64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-android-arm64/-/binding-android-arm64-0.8.4.tgz",
+      "integrity": "sha512-+HIMmv08Zrh9ugIAEMnKBMMePOl7CDxrjc8Vui1+GG2TJHM1yI1+3wo1pnXB6Nj2IiHugDkQw8ycUI6SA2EUkQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-darwin-arm64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.8.4.tgz",
+      "integrity": "sha512-Elf/B/2m3OsyvxoQnBk8Dtu+9csHkzBNs5Yv9GbHjT3x0kVKNWjFusyZgm41VwxcPDqdpRi8tWxNX7OqXkmf/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-darwin-x64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-x64/-/binding-darwin-x64-0.8.4.tgz",
+      "integrity": "sha512-CjZuMoXnL5XUkVpDqh4WDPwpAw8CwmtHHnTerGkS45So/sNuwkXdyIAEqqIZfaLopi5W/V9NApAT2md9XizjsQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-freebsd-x64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.8.4.tgz",
+      "integrity": "sha512-ibLKORdz71iI4Vs+fyFgvwQ51P5XcxJIyQLa8cSEWqwptRdo+BTcZHIQEcZnFDPUuvmJ19RRH9CoiJdfhr7pZw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-linux-arm-gnu": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.8.4.tgz",
+      "integrity": "sha512-Fo3r5fYhGDcFnl+KN+L9PgtiQPS4AIE1n1mG1o5jZ11p7g5yZ/1EjLFmSHAUsoXreun8KjTsFjEL7P1Sb93PZQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-linux-arm-musl": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-musl/-/binding-linux-arm-musl-0.8.4.tgz",
+      "integrity": "sha512-BEB31vUEgXPWf7WkoMPSzzJhpC/wWCBXyysRCCPsw47BJ/OtbQsvJbxX9fFDuSRLy3kbyIV/WbdUTgbQ9COxiw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-linux-arm64-gnu": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.8.4.tgz",
+      "integrity": "sha512-xGLCRcHn9xVz7JVNyyKtiNJSf503qtUmih9XVSsghgzOmiKUMnHObs69OMMwXN3788tg1jsl11fNjjQlB8idMA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-linux-arm64-musl": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.8.4.tgz",
+      "integrity": "sha512-3kNRi8NJT2q6FQRVCUFHIQ99+kXdi8cVJEEUi6+xtFqpMgNrZNnbgAj28ILsDC7zmclaU+v47eUCeJoKLSCbww==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-linux-x64-gnu": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.8.4.tgz",
+      "integrity": "sha512-isi62oMy94Z3OXwGs2l2rkqRiRyqLmfHeTRHpA/uWZbsNhnm7IdVvkF7e7wHNKAtd5jwGzOqYSroxKv2fOm7Cw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-linux-x64-musl": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.8.4.tgz",
+      "integrity": "sha512-9RsEw2xYHqU/pjSRBTOupWN3sF8uz9stjJdARfz6o0llvF+yfrj5QHmTiocGGBeAI80fvKmDtr2RIQClUzAPcA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-win32-arm64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-arm64/-/binding-win32-arm64-0.8.4.tgz",
+      "integrity": "sha512-VEZHo9rEGOBKR20sA3vCO00aQvwWND5aLu7YxeX+YupMZJh9hd1f17AbClJN37Q2iL1PCHht+wDTOKX6tZYqXg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@yuku-parser/binding-win32-x64": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-x64/-/binding-win32-x64-0.8.4.tgz",
+      "integrity": "sha512-PeH3VzN1feGjPtDpVEAqf000fPT+nxtw/696LKp/5Z9RJi/MaXpB636QC+5QtrAPSoEnIkyEe+c+mq2QLZPWBA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@yuku-toolchain/types": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.8.4.tgz",
+      "integrity": "sha512-p7JE8flrj7ijZ/qLjHi4UwKqMarMD6zumbKXhrjp2I2iLJOuTYiQyci2U36VlXcUlNyzsY7E/mLnKCHotbzJVw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ansis": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz",
+      "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/cac": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz",
+      "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      }
+    },
+    "node_modules/defu": {
+      "version": "6.1.7",
+      "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+      "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/dts-resolver": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-3.0.0.tgz",
+      "integrity": "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^22.18.0 || >=24.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      },
+      "peerDependencies": {
+        "oxc-resolver": ">=11.0.0"
+      },
+      "peerDependenciesMeta": {
+        "oxc-resolver": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/empathic": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz",
+      "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fflate": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+      "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+      "license": "MIT"
+    },
+    "node_modules/fzstd": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz",
+      "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==",
+      "license": "MIT"
+    },
+    "node_modules/get-tsconfig": {
+      "version": "5.0.0-beta.5",
+      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.5.tgz",
+      "integrity": "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "resolve-pkg-maps": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=20.20.0"
+      },
+      "funding": {
+        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+      }
+    },
+    "node_modules/hookable": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz",
+      "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/import-without-cache": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.4.0.tgz",
+      "integrity": "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^22.18.0 || >=24.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      }
+    },
+    "node_modules/obug": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+      "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/sxzz",
+        "https://opencollective.com/debug"
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/quansync": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz",
+      "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/antfu"
+        },
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/sxzz"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/resolve-pkg-maps": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+      }
+    },
+    "node_modules/rolldown": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
+      "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@oxc-project/types": "=0.143.0",
+        "@rolldown/pluginutils": "^1.0.0"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.2.3",
+        "@rolldown/binding-darwin-arm64": "1.2.3",
+        "@rolldown/binding-darwin-x64": "1.2.3",
+        "@rolldown/binding-freebsd-x64": "1.2.3",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
+        "@rolldown/binding-linux-arm64-gnu": "1.2.3",
+        "@rolldown/binding-linux-arm64-musl": "1.2.3",
+        "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
+        "@rolldown/binding-linux-s390x-gnu": "1.2.3",
+        "@rolldown/binding-linux-x64-gnu": "1.2.3",
+        "@rolldown/binding-linux-x64-musl": "1.2.3",
+        "@rolldown/binding-openharmony-arm64": "1.2.3",
+        "@rolldown/binding-win32-arm64-msvc": "1.2.3",
+        "@rolldown/binding-win32-x64-msvc": "1.2.3"
+      }
+    },
+    "node_modules/rolldown-plugin-dts": {
+      "version": "0.27.14",
+      "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.27.14.tgz",
+      "integrity": "sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dts-resolver": "^3.0.0",
+        "get-tsconfig": "5.0.0-beta.5",
+        "obug": "^2.1.4",
+        "yuku-ast": "^0.8.0",
+        "yuku-codegen": "^0.8.0",
+        "yuku-parser": "^0.8.0"
+      },
+      "engines": {
+        "node": "^22.18.0 || >=24.11.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      },
+      "peerDependencies": {
+        "@typescript/native-preview": "*",
+        "@volar/typescript": "~2.4.0",
+        "rolldown": "^1.0.0",
+        "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0",
+        "vue-tsc": "~3.2.0 || ~3.3.0"
+      },
+      "peerDependenciesMeta": {
+        "@typescript/native-preview": {
+          "optional": true
+        },
+        "@volar/typescript": {
+          "optional": true
+        },
+        "typescript": {
+          "optional": true
+        },
+        "vue-tsc": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tinyexec": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+      "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tree-kill": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+      "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "tree-kill": "cli.js"
+      }
+    },
+    "node_modules/tsdown": {
+      "version": "0.22.14",
+      "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.22.14.tgz",
+      "integrity": "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansis": "^4.3.1",
+        "cac": "^7.0.0",
+        "defu": "^6.1.7",
+        "empathic": "^2.0.1",
+        "hookable": "^6.1.1",
+        "import-without-cache": "^0.4.0",
+        "obug": "^2.1.4",
+        "picomatch": "^4.0.5",
+        "rolldown": "~1.2.0",
+        "rolldown-plugin-dts": "^0.27.13",
+        "tinyexec": "^1.2.4",
+        "tinyglobby": "^0.2.17",
+        "tree-kill": "^1.2.2",
+        "unconfig-core": "^7.5.0",
+        "verkit": "^0.3.0"
+      },
+      "bin": {
+        "tsdown": "dist/run.mjs"
+      },
+      "engines": {
+        "node": "^22.18.0 || >=24.11.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      },
+      "peerDependencies": {
+        "@arethetypeswrong/core": "^0.18.1",
+        "@tsdown/css": "0.22.14",
+        "@tsdown/exe": "0.22.14",
+        "@vitejs/devtools": "*",
+        "publint": "^0.3.8",
+        "tsx": "*",
+        "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0",
+        "unplugin-unused": "^0.5.0",
+        "unrun": "*"
+      },
+      "peerDependenciesMeta": {
+        "@arethetypeswrong/core": {
+          "optional": true
+        },
+        "@tsdown/css": {
+          "optional": true
+        },
+        "@tsdown/exe": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "publint": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "typescript": {
+          "optional": true
+        },
+        "unplugin-unused": {
+          "optional": true
+        },
+        "unrun": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/unconfig-core": {
+      "version": "7.5.0",
+      "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz",
+      "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@quansync/fs": "^1.0.0",
+        "quansync": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.24.6",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+      "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/verkit": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.3.2.tgz",
+      "integrity": "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sxzz"
+      }
+    },
+    "node_modules/yuku-ast": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/yuku-ast/-/yuku-ast-0.8.4.tgz",
+      "integrity": "sha512-s7EWfWIQkaGmsGnyr/BU0jli9YTN5TvrKIsSmALyRD9elumDQInuhv0BrVObENKVCxr9W3Ikmnx5u02KvfuUmw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@yuku-toolchain/types": "^0.8.4"
+      }
+    },
+    "node_modules/yuku-codegen": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/yuku-codegen/-/yuku-codegen-0.8.4.tgz",
+      "integrity": "sha512-1Rw+NYcmB1xkHAWlsIpbwIv/Fr50idtEbLf7OjDA+90dny6PM4Krz7Fs0TT+w2PBdjaldZpN4ye5wR4Dhlm8vA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@yuku-toolchain/types": "^0.8.4"
+      },
+      "optionalDependencies": {
+        "@yuku-codegen/binding-android-arm64": "0.8.4",
+        "@yuku-codegen/binding-darwin-arm64": "0.8.4",
+        "@yuku-codegen/binding-darwin-x64": "0.8.4",
+        "@yuku-codegen/binding-freebsd-x64": "0.8.4",
+        "@yuku-codegen/binding-linux-arm-gnu": "0.8.4",
+        "@yuku-codegen/binding-linux-arm-musl": "0.8.4",
+        "@yuku-codegen/binding-linux-arm64-gnu": "0.8.4",
+        "@yuku-codegen/binding-linux-arm64-musl": "0.8.4",
+        "@yuku-codegen/binding-linux-x64-gnu": "0.8.4",
+        "@yuku-codegen/binding-linux-x64-musl": "0.8.4",
+        "@yuku-codegen/binding-win32-arm64": "0.8.4",
+        "@yuku-codegen/binding-win32-x64": "0.8.4"
+      }
+    },
+    "node_modules/yuku-parser": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.8.4.tgz",
+      "integrity": "sha512-sw41wouvT5rUmLIp87hmvm5vtF+MRSI3x6yjq6xqpYmtkQj+Ht6N7xRQ8lMhLv8N7JAzughGj0Rfi0jQRSu9HQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@yuku-toolchain/types": "^0.8.4",
+        "yuku-ast": "^0.8.4"
+      },
+      "optionalDependencies": {
+        "@yuku-parser/binding-android-arm64": "0.8.4",
+        "@yuku-parser/binding-darwin-arm64": "0.8.4",
+        "@yuku-parser/binding-darwin-x64": "0.8.4",
+        "@yuku-parser/binding-freebsd-x64": "0.8.4",
+        "@yuku-parser/binding-linux-arm-gnu": "0.8.4",
+        "@yuku-parser/binding-linux-arm-musl": "0.8.4",
+        "@yuku-parser/binding-linux-arm64-gnu": "0.8.4",
+        "@yuku-parser/binding-linux-arm64-musl": "0.8.4",
+        "@yuku-parser/binding-linux-x64-gnu": "0.8.4",
+        "@yuku-parser/binding-linux-x64-musl": "0.8.4",
+        "@yuku-parser/binding-win32-arm64": "0.8.4",
+        "@yuku-parser/binding-win32-x64": "0.8.4"
+      }
+    }
+  }
+}
diff --git a/web-libs/kandelo-web/package.json b/web-libs/kandelo-web/package.json
new file mode 100644
index 0000000000..2f30d094df
--- /dev/null
+++ b/web-libs/kandelo-web/package.json
@@ -0,0 +1,35 @@
+{
+  "name": "@kandelo/web",
+  "version": "0.1.0",
+  "description": "Browser distribution of the Kandelo POSIX-compatible WebAssembly kernel runtime: BrowserKernel, a synchronous host-side VFS, and a loader that fetches the kernel/rootfs/program Wasm from a Kandelo binaries release.",
+  "license": "GPL-2.0-or-later",
+  "type": "module",
+  "module": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "default": "./dist/index.js"
+    },
+    "./package.json": "./package.json"
+  },
+  "sideEffects": false,
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsdown",
+    "dev": "tsdown --watch",
+    "typecheck": "tsc --noEmit",
+    "prepack": "npm run build"
+  },
+  "devDependencies": {
+    "@types/node": "^25.3.5",
+    "tsdown": "^0.22.14",
+    "typescript": "^5.4.0"
+  },
+  "dependencies": {
+    "fflate": "^0.8.2",
+    "fzstd": "^0.1.1"
+  }
+}
diff --git a/web-libs/kandelo-web/src/assets-bundled.ts b/web-libs/kandelo-web/src/assets-bundled.ts
new file mode 100644
index 0000000000..46d26937f0
--- /dev/null
+++ b/web-libs/kandelo-web/src/assets-bundled.ts
@@ -0,0 +1,26 @@
+/**
+ * Packaged worker-entry URLs for `@kandelo/web`.
+ *
+ * The package build (see `tsdown.config.ts`) aliases the host runtime's
+ * `browser-kernel-assets` module to this file. It resolves the two entries the
+ * package actually ships against the published `dist/` via
+ * `new URL(..., import.meta.url)`. rolldown leaves those expressions intact
+ * (the targets do not exist next to the source at build time); the consuming
+ * bundler rewrites them to the correct hashed worker URLs, and serves them
+ * as-is in dev.
+ *
+ * Layout these resolve against, relative to `dist/index.js`:
+ *   dist/worker-entry-browser.js          (per-process worker entry)
+ *   dist/browser-kernel-worker-entry.js   (dedicated kernel worker entry)
+ *
+ * The kernel wasm and rootfs image are not here and are not shipped; see
+ * `default-artifacts-unbundled.ts` and `fetch-binaries.ts`.
+ */
+import type { BrowserKernelAssets } from "../../../host/src/browser-kernel-host";
+
+export type { BrowserKernelAssets };
+
+export const BROWSER_KERNEL_ASSETS: BrowserKernelAssets = {
+  processWorkerUrl: new URL("./worker-entry-browser.js", import.meta.url).href,
+  kernelWorkerUrl: new URL("./browser-kernel-worker-entry.js", import.meta.url).href,
+};
diff --git a/web-libs/kandelo-web/src/default-artifacts-unbundled.ts b/web-libs/kandelo-web/src/default-artifacts-unbundled.ts
new file mode 100644
index 0000000000..1e02ecfe99
--- /dev/null
+++ b/web-libs/kandelo-web/src/default-artifacts-unbundled.ts
@@ -0,0 +1,30 @@
+/**
+ * Packaged replacement for the host runtime's
+ * `browser-kernel-default-artifacts` module.
+ *
+ * In the repository that module resolves `@kernel-wasm` / `@rootfs-vfs` — Vite
+ * aliases that point at the demo build's artifacts. `@kandelo/web` ships no
+ * Wasm, so there is no default kernel or rootfs to hand out. The package build
+ * (see `tsdown.config.ts`) aliases the module here.
+ *
+ * Reaching this code means a caller booted without supplying bytes. That is a
+ * real misconfiguration, so it fails with an actionable message instead of
+ * fetching an empty URL. Obtain the bytes from a binaries release with
+ * {@link fetchKandeloBinaries} and pass them to `boot()` / `initFromImage()`.
+ */
+function unavailable(what: string): never {
+  throw new Error(
+    `@kandelo/web ships no ${what}. Fetch ABI-matched binaries with ` +
+      `fetchKandeloBinaries() and pass them explicitly, e.g. ` +
+      `kernel.boot({ kernelWasm, vfsImage }).`,
+  );
+}
+
+export const browserKernelDefaultArtifactUrls = {
+  get kernelWasm(): string {
+    return unavailable("kernel wasm");
+  },
+  get rootfsVfs(): string {
+    return unavailable("rootfs.vfs image");
+  },
+};
diff --git a/web-libs/kandelo-web/src/fetch-binaries.ts b/web-libs/kandelo-web/src/fetch-binaries.ts
new file mode 100644
index 0000000000..f2efe47e8f
--- /dev/null
+++ b/web-libs/kandelo-web/src/fetch-binaries.ts
@@ -0,0 +1,467 @@
+/**
+ * Release-fetch loader for `@kandelo/web`.
+ *
+ * The npm package ships **code only** — no kernel wasm, no rootfs image, no
+ * program binaries. The Wasm artifacts live in a Kandelo *binaries release*
+ * (a GitHub release, a fork's release, or any URL serving the same layout) and
+ * are fetched at runtime by the functions here.
+ *
+ * A binaries release is an `index.toml` plus content-addressed `.tar.zst`
+ * archives, one per package:
+ *
+ *   index.toml                                  (abi_version + per-package entries)
+ *   kernel-0.1.0-rev1-abi15-wasm32-.tar.zst   -> artifacts/kandelo-kernel.wasm
+ *   rootfs-0.1.0-rev4-abi15-wasm32-.tar.zst   -> artifacts/rootfs.vfs
+ *   php-8.3.2-rev3-abi15-wasm32-.tar.zst      -> artifacts/php.wasm, ...
+ *
+ * `index.toml` records `archive_url` (relative to the release base) and
+ * `archive_sha256` for each package/arch. The loader fetches the archive,
+ * verifies its sha256, `fzstd`-decompresses it, untars it (ustar), and returns
+ * the `artifacts/*` payloads.
+ *
+ * ## Binary source is a first-class parameter
+ *
+ * Nothing here hardcodes the canonical repo. Point the loader at:
+ *   - `{ repo: "myorg/my-fork", tag: "binaries-abi-v42" }` — a fork's release,
+ *   - `{ baseUrl: "https://cdn.example/bins/" }` — self-built binaries hosted
+ *     anywhere (static server, local dev server, S3, ...),
+ *   - nothing — defaults to `Automattic/kandelo` + the tag matching the ABI
+ *     this build of `@kandelo/web` speaks.
+ *
+ * ## Caching is the consumer's choice
+ *
+ * The loader performs plain `fetch()` and stays cache-agnostic. Inject a custom
+ * `fetch` (the `fetch` option) to layer CacheStorage / IndexedDB / auth / a
+ * proxy however you like — it receives the same `(url, init)` signature.
+ *
+ * ## ABI safety
+ *
+ * Because an external source can hold a different ABI, the loader compares the
+ * release's `abi_version` against this package's {@link ABI_VERSION} and throws
+ * on mismatch unless `allowAbiMismatch` is set. A kernel/program built for a
+ * different ABI cannot run; surfacing that as a loud failure is the ABI
+ * contract, not a convenience.
+ */
+import { decompress as zstdDecompress } from "fzstd";
+
+import { ABI_VERSION } from "../../../host/src/generated/abi";
+
+/** The release tag holding ABI-matched binaries for this build: `binaries-abi-v`. */
+const BINARIES_RELEASE_TAG = `binaries-abi-v${ABI_VERSION}`;
+
+/** Where a binaries release lives. Resolution precedence: `baseUrl` > `repo`+`tag`. */
+export interface KandeloBinarySource {
+  /**
+   * Full base URL that hosts `index.toml` and the archives, e.g.
+   * `"https://cdn.example/kandelo/abi15/"`. Takes precedence over `repo`/`tag`.
+   * A trailing slash is added if missing.
+   */
+  baseUrl?: string;
+  /** GitHub `owner/repo` whose release holds the binaries. Default `"Automattic/kandelo"`. */
+  repo?: string;
+  /** Release tag. Default {@link BINARIES_RELEASE_TAG} (matches this package's ABI). */
+  tag?: string;
+}
+
+/** Target architecture for the fetched artifacts. */
+export type KandeloArch = "wasm32" | "wasm64";
+
+export interface KandeloFetchOptions extends KandeloBinarySource {
+  /** Target arch. Default `"wasm32"`. */
+  arch?: KandeloArch;
+  /**
+   * Custom fetch — the injection point for caching, auth, or proxying. Receives
+   * the standard `(input, init)` signature. Default `globalThis.fetch`.
+   */
+  fetch?: typeof fetch;
+  /** Skip `archive_sha256` verification. Default `false` (verification on). */
+  skipIntegrityCheck?: boolean;
+  /**
+   * Load binaries whose release `abi_version` differs from this package's
+   * {@link ABI_VERSION}. Default `false` — a mismatch throws.
+   */
+  allowAbiMismatch?: boolean;
+  /** A pre-fetched index to reuse (avoids re-downloading `index.toml`). */
+  index?: KandeloReleaseIndex;
+}
+
+/** One package's binary entry for a single arch, parsed from `index.toml`. */
+export interface KandeloIndexEntry {
+  name: string;
+  version: string;
+  revision: number;
+  status: string;
+  /** Archive URL as written in the index (may be relative to the release base). */
+  archiveUrl: string;
+  archiveSha256: string;
+}
+
+/** Parsed `index.toml` for one release. */
+export interface KandeloReleaseIndex {
+  /** ABI version the release was built for. */
+  abiVersion: number;
+  /** Absolute base URL the archive URLs resolve against. */
+  baseUrl: string;
+  /** Arch these entries were filtered to. */
+  arch: KandeloArch;
+  /** Package name -> binary entry (for {@link arch}). */
+  packages: Map;
+}
+
+/** The contents of one fetched package archive. */
+export interface KandeloPackageArtifacts {
+  name: string;
+  version: string;
+  revision: number;
+  /** Artifact basename (e.g. `"php.wasm"`) -> bytes, from the archive's `artifacts/`. */
+  artifacts: Record;
+}
+
+/** The kernel and rootfs bytes, shaped for `BrowserKernel.boot()`. */
+export interface KandeloBinaries {
+  /** Kernel module bytes, for `boot({ kernelWasm })`. */
+  kernelWasm: ArrayBuffer;
+  /** Root filesystem image, for `boot({ vfsImage })`. */
+  rootfsVfs: Uint8Array;
+}
+
+const DEFAULT_REPO = "Automattic/kandelo";
+
+/** Normalize the `string | options` argument the public functions accept. */
+function normalizeOptions(arg?: string | KandeloFetchOptions): KandeloFetchOptions {
+  return typeof arg === "string" ? { tag: arg } : { ...(arg ?? {}) };
+}
+
+/**
+ * Resolve the release base URL (always trailing-slashed) from a source. A
+ * relative `baseUrl` — the same-origin proxy pattern — resolves against the
+ * page URL, so that archive URLs derived from it later (`new URL(archive,
+ * base)`) have the absolute base the URL constructor requires.
+ */
+function resolveBaseUrl(src: KandeloBinarySource): string {
+  if (src.baseUrl) {
+    const base = src.baseUrl.endsWith("/") ? src.baseUrl : `${src.baseUrl}/`;
+    const pageHref = (globalThis as { location?: { href: string } }).location?.href;
+    return pageHref ? new URL(base, pageHref).href : base;
+  }
+  const repo = src.repo ?? DEFAULT_REPO;
+  const tag = src.tag ?? BINARIES_RELEASE_TAG;
+  return `https://github.com/${repo}/releases/download/${tag}/`;
+}
+
+function getFetch(opts: KandeloFetchOptions): typeof fetch {
+  const f = opts.fetch ?? (globalThis as { fetch?: typeof fetch }).fetch;
+  if (!f) {
+    throw new Error(
+      "@kandelo/web: no fetch available. Pass a `fetch` implementation in options " +
+        "(the injection point for caching/auth/proxying).",
+    );
+  }
+  return f;
+}
+
+async function fetchBytes(f: typeof fetch, url: string, what: string): Promise {
+  const res = await f(url);
+  if (!res.ok) {
+    throw new Error(`@kandelo/web: failed to fetch ${what} from ${url}: ${res.status} ${res.statusText}`);
+  }
+  return new Uint8Array(await res.arrayBuffer());
+}
+
+async function sha256Hex(bytes: Uint8Array): Promise {
+  const subtle = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto?.subtle;
+  if (!subtle) {
+    throw new Error(
+      "@kandelo/web: SubtleCrypto unavailable for archive_sha256 verification. " +
+        "Run in a secure context, or pass `skipIntegrityCheck: true` to opt out.",
+    );
+  }
+  const digest = await subtle.digest("SHA-256", bytes as unknown as ArrayBuffer);
+  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+// ── Minimal TOML reader for the index ────────────────────────────────────────
+//
+// The index is machine-generated and regular; a purpose-built reader avoids a
+// TOML dependency. We only need: a top-level `abi_version`, repeated
+// `[[packages]]` tables (name/version/revision), and their
+// `[packages.binary.]` subtables (status/archive_url/archive_sha256).
+
+function parseScalar(raw: string): string | number {
+  const v = raw.trim();
+  if (v.startsWith('"')) {
+    const end = v.lastIndexOf('"');
+    return end > 0 ? v.slice(1, end) : v.slice(1);
+  }
+  if (/^-?\d+$/.test(v)) return parseInt(v, 10);
+  return v;
+}
+
+interface ParsedIndex {
+  abiVersion: number;
+  packages: Map }>;
+}
+
+function parseIndexToml(text: string): ParsedIndex {
+  let abiVersion = NaN;
+  const packages = new Map }>();
+
+  type Section = "root" | "package" | "binary";
+  let section: Section = "root";
+  let pkg: { name: string; version: string; revision: number; binary: Map } | undefined;
+  let arch: string | undefined;
+  // Staging for the current binary subtable before we know its name (name comes
+  // from the enclosing package, already known).
+  let bin: Partial | undefined;
+
+  const flushPkg = () => {
+    if (pkg) packages.set(pkg.name, { version: pkg.version, revision: pkg.revision, binary: pkg.binary });
+  };
+  const flushBin = () => {
+    if (pkg && arch && bin) {
+      pkg.binary.set(arch, {
+        name: pkg.name,
+        version: pkg.version,
+        revision: pkg.revision,
+        status: String(bin.status ?? ""),
+        archiveUrl: String(bin.archiveUrl ?? ""),
+        archiveSha256: String(bin.archiveSha256 ?? ""),
+      });
+    }
+    bin = undefined;
+  };
+
+  for (const rawLine of text.split("\n")) {
+    const line = rawLine.trim();
+    if (!line || line.startsWith("#")) continue;
+
+    if (line === "[[packages]]") {
+      flushBin();
+      flushPkg();
+      pkg = { name: "", version: "", revision: 0, binary: new Map() };
+      arch = undefined;
+      section = "package";
+      continue;
+    }
+
+    const binMatch = /^\[packages\.binary\.([A-Za-z0-9_]+)\]$/.exec(line);
+    if (binMatch) {
+      flushBin();
+      arch = binMatch[1];
+      bin = {};
+      section = "binary";
+      continue;
+    }
+
+    if (line.startsWith("[")) {
+      // Some other table; ignore until the next [[packages]] / binary subtable.
+      flushBin();
+      section = "root";
+      continue;
+    }
+
+    const eq = line.indexOf("=");
+    if (eq < 0) continue;
+    const key = line.slice(0, eq).trim();
+    const value = parseScalar(line.slice(eq + 1));
+
+    if (section === "root") {
+      if (key === "abi_version") abiVersion = Number(value);
+    } else if (section === "package" && pkg) {
+      if (key === "name") pkg.name = String(value);
+      else if (key === "version") pkg.version = String(value);
+      else if (key === "revision") pkg.revision = Number(value);
+    } else if (section === "binary" && bin) {
+      if (key === "status") bin.status = String(value);
+      else if (key === "archive_url") bin.archiveUrl = String(value);
+      else if (key === "archive_sha256") bin.archiveSha256 = String(value);
+    }
+  }
+  flushBin();
+  flushPkg();
+
+  return { abiVersion, packages };
+}
+
+// ── Minimal ustar reader ─────────────────────────────────────────────────────
+//
+// The archives are plain (ustar/GNU) tar inside zstd. Headers are 512 bytes;
+// data is padded to 512. Two consecutive zero blocks end the stream. We keep
+// only regular files and honor the ustar `prefix` field for long paths.
+
+function readTarString(block: Uint8Array, offset: number, length: number): string {
+  let end = offset;
+  const limit = offset + length;
+  while (end < limit && block[end] !== 0) end++;
+  return new TextDecoder().decode(block.subarray(offset, end));
+}
+
+function untar(buf: Uint8Array): Array<{ name: string; data: Uint8Array }> {
+  const out: Array<{ name: string; data: Uint8Array }> = [];
+  let off = 0;
+  while (off + 512 <= buf.length) {
+    const header = buf.subarray(off, off + 512);
+    // All-zero block marks end of archive.
+    let allZero = true;
+    for (let i = 0; i < 512; i++) {
+      if (header[i] !== 0) {
+        allZero = false;
+        break;
+      }
+    }
+    if (allZero) break;
+
+    const name = readTarString(header, 0, 100);
+    const sizeStr = readTarString(header, 124, 12).trim();
+    const size = sizeStr ? parseInt(sizeStr, 8) : 0;
+    const typeflag = header[156];
+    const prefix = readTarString(header, 345, 155);
+    const fullName = prefix ? `${prefix}/${name}` : name;
+
+    const dataStart = off + 512;
+    // typeflag '0' or NUL => regular file.
+    if ((typeflag === 0x30 || typeflag === 0) && name) {
+      out.push({ name: fullName, data: buf.subarray(dataStart, dataStart + size) });
+    }
+    off = dataStart + Math.ceil(size / 512) * 512;
+  }
+  return out;
+}
+
+// ── Public API ───────────────────────────────────────────────────────────────
+
+/**
+ * Fetch and parse a release's `index.toml`. Verifies the release's
+ * `abi_version` against this package's {@link ABI_VERSION} (unless
+ * `allowAbiMismatch`).
+ */
+export async function fetchKandeloIndex(
+  source?: string | KandeloFetchOptions,
+): Promise {
+  const opts = normalizeOptions(source);
+  const f = getFetch(opts);
+  const arch: KandeloArch = opts.arch ?? "wasm32";
+  const baseUrl = resolveBaseUrl(opts);
+
+  const text = new TextDecoder().decode(await fetchBytes(f, `${baseUrl}index.toml`, "index.toml"));
+  const parsed = parseIndexToml(text);
+
+  if (!Number.isFinite(parsed.abiVersion)) {
+    throw new Error(`@kandelo/web: ${baseUrl}index.toml has no abi_version`);
+  }
+  if (!opts.allowAbiMismatch && parsed.abiVersion !== ABI_VERSION) {
+    throw new Error(
+      `@kandelo/web: ABI mismatch — this build speaks ABI ${ABI_VERSION} but the ` +
+        `release at ${baseUrl} is ABI ${parsed.abiVersion}. Binaries built for a ` +
+        `different ABI cannot run. Point at an ABI-${ABI_VERSION} release, or pass ` +
+        `allowAbiMismatch: true if you know what you are doing.`,
+    );
+  }
+
+  const packages = new Map();
+  for (const [name, pkg] of parsed.packages) {
+    const entry = pkg.binary.get(arch);
+    if (entry) packages.set(name, entry);
+  }
+
+  return { abiVersion: parsed.abiVersion, baseUrl, arch, packages };
+}
+
+/**
+ * Fetch one package's archive from a release, verify its `archive_sha256`,
+ * decompress + untar it, and return the `artifacts/*` payloads keyed by
+ * basename (e.g. `"php.wasm"`, `"php-fpm.wasm"`, `"opcache.so"`).
+ */
+export async function fetchKandeloPackage(
+  name: string,
+  source?: string | KandeloFetchOptions,
+): Promise {
+  const opts = normalizeOptions(source);
+  const f = getFetch(opts);
+  const index = opts.index ?? (await fetchKandeloIndex(opts));
+
+  const entry = index.packages.get(name);
+  if (!entry) {
+    throw new Error(
+      `@kandelo/web: package "${name}" (${index.arch}) not found in ${index.baseUrl}index.toml`,
+    );
+  }
+  if (entry.status !== "success") {
+    throw new Error(
+      `@kandelo/web: package "${name}" (${index.arch}) has status "${entry.status}" in the index`,
+    );
+  }
+
+  const archiveUrl = new URL(entry.archiveUrl, index.baseUrl).href;
+  const archive = await fetchBytes(f, archiveUrl, `archive for "${name}"`);
+
+  if (!opts.skipIntegrityCheck) {
+    const actual = await sha256Hex(archive);
+    if (actual !== entry.archiveSha256) {
+      throw new Error(
+        `@kandelo/web: archive_sha256 mismatch for "${name}" (${archiveUrl})\n` +
+          `  expected ${entry.archiveSha256}\n  actual   ${actual}`,
+      );
+    }
+  }
+
+  const tar = zstdDecompress(archive);
+  const artifacts: Record = {};
+  for (const f2 of untar(tar)) {
+    const m = /^artifacts\/(.+)$/.exec(f2.name);
+    if (m) artifacts[m[1]] = f2.data;
+  }
+
+  return { name: entry.name, version: entry.version, revision: entry.revision, artifacts };
+}
+
+/**
+ * Fetch the kernel and rootfs from a binaries release and return their bytes,
+ * shaped for `BrowserKernel.boot()`.
+ *
+ * ```ts
+ * const { kernelWasm, rootfsVfs } = await fetchKandeloBinaries();
+ * const kernel = new BrowserKernel({ onStdout });
+ * await kernel.boot({ kernelWasm, vfsImage: rootfsVfs });
+ * ```
+ *
+ * Point at a fork or self-hosted binaries with the source options:
+ * `fetchKandeloBinaries({ repo: "myorg/fork" })` or
+ * `fetchKandeloBinaries({ baseUrl: "https://cdn.example/bins/" })`.
+ */
+export async function fetchKandeloBinaries(
+  source?: string | KandeloFetchOptions,
+): Promise {
+  const opts = normalizeOptions(source);
+  const index = opts.index ?? (await fetchKandeloIndex(opts));
+  const withIndex: KandeloFetchOptions = { ...opts, index };
+
+  const [kernelPkg, rootfsPkg] = await Promise.all([
+    fetchKandeloPackage("kernel", withIndex),
+    fetchKandeloPackage("rootfs", withIndex),
+  ]);
+
+  const kernelWasm = kernelPkg.artifacts["kandelo-kernel.wasm"];
+  const rootfsVfs = rootfsPkg.artifacts["rootfs.vfs"];
+  if (!kernelWasm) {
+    throw new Error(
+      `@kandelo/web: kernel archive has no artifacts/kandelo-kernel.wasm (got: ${Object.keys(kernelPkg.artifacts).join(", ")})`,
+    );
+  }
+  if (!rootfsVfs) {
+    throw new Error(
+      `@kandelo/web: rootfs archive has no artifacts/rootfs.vfs (got: ${Object.keys(rootfsPkg.artifacts).join(", ")})`,
+    );
+  }
+
+  // `boot()` takes the kernel as an ArrayBuffer and the image as a Uint8Array.
+  // Slice the kernel out of its tar-backed buffer so the caller owns one whole
+  // buffer, which is also what the ownership-transferring boot path requires.
+  return {
+    kernelWasm: kernelWasm.buffer.slice(
+      kernelWasm.byteOffset,
+      kernelWasm.byteOffset + kernelWasm.byteLength,
+    ) as ArrayBuffer,
+    rootfsVfs,
+  };
+}
diff --git a/web-libs/kandelo-web/src/index.ts b/web-libs/kandelo-web/src/index.ts
new file mode 100644
index 0000000000..79331a04a8
--- /dev/null
+++ b/web-libs/kandelo-web/src/index.ts
@@ -0,0 +1,140 @@
+/**
+ * `@kandelo/web` — the browser distribution of the Kandelo POSIX kernel
+ * runtime. A downstream browser project (e.g. a web IDE) installs this package
+ * instead of vendoring `host/src` or carrying a git submodule.
+ *
+ * The package ships:
+ *   - {@link BrowserKernel}: the main-thread proxy that drives the dedicated
+ *     kernel worker (the worker owns the Wasm instance and all process
+ *     lifecycle — fork/exec/clone/exit). Boots from a VFS image and runs
+ *     binaries already present in the VFS via `spawnFromVfs`.
+ *   - A synchronous, host-side VFS reachable from the main thread
+ *     (`BrowserKernel.hostFs`) plus the VFS backends needed to build images.
+ *   - The kernel/process **worker entries** (code), referenced by default — no
+ *     `?worker&url` wiring required in a Vite consumer.
+ *   - A **binaries loader** (`fetchKandeloBinaries` / `fetchKandeloPackage`).
+ *     The package ships NO Wasm artifacts; the kernel wasm, `rootfs.vfs`, and
+ *     program binaries (`php.wasm`, ...) are fetched at runtime from a Kandelo
+ *     binaries release — the canonical one matching this build's ABI, a fork's
+ *     release, or any URL you host yourself.
+ *
+ * ## Host-side filesystem: sync vs. async
+ *
+ * The runtime filesystem lives inside the kernel worker, but it is backed by a
+ * `SharedArrayBuffer`. Pass `exposeHostFs: true` and the worker reports that SAB
+ * to the main thread at boot, so `BrowserKernel.hostFs` operates on the *same
+ * bytes* the running processes see — **synchronously, with no message
+ * round-trip**. Kandelo already requires cross-origin isolation (COOP/COEP) for
+ * `SharedArrayBuffer` + `Atomics`, so this synchronous path is always available;
+ * there is no async-message API to fall back to. This is the one capability a
+ * consumer cannot build for itself, and it is intended to be wrapped in the
+ * consumer's own ergonomic FS facade.
+ *
+ * It is opt-in because holding the SAB makes the main thread a co-owner of the
+ * VFS, which on WebKit defers reclamation from `Worker.terminate()` to page GC.
+ *
+ * @packageDocumentation
+ */
+
+// ── Kernel + option/boot types ──
+export { BrowserKernel } from "../../../host/src/browser-kernel-host";
+export type {
+  BrowserKernelOptions,
+  BrowserKernelBootOptions,
+  BrowserKernelAssets,
+  HttpRequest,
+  HttpResponse,
+} from "../../../host/src/browser-kernel-host";
+
+// ── Process / trace types surfaced by BrowserKernel methods ──
+export type { ProcessSnapshot, SyscallTraceEvent } from "../../../host/src/kernel-worker";
+export type { StatResult, StatfsResult } from "../../../host/src/types";
+
+// ── VFS backends + the host-side filesystem contract ──
+//
+// `BrowserKernel.hostFs` returns a `MemoryFileSystem`, which implements the
+// full `FileSystemBackend` surface (open/read/write/close/seek/fstat/stat/
+// lstat/mkdir/rmdir/unlink/rename/readlink/symlink/chmod/chown plus
+// opendir/readdir/closedir). The backends and image helpers below let a
+// consumer build the VFS image it boots from.
+export { MemoryFileSystem } from "../../../host/src/vfs/memory-fs";
+export { DeviceFileSystem } from "../../../host/src/vfs/device-fs";
+export { OpfsFileSystem } from "../../../host/src/vfs/opfs";
+export { VirtualPlatformIO } from "../../../host/src/vfs/vfs";
+export { loadVfsImage } from "../../../host/src/vfs/load-image";
+export {
+  writeVfsFile,
+  writeVfsBinary,
+  ensureDir,
+  ensureDirRecursive,
+  symlink as vfsSymlink,
+} from "../../../host/src/vfs/image-helpers";
+export {
+  DEFAULT_MOUNT_SPEC,
+  resolveForBrowser,
+} from "../../../host/src/vfs/default-mounts";
+export type {
+  FileSystemBackend,
+  DirEntry,
+  MountConfig,
+  TimeProvider,
+} from "../../../host/src/vfs/types";
+export type {
+  LazyDownloadEvent,
+  LazyDownloadKind,
+  LazyDownloadListener,
+  LazyDownloadStatus,
+  LazyFileEntry,
+  VfsImageMetadata,
+  VfsImageOptions,
+} from "../../../host/src/vfs/memory-fs";
+
+// ── Default asset URLs ──
+//
+// In the published package this carries only the bundled worker-entry URLs;
+// `kernelWasmUrl`/`rootfsVfsUrl` are empty because the package ships no Wasm —
+// obtain those from `fetchKandeloBinaries()`. Exposed as an escape hatch for
+// custom worker hosting.
+export { BROWSER_KERNEL_ASSETS } from "../../../host/src/browser-kernel-assets";
+
+// ── Binaries loader (fetch kernel/rootfs/programs from a release) ──
+export {
+  fetchKandeloIndex,
+  fetchKandeloPackage,
+  fetchKandeloBinaries,
+} from "./fetch-binaries";
+export type {
+  KandeloBinarySource,
+  KandeloFetchOptions,
+  KandeloArch,
+  KandeloIndexEntry,
+  KandeloReleaseIndex,
+  KandeloPackageArtifacts,
+  KandeloBinaries,
+} from "./fetch-binaries";
+
+// ── Binaries ABI version ──
+//
+// Downstream projects fetch matching Wasm binaries from the Kandelo release
+// tagged `binaries-abi-v`. These constants let a consumer compute
+// the right tag / index URL without hardcoding the number. They derive from
+// the generated `ABI_VERSION`, so a kernel ABI bump cannot leave them stale.
+import { ABI_VERSION } from "../../../host/src/generated/abi";
+
+/** The kernel ABI version this build of `@kandelo/web` speaks. */
+export { ABI_VERSION };
+
+/** Alias of {@link ABI_VERSION}, named for the binaries-fetch use case. */
+export const BINARIES_ABI_VERSION: number = ABI_VERSION;
+
+/** The GitHub release tag that holds ABI-matched binaries: `binaries-abi-v`. */
+export const BINARIES_RELEASE_TAG = `binaries-abi-v${ABI_VERSION}` as const;
+
+/**
+ * URL of the binary index for the ABI this package speaks. Mirrors the
+ * `index_url` template in each package's `build.toml`. `repo` defaults to the
+ * canonical upstream; override it to point at a fork's releases.
+ */
+export function binariesIndexUrl(repo = "Automattic/kandelo"): string {
+  return `https://github.com/${repo}/releases/download/${BINARIES_RELEASE_TAG}/index.toml`;
+}
diff --git a/web-libs/kandelo-web/src/vite-shims.d.ts b/web-libs/kandelo-web/src/vite-shims.d.ts
new file mode 100644
index 0000000000..826d7c7cb8
--- /dev/null
+++ b/web-libs/kandelo-web/src/vite-shims.d.ts
@@ -0,0 +1,17 @@
+/**
+ * Ambient declarations for the Vite-style asset imports used by the host
+ * runtime's `browser-kernel-assets` and `browser-kernel-default-artifacts`
+ * modules. These let the `.d.ts` generation pass — which does not run the
+ * build-time alias swap — typecheck the host browser graph. At runtime in the
+ * published package these specifiers are never evaluated; the build aliases
+ * both modules to package-local replacements.
+ */
+declare module "*?url" {
+  const url: string;
+  export default url;
+}
+
+declare module "*?worker&url" {
+  const url: string;
+  export default url;
+}
diff --git a/web-libs/kandelo-web/test/dist-contract.test.ts b/web-libs/kandelo-web/test/dist-contract.test.ts
new file mode 100644
index 0000000000..1ee0f1c6d7
--- /dev/null
+++ b/web-libs/kandelo-web/test/dist-contract.test.ts
@@ -0,0 +1,91 @@
+import { execFileSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { beforeAll, describe, expect, it } from "vitest";
+
+const PACKAGE_DIR = path.resolve(__dirname, "..");
+const DIST = path.join(PACKAGE_DIR, "dist");
+
+const WORKER_FILES = [
+  "worker-entry-browser.js",
+  "browser-kernel-worker-entry.js",
+];
+
+function distFile(name: string): string {
+  return readFileSync(path.join(DIST, name), "utf8");
+}
+
+function staticImportSpecifiers(code: string): string[] {
+  return [...code.matchAll(/^import\b[^;]*?from\s*"([^"]+)"/gm)].map(
+    (m) => m[1],
+  );
+}
+
+function dynamicImportSpecifiers(code: string): string[] {
+  return [...code.matchAll(/\bimport\("([^"]+)"\)/g)].map((m) => m[1]);
+}
+
+describe("dist contract", () => {
+  beforeAll(() => {
+    execFileSync(path.join(PACKAGE_DIR, "node_modules", ".bin", "tsdown"), [], {
+      cwd: PACKAGE_DIR,
+      stdio: "pipe",
+    });
+  }, 120_000);
+
+  it("emits the three entries and the declaration", () => {
+    for (const name of ["index.js", "index.d.ts", ...WORKER_FILES]) {
+      expect(distFile(name).length).toBeGreaterThan(0);
+    }
+  });
+
+  it("index.js resolves both worker entries against dist via new URL", () => {
+    const index = distFile("index.js");
+    for (const worker of WORKER_FILES) {
+      expect(index).toContain(`new URL("./${worker}", import.meta.url)`);
+    }
+  });
+
+  it("index.js imports only the declared runtime dependencies", () => {
+    expect(staticImportSpecifiers(distFile("index.js")).sort()).toEqual([
+      "fflate",
+      "fzstd",
+    ]);
+  });
+
+  it("worker entries are self-contained module-worker assets", () => {
+    for (const worker of WORKER_FILES) {
+      const code = distFile(worker);
+      expect(staticImportSpecifiers(code)).toEqual([]);
+      const dynamic = dynamicImportSpecifiers(code).filter(
+        (specifier) => specifier !== "net",
+      );
+      expect(dynamic).toEqual([]);
+    }
+  });
+
+  it("no Vite-only specifier survives as an import", () => {
+    for (const name of ["index.js", ...WORKER_FILES]) {
+      const code = distFile(name);
+      const specifiers = [
+        ...staticImportSpecifiers(code),
+        ...dynamicImportSpecifiers(code),
+      ];
+      for (const specifier of specifiers) {
+        expect(specifier).not.toMatch(/\?worker&url|@kernel-wasm|@rootfs-vfs/);
+      }
+    }
+  });
+
+  it("index.d.ts declares the public API", () => {
+    const dts = distFile("index.d.ts");
+    for (const name of [
+      "declare class BrowserKernel",
+      "interface BrowserKernelAssets",
+      "declare function fetchKandeloBinaries",
+      "ABI_VERSION",
+    ]) {
+      expect(dts).toContain(name);
+    }
+  });
+});
diff --git a/web-libs/kandelo-web/test/fetch-binaries.test.ts b/web-libs/kandelo-web/test/fetch-binaries.test.ts
new file mode 100644
index 0000000000..5f414c768e
--- /dev/null
+++ b/web-libs/kandelo-web/test/fetch-binaries.test.ts
@@ -0,0 +1,33 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fetchKandeloIndex } from "../src/fetch-binaries";
+import { ABI_VERSION } from "../../../host/src/generated/abi";
+
+const INDEX_TOML = `abi_version = ${ABI_VERSION}\n`;
+
+function fetchStub(): typeof fetch {
+  return vi.fn(async () => {
+    return new Response(new TextEncoder().encode(INDEX_TOML));
+  }) as unknown as typeof fetch;
+}
+
+describe("fetchKandeloIndex baseUrl resolution", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("resolves a relative baseUrl against the page URL", async () => {
+    vi.stubGlobal("location", { href: "https://app.example/ide/" });
+    const f = fetchStub();
+    const index = await fetchKandeloIndex({ baseUrl: "/kandelo-binaries/", fetch: f });
+    expect(index.baseUrl).toBe("https://app.example/kandelo-binaries/");
+    expect(f).toHaveBeenCalledWith("https://app.example/kandelo-binaries/index.toml");
+  });
+
+  it("keeps an absolute baseUrl and appends the trailing slash", async () => {
+    const index = await fetchKandeloIndex({
+      baseUrl: "https://cdn.example/bins",
+      fetch: fetchStub(),
+    });
+    expect(index.baseUrl).toBe("https://cdn.example/bins/");
+  });
+});
diff --git a/web-libs/kandelo-web/tsconfig.json b/web-libs/kandelo-web/tsconfig.json
new file mode 100644
index 0000000000..ae60dcf42b
--- /dev/null
+++ b/web-libs/kandelo-web/tsconfig.json
@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "noEmit": true,
+    "declaration": true,
+    "sourceMap": true,
+    "isolatedModules": true,
+    "resolveJsonModule": true,
+    "lib": ["ES2024", "ES2024.SharedMemory", "DOM"],
+    "types": ["node"]
+  },
+  "include": ["src"]
+}
diff --git a/web-libs/kandelo-web/tsdown.config.ts b/web-libs/kandelo-web/tsdown.config.ts
new file mode 100644
index 0000000000..36f445d6cc
--- /dev/null
+++ b/web-libs/kandelo-web/tsdown.config.ts
@@ -0,0 +1,94 @@
+import { defineConfig } from "tsdown";
+import path from "node:path";
+
+const HOST_SRC = path.resolve(import.meta.dirname, "../../host/src");
+const ASSETS_BUNDLED = path.resolve(import.meta.dirname, "src/assets-bundled.ts");
+const DEFAULT_ARTIFACTS = path.resolve(
+  import.meta.dirname,
+  "src/default-artifacts-unbundled.ts",
+);
+
+/**
+ * Rolldown plugin: swap the two host-runtime modules that only Vite can resolve.
+ *
+ *  - `browser-kernel-assets` (`?worker&url` imports) → `assets-bundled.ts`,
+ *    which resolves the shipped worker entries via
+ *    `new URL(..., import.meta.url)`. This is what lets BrowserKernel run in a
+ *    plain Vite consumer with no `?worker&url` aliases.
+ *  - `browser-kernel-default-artifacts` (`@kernel-wasm` / `@rootfs-vfs`) →
+ *    `default-artifacts-unbundled.ts`. The package ships no Wasm, so those
+ *    aliases have nothing to point at; the replacement throws an actionable
+ *    error instead.
+ *
+ * The swap does not reach the `.d.ts` type graph: `rolldown-plugin-dts`
+ * disk-resolves `browser-kernel-assets` into it (via the `BROWSER_KERNEL_ASSETS`
+ * value imports), and there the raw module's `?worker&url` imports are
+ * unloadable — the `load` shim feeds those ids an empty string module. tsc
+ * drops the imports from the emitted declarations, and the swap keeps them
+ * out of the JS output entirely.
+ */
+const swapViteCoupledModules = {
+  name: "kandelo-swap-vite-coupled-modules",
+  resolveId(source: string) {
+    if (/browser-kernel-assets$/.test(source)) return ASSETS_BUNDLED;
+    if (/browser-kernel-default-artifacts$/.test(source)) {
+      return DEFAULT_ARTIFACTS;
+    }
+    return null;
+  },
+  load(id: string) {
+    if (!id.endsWith("?worker&url")) return null;
+    return 'export default "";';
+  },
+};
+
+// One config per entry: rolldown splits modules shared between entries into
+// chunks, but each worker entry must stay a single self-contained file so a
+// consumer's bundler can copy it as a plain `new URL(...)` asset. For the same
+// reason `codeSplitting: false` folds dynamic imports into the entry file.
+const entryConfig = {
+  format: "esm" as const,
+  platform: "browser" as const,
+  target: "es2022",
+  sourcemap: true,
+  outputOptions: { codeSplitting: false },
+  plugins: [swapViteCoupledModules],
+};
+
+// The worker files are copied verbatim as `new URL(...)` assets, so a bare
+// import ("fzstd") would be unresolvable at runtime: everything gets bundled
+// in, and `deps.onlyImport` makes the build fail if any other import
+// survives. "net" is the one exception — `kernel-worker.ts` lazily
+// `await import("net")`s it on the Node-host TCP path, which a browser worker
+// never executes. The index entry keeps `dependencies` external so the
+// consumer dedupes one copy.
+const workerConfig = {
+  ...entryConfig,
+  dts: false,
+  deps: { alwaysBundle: [/./], onlyImport: ["net"] },
+};
+
+export default defineConfig([
+  {
+    ...entryConfig,
+    entry: { index: "src/index.ts" },
+    dts: true,
+  },
+  // Worker entries emitted as their own self-contained files so BrowserKernel
+  // can spawn them as module workers via the URLs in assets-bundled.ts.
+  {
+    ...workerConfig,
+    entry: {
+      "browser-kernel-worker-entry": path.join(
+        HOST_SRC,
+        "browser-kernel-worker-entry.ts",
+      ),
+    },
+  },
+  {
+    ...workerConfig,
+    entry: {
+      "worker-entry-browser": path.join(HOST_SRC, "worker-entry-browser.ts"),
+    },
+  },
+]);

From fcee6b1514f14d7b1904eb4e4cc0f9d91b7a0cdd Mon Sep 17 00:00:00 2001
From: mho22 
Date: Tue, 11 Aug 2026 20:42:21 +0200
Subject: [PATCH 3/3] [Demos] Consume BrowserKernel through @kandelo/web

apps/browser-demos imports BrowserKernel, MemoryFileSystem, and
writeVfsFile from @kandelo/web through a repository alias in
browserRepositoryAliases(), exercising the package entry from
source in every demo page while published consumers get the
bundled dist.
---
 apps/browser-demos/browser-module-contract.mjs         | 10 ++++++++++
 apps/browser-demos/lib/connection-pump.ts              |  2 +-
 apps/browser-demos/lib/init/sw-bridge-fetch.ts         |  2 +-
 apps/browser-demos/lib/mysql-client.ts                 |  2 +-
 apps/browser-demos/lib/pty-terminal.ts                 |  2 +-
 apps/browser-demos/lib/redis-client.ts                 |  2 +-
 apps/browser-demos/pages/benchmark/main.ts             |  2 +-
 apps/browser-demos/pages/git-test/main.ts              |  2 +-
 .../pages/kandelo/kernel-host/live-setup.ts            |  2 +-
 apps/browser-demos/pages/mariadb-test/main.ts          |  2 +-
 apps/browser-demos/pages/sqlite-test/main.ts           |  4 +---
 apps/browser-demos/pages/test-runner/main.ts           |  2 +-
 apps/browser-demos/tsconfig.json                       |  1 +
 13 files changed, 22 insertions(+), 13 deletions(-)

diff --git a/apps/browser-demos/browser-module-contract.mjs b/apps/browser-demos/browser-module-contract.mjs
index 8ada14f5c0..89da582ba9 100644
--- a/apps/browser-demos/browser-module-contract.mjs
+++ b/apps/browser-demos/browser-module-contract.mjs
@@ -10,6 +10,16 @@ import { resolve } from "node:path";
 export function browserRepositoryAliases(repoRoot) {
   return Object.freeze({
     "@host": resolve(repoRoot, "host", "src"),
+    // Dogfood the published browser package: `@kandelo/web` resolves to its
+    // source entry, which re-exports the same host modules. Published
+    // consumers get the bundled `dist/` instead.
+    "@kandelo/web": resolve(
+      repoRoot,
+      "web-libs",
+      "kandelo-web",
+      "src",
+      "index.ts",
+    ),
   });
 }
 
diff --git a/apps/browser-demos/lib/connection-pump.ts b/apps/browser-demos/lib/connection-pump.ts
index 33cf0639ab..f14c3856f6 100644
--- a/apps/browser-demos/lib/connection-pump.ts
+++ b/apps/browser-demos/lib/connection-pump.ts
@@ -3,7 +3,7 @@
  * and the kernel's pipe-backed sockets. This is the browser equivalent of
  * handleIncomingTcpConnection in kernel-worker.ts.
  */
-import type { BrowserKernel } from "@host/browser-kernel-host";
+import type { BrowserKernel } from "@kandelo/web";
 import type { HttpRequest, HttpResponse, HttpBridgeHost } from "./http-bridge";
 
 const encoder = new TextEncoder();
diff --git a/apps/browser-demos/lib/init/sw-bridge-fetch.ts b/apps/browser-demos/lib/init/sw-bridge-fetch.ts
index 92082060b1..24b73fcf50 100644
--- a/apps/browser-demos/lib/init/sw-bridge-fetch.ts
+++ b/apps/browser-demos/lib/init/sw-bridge-fetch.ts
@@ -11,7 +11,7 @@
  * bridge entirely on the main thread; the kernel worker no longer needs a
  * special direct port.
  */
-import type { BrowserKernel } from "@host/browser-kernel-host";
+import type { BrowserKernel } from "@kandelo/web";
 import { HttpBridgeHost, type HttpRequest } from "../http-bridge";
 import { initServiceWorkerBridge } from "./service-worker-bridge";
 
diff --git a/apps/browser-demos/lib/mysql-client.ts b/apps/browser-demos/lib/mysql-client.ts
index 069fa217ce..648ab6fd29 100644
--- a/apps/browser-demos/lib/mysql-client.ts
+++ b/apps/browser-demos/lib/mysql-client.ts
@@ -9,7 +9,7 @@
  * Operates entirely over kernel pipe pairs (no real TCP).
  * All pipe operations are async (message round-trip to kernel worker).
  */
-import type { BrowserKernel } from "@host/browser-kernel-host";
+import type { BrowserKernel } from "@kandelo/web";
 
 const encoder = new TextEncoder();
 const decoder = new TextDecoder();
diff --git a/apps/browser-demos/lib/pty-terminal.ts b/apps/browser-demos/lib/pty-terminal.ts
index 118b527b6d..602491ca22 100644
--- a/apps/browser-demos/lib/pty-terminal.ts
+++ b/apps/browser-demos/lib/pty-terminal.ts
@@ -7,7 +7,7 @@
  */
 import { Terminal, type ITerminalOptions } from "@xterm/xterm";
 import { FitAddon } from "@xterm/addon-fit";
-import type { BrowserKernel, BrowserKernelBootOptions } from "@host/browser-kernel-host";
+import type { BrowserKernel, BrowserKernelBootOptions } from "@kandelo/web";
 
 const encoder = new TextEncoder();
 
diff --git a/apps/browser-demos/lib/redis-client.ts b/apps/browser-demos/lib/redis-client.ts
index 62e6c14245..f32c6628ef 100644
--- a/apps/browser-demos/lib/redis-client.ts
+++ b/apps/browser-demos/lib/redis-client.ts
@@ -8,7 +8,7 @@
  * Operates entirely over kernel pipe pairs (no real TCP).
  * All pipe operations are async (message round-trip to kernel worker).
  */
-import type { BrowserKernel } from "@host/browser-kernel-host";
+import type { BrowserKernel } from "@kandelo/web";
 
 const encoder = new TextEncoder();
 const decoder = new TextDecoder();
diff --git a/apps/browser-demos/pages/benchmark/main.ts b/apps/browser-demos/pages/benchmark/main.ts
index 73ae0bb3cb..fce8e39769 100644
--- a/apps/browser-demos/pages/benchmark/main.ts
+++ b/apps/browser-demos/pages/benchmark/main.ts
@@ -11,7 +11,7 @@
  *   - "mariadb-aria": MariaDB with Aria engine
  *   - "mariadb-innodb": MariaDB with InnoDB engine
  */
-import { BrowserKernel } from "@host/browser-kernel-host";
+import { BrowserKernel } from "@kandelo/web";
 import { MemoryFileSystem } from "../../../../host/src/vfs/memory-fs";
 import { restoreVerifiedVfsImage } from "../../../../host/src/vfs/load-image";
 import {
diff --git a/apps/browser-demos/pages/git-test/main.ts b/apps/browser-demos/pages/git-test/main.ts
index dbfd5fb653..53a903ca81 100644
--- a/apps/browser-demos/pages/git-test/main.ts
+++ b/apps/browser-demos/pages/git-test/main.ts
@@ -4,7 +4,7 @@
  * Loads git.wasm and git-remote-http.wasm, writes them to the VFS,
  * and exposes window.__runGitClone(url) for Playwright to call.
  */
-import { BrowserKernel } from "@host/browser-kernel-host";
+import { BrowserKernel } from "@kandelo/web";
 import {
   createBuildFsWithEtc,
   finalizeKernelOwnedImage,
diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts
index dd2927fc44..746a6d25ca 100644
--- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts
+++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts
@@ -1,6 +1,6 @@
 // Builds a LiveKernelHost over a real BrowserKernel for the Kandelo page.
 
-import { BrowserKernel } from "@host/browser-kernel-host";
+import { BrowserKernel } from "@kandelo/web";
 import { ensureServiceWorkerReady } from "../../../lib/init/service-worker-bridge";
 import { setupServiceWorkerFetchBridge } from "../../../lib/init/sw-bridge-fetch";
 import { bindImageOwnedRuntimeUrls } from "../../../lib/init/image-owned-runtime-urls";
diff --git a/apps/browser-demos/pages/mariadb-test/main.ts b/apps/browser-demos/pages/mariadb-test/main.ts
index 6537c2b365..338f529f5d 100644
--- a/apps/browser-demos/pages/mariadb-test/main.ts
+++ b/apps/browser-demos/pages/mariadb-test/main.ts
@@ -15,7 +15,7 @@
  *   mariadb (daemon, port 3306)
  *   mysqltest (transient, one per __runMariadbTest call)
  */
-import { BrowserKernel } from "@host/browser-kernel-host";
+import { BrowserKernel } from "@kandelo/web";
 import kernelWasmUrl from "@kernel-wasm?url";
 import mysqlTestWasmUrl from "@binaries/programs/wasm32/mariadb/mysqltest.wasm?url";
 import VFS_IMAGE_URL from "@binaries/programs/wasm32/mariadb-test.vfs.zst?url";
diff --git a/apps/browser-demos/pages/sqlite-test/main.ts b/apps/browser-demos/pages/sqlite-test/main.ts
index 0357ac84a4..7833dace57 100644
--- a/apps/browser-demos/pages/sqlite-test/main.ts
+++ b/apps/browser-demos/pages/sqlite-test/main.ts
@@ -3,9 +3,7 @@
  *
  * Exposes window.__runSqliteTest("select1.test", timeoutMs) for Playwright.
  */
-import { BrowserKernel } from "@host/browser-kernel-host";
-import { MemoryFileSystem } from "@host/vfs/memory-fs";
-import { writeVfsFile } from "@host/vfs/image-helpers";
+import { BrowserKernel, MemoryFileSystem, writeVfsFile } from "@kandelo/web";
 import { restoreVerifiedVfsImage } from "@host/vfs/load-image";
 import { finalizeKernelOwnedImage, settleWebKitReclaim } from "../../lib/kernel-owned-boot";
 import {
diff --git a/apps/browser-demos/pages/test-runner/main.ts b/apps/browser-demos/pages/test-runner/main.ts
index 4b45fcc850..c52a2f421a 100644
--- a/apps/browser-demos/pages/test-runner/main.ts
+++ b/apps/browser-demos/pages/test-runner/main.ts
@@ -5,7 +5,7 @@
  * Each call creates a fresh BrowserKernel, runs the program, cleans up,
  * and returns { exitCode, stdout, stderr, hostDiagnostics }.
  */
-import { BrowserKernel } from "@host/browser-kernel-host";
+import { BrowserKernel } from "@kandelo/web";
 import type { HostDiagnostic } from "@host/host-diagnostic";
 import {
   createBuildFsWithEtc,
diff --git a/apps/browser-demos/tsconfig.json b/apps/browser-demos/tsconfig.json
index 47ebc58275..f5daee6d19 100644
--- a/apps/browser-demos/tsconfig.json
+++ b/apps/browser-demos/tsconfig.json
@@ -15,6 +15,7 @@
     "types": ["vite/client"],
     "baseUrl": ".",
     "paths": {
+      "@kandelo/web": ["../../web-libs/kandelo-web/src/index.ts"],
       "@host/*": ["../../host/src/*"]
     }
   },