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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/browser-demos/browser-module-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
});
}

Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/lib/connection-pump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/lib/init/sw-bridge-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/lib/mysql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/lib/pty-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/lib/redis-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/pages/benchmark/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/pages/git-test/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/pages/mariadb-test/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 1 addition & 3 deletions apps/browser-demos/pages/sqlite-test/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/browser-demos/pages/test-runner/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/browser-demos/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@kandelo/web": ["../../web-libs/kandelo-web/src/index.ts"],
"@host/*": ["../../host/src/*"]
}
},
Expand Down
157 changes: 157 additions & 0 deletions docs/plans/2026-06-18-kandelo-web-npm-packaging.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions host/src/browser-kernel-assets.ts
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading