Packages
How fishbowl systems are extended with third-party capabilities — from the host side (TypeScript developer) and the guest side (shell user). Built on npm conventions, unified by the Extension type.
Users
| User | Role | Primary interaction |
|---|---|---|
| Distro maintainer | Curates the @fishnet/* package set, decides what ships in standard images | Publishes packages, defines catalogs |
| App developer | Embeds fishbowl, composes a system from packages at build time | npm install + .use() |
| End user / LLM | Runs inside the sandbox, installs and uses tools at runtime | pkg install, pkg list |
| Package author | Writes a new tool (JS or WASM) and publishes it | npm publish with fishbowl field |
Functional Requirements
| # | Requirement | Notes |
|---|---|---|
| F1 | Install — resolve package by name, register bins, seed files, set env defaults | Must work build-time (.use()) and runtime (pkg install) |
| F2 | Remove — unregister bins, clean seeded files, stop services | Runtime only. Refuse if open FDs on mounts |
| F3 | List / Search — show installed and available packages | Installed = active bins. Available = catalog + registry |
| F4 | Bin resolution — PATH resolves package-provided bins transparently | User types vim, shell finds it. No difference from a JS bin |
| F5 | File seeding — packages declare files placed in virtual FS on install | vimrc, runtime data, man pages, etc. |
| F6 | Env defaults — packages declare env vars, sandbox env takes precedence | TERM=xterm-256color unless already set |
| F7 | Dependencies — package A requires package B | npm deps for build-time. Runtime: flat check-and-install |
| F8 | Catalog — pre-bundle packages as “available” without installing | For sandboxes where network isn’t available |
| F9 | Services — packages can declare supervised services | Init system integration (already exists) |
| F10 | Registry — configurable package source, npm-compatible by default | Default @fishbowl scope on npmjs.com |
| F11 | WASM transparency — WASM-backed bins are indistinguishable from JS bins | Package system doesn’t know or care about implementation |
| F12 | Version pinning — pkg install name@version | Registry resolves latest if unspecified |
Future Requirements
These are deferred but the architecture must not preclude them:
| # | Requirement | Design constraint |
|---|---|---|
| V2-1 | Semver resolution — version ranges, conflict detection | Package manifest already has version. Registry can serve version lists. depends field supports name@range syntax |
| V2-2 | Upgrade in-place — pkg upgrade name | Remove + install is the v1 path. Upgrade needs state migration hooks (preupgrade, postupgrade) |
| V2-3 | Lockfile — reproducible runtime installs | Build-time uses npm lockfile. Runtime lockfile is a /pkg/lock.json listing exact versions |
| V2-4 | Package signing — verify bundle integrity | Registry-level concern. Add signature field to manifest response. pkg verifies before eval |
| V2-5 | Post-install scripts (sandboxed) — richer lifecycle | Current hooks are shell commands. Could add JS hooks that run in a restricted namespace |
| V2-6 | Transitive dependencies — full dependency tree | Flat deps are v1. Extend depends to support version ranges, build a resolver |
Core Insight
The Extension type (defined in the developer-api spec) is the package format. A package is an npm module that exports an Extension or a function returning one. The same format is consumed two ways:
Host-side (ahead of time) Guest-side (runtime)───────────────────────── ────────────────────npm install @fishnet/sqlite $ pkg install @fishnet/sqliteimport sqlite from '...' fetch from registryUnix().use(sqlite()) evaluate → Extensionbuilder accumulates apply to live namespace.boot() → init → services + shell bins available immediatelyA package author writes one thing. Both consumers use it.
Package Format
A package is an npm module with two layers: a static declaration in package.json and an optional parameterized function in the entry point.
Static Layer: package.json
The fishbowl field in package.json declares everything about the package that doesn’t require runtime configuration — bins, mounts, services, env, hooks. This is the canonical description of the package, inspectable without evaluating any JavaScript.
{ "name": "@fishnet/http-server", "version": "1.0.0", "main": "./index.js", "fishbowl": { "type": "extension", "bins": ["httpd", "httpdctl"], "mounts": { "/var/www": { "type": "memory" } }, "env": { "HTTP_PORT": "8080" }, "services": [ { "name": "httpd", "bin": "httpd", "argv": ["--port", "8080"], "requires": ["mount:/var/www"], "restart": { "policy": "always" } } ], "hooks": { "postinstall": "mkdir -p /var/www/public", "preremove": "rm -rf /var/www/.cache" } }}The static layer enables:
- Registry indexing. The registry can catalog what a package provides (bins, services, mounts) without downloading or evaluating the bundle.
pkg search --provides service:httpdworks against package.json metadata alone. - Pre-install validation.
pkg installcan check whether a package’srequiresare satisfiable before fetching the bundle. “This package requiresmount:/var/db— do you have it?” - Static graph analysis. Tools can build the full dependency graph across all installed packages by reading package.json files in
/pkg/. No JS evaluation needed. - Documentation. A developer reading package.json immediately sees what the package provides and depends on. No need to read source code.
Dynamic Layer: TypeScript Entry Point
The entry point exports a default function that returns an Extension. This is the parameterized layer — it accepts configuration and produces the runtime Extension object:
export default function httpServer(config?: { port?: number }): Extension { const port = config?.port ?? 8080 return { bins: { httpd: httpdBin, httpdctl: httpdctlBin }, mounts: { '/var/www': memoryFS() }, services: [{ name: 'httpd', bin: 'httpd', argv: ['--port', String(port)], requires: ['mount:/var/www'], restart: { policy: 'always' }, }], env: { HTTP_PORT: String(port) }, }}When the function is called (with or without arguments), its returned Extension fully replaces the static layer — the package.json fishbowl fields are not consulted. When no entry point exists, or when pkg install evaluates a bundle that has no default export, the static layer is used to construct the Extension.
Decision: dynamic layer fully replaces the static layer, no field-by-field merge. This follows npm’s own convention:
pm2’s process config completely overridespackage.jsonscripts.start, it doesn’t merge fields. The static layer is a fallback for when no entry point exists or no config is provided. When the dynamic function returns an Extension, that Extension IS the package — bins, mounts, env, services, everything comes from the returned object. Merging would create ambiguity about which layer contributed which field, making debugging harder.
Two Layers, Two Entry Points
| package.json (static) | TypeScript function (dynamic) | |
|---|---|---|
| When used | No entry point, registry indexing, static analysis, discovery | Host-side .use(), guest-side pkg install with entry point |
| Configurable | No — fixed defaults | Yes — accepts arguments |
| Inspectable without eval | Yes | No |
| Precedence | Fallback when no entry point exists | Full replacement — static layer is not consulted |
The relationship mirrors npm’s own pattern: package.json declares scripts.start as a static default, but a tool like pm2 can override the start command dynamically. The override is total, not a merge.
Decision: packages are npm modules, not a custom format. No custom registry protocol, no custom archive format, no custom dependency resolution. NPM already solves distribution, versioning, and dependencies. We add a convention (
fishbowlfield, default export shape) on top. Like how Next.js plugins, Vite plugins, and ESLint configs are all just npm packages with conventions.
Decision: services are arrays everywhere. Both the package.json static layer and the TypeScript function return
ServiceDef[](arrays with anamefield on each entry). This matches the Extension type and preserves declaration ordering. No normalization step between static and dynamic layers.
Decision: package.json is the static source of truth; the TypeScript function is the parameterized override. Guest-side
pkg installreads package.json and uses the declared defaults. Host-side.use(httpServer({ port: 3000 }))calls the function and gets a customized Extension. Same package, two levels of control. A package without a TypeScript entry point (pure static declaration) is valid for packages that only declare env, files, or mounts — but any package that provides bins must have an entry point, sinceBinFunctionreferences cannot exist in JSON.
Package Categories
Packages are categorized by what they provide:
| Category | Example | Typical contents |
|---|---|---|
| Bin pack | @fishnet/data-tools | bins only — new commands |
| FS adapter | @fishnet/s3 | mounts + maybe config bins |
| Environment | @fishnet/python-sandbox | bins + mounts + env + files |
| Service | @fishnet/http-server | bins + services |
| Driver | @fishnet/webrtc-tty | TtyFS implementation (used in BootOpts, not as Extension) |
These aren’t formal types — a package can provide any combination of Extension fields. The categories are a naming convention for documentation and discovery.
Lifecycle Hooks
Packages can declare shell commands to run after install or before removal:
{ "fishbowl": { "hooks": { "postinstall": "mkdir -p /var/db && echo 'initialized'", "preremove": "rm -rf /var/db/.cache" } }}Hooks run inside the fishbowl shell, not the host. They execute with the same permissions as the shell user and have access to whatever’s mounted at that point. If a postinstall hook fails (non-zero exit), the install is rolled back (mounts removed, bins unregistered).
Hooks are a guest-side concept only. They run at pkg install time inside a running system where a shell exists. Host-side .use() does NOT run hooks — the developer handles setup directly in their TypeScript code, where they have full programmatic control. This is the same as npm: postinstall scripts run after npm install, not when you import a package.
Decision: hooks are shell commands, not JavaScript. They run in the environment they’re extending. A postinstall that creates directories uses
mkdir. A preremove that cleans up usesrm. This keeps packages self-contained — no host-side scripts, no Node.js-specific lifecycle.
Decision: hooks are guest-side only —
.use()does not execute hooks. No shell exists during builder composition. The developer calling.use(httpServer())is writing TypeScript — they can callmemoryFS(), set up directories, and configure services directly in code. Hooks exist for the guest-sidepkg installpath where the user has only a shell. This matches npm’s model:postinstallruns afternpm installin a terminal, not when your bundler resolves animport.
Host-Side Usage
From the TypeScript developer’s perspective, packages are standard imports:
import sqlite from '@fishnet/sqlite'import dataTools from '@fishnet/data-tools'
const sys = await Unix() .use(stdSystem()) .use(sqlite({ dbPath: '/data' })) .use(dataTools()) .boot({ tty: { input, output } })The developer npm installs packages and .use()s them. No runtime fetching, no registry interaction. The package is bundled with the host application.
This is the recommended path for known, ahead-of-time configuration. The builder composes Extensions at construction time, boot applies them.
Guest-Side Usage
From the shell user’s perspective, the pkg bin manages packages at runtime:
$ pkg install @fishnet/sqlitemounting /var/dbregistering bins: sqlite3, dbsyncdstarting service: dbsyncdrunning postinstall hook...done.
...
$ pkg install @fishnet/s3 --bucket=my-data --region=us-east-1mounting /mnt/s3registering bins: s3ctldone.
$ pkg listNAME VERSION BINS MOUNTS SERVICES@fishnet/sqlite 1.0.0 sqlite3, dbsyncd /var/db dbsyncd@fishnet/s3 1.0.0 s3ctl /mnt/s3 -
$ pkg remove @fishnet/sqliterunning preremove hook...stopping service: dbsyncdunmounting /var/dbunregistering bins: sqlite3, dbsyncddone.Version Pinning
pkg install <name>@<version> fetches a specific version from the registry. Without a version, the registry resolves to latest.
Guest-Side Configuration
Flags after the package name are passed as the config object to the package’s default export function:
$ pkg install @fishnet/s3 --bucket=my-data --region=us-east-1This is equivalent to the host-side call .use(s3({ bucket: 'my-data', region: 'us-east-1' })). Flags are parsed as --key=value pairs and passed as Record<string, string> to the entry point. Boolean flags (--verbose) pass as true. If the package has no TypeScript entry point, flags are ignored.
Decision: config via CLI flags, not interactive prompts. Flags are explicit, scriptable, and composable. A package that requires configuration but receives none falls back to package.json defaults or reads from env vars. No interactive prompts — the shell user (often an LLM) must be able to install packages non-interactively.
Bin Name Conflicts
When two installed packages declare the same bin name, last-installed wins with a warning:
$ pkg install @fishnet/better-grepregistering bins: grep, egrepwarning: grep: overriding @fishnet/coreutilsdone.
$ pkg remove @fishnet/better-grepunregistering bins: grep, egreprestoring grep from @fishnet/coreutilsdone.Decision: last-installed wins for bin name conflicts, with warning and restore on remove. This follows the Unix convention where
/usr/local/binshadows/usr/binin PATH — the later entry wins. The installed manifest (at/pkg/<name>/manifest.json) records which bins were overridden and by whom, sopkg removecan restore the previous package’s bin. This is simple, predictable, and matches user expectations from both Unix (update-alternatives) and npm (npxprecedence).
Removal Safety
pkg remove refuses to unmount a fileserver if there are open file descriptors on its mounts, similar to umount in real Unix. The user must close files or stop services using the mount before removal succeeds:
$ pkg remove @fishnet/sqliteerror: cannot unmount /var/db — 2 open file descriptors pid 5 (sqlite3): /var/db/main.dbhint: stop services and close files first: svc stop dbsyncdServices declared by the package are stopped as part of pkg remove, but only after verifying no other processes hold open FDs on the package’s mounts.
Resolution Order
pkg install <name>: 1. Check /pkg/<name>/ → already installed? Done. 2. Check /pkg/.cache/ → cached bundle? Install from cache. 3. Fetch from registry → download, cache, install.Install Mechanics
Guest-side install uses the two-layer model: validate against the static declaration first, then fetch and evaluate.
pkg install @fishnet/sqlite: 1. Fetch manifest from registry (package.json metadata) 2. Validate dependencies from static declaration: - Check `services.*.requires` against running units - Check `mounts` don't conflict with existing mounts - If unmet requires → error before downloading bundle 3. Fetch bundle → write to /pkg/.cache/sqlite/ 4. Evaluate entry point - If entry point exists → call default export(config) → get Extension (config = parsed --key=value flags, or {} if none provided) - If no entry point → construct Extension from package.json fishbowl field 5. Apply Extension to live system: - Mount fileservers into process namespace - Register bins via setExec() on the root filesystem (ExecCapable) - Set env vars in shell environment - Write seed files 6. Run postinstall hook if declared 7. If services declared → signal init to start them 8. Write installed manifest to /pkg/sqlite/manifest.jsonStep 2 is the key benefit of the static layer — dependency validation happens before any code is downloaded or evaluated. A package that requires mount:/var/db fails immediately if nothing provides it, without wasting time fetching a bundle.
Static bins vs Live BinFunction Conversion
The static fishbowl.bins field in package.json is an array of strings (["sqlite3", "dbsyncd"]) — bin names for discovery and validation. The PackageManifest type in types.ts has bins: Record<string, BinFunction> — live function references from an evaluated package. These are different representations of the same concept at different lifecycle stages:
- Static layer (
package.json):bins: string[]— names only. Used by the registry for indexing, bypkg searchfor discovery, and by pre-install validation to detect conflicts. No code evaluation needed. - Evaluated layer (
PackageManifestin types.ts):bins: Record<string, BinFunction>— live callable functions. Produced by evaluating the bundle’s default export, which returns an Extension containing realBinFunctionreferences.
The conversion happens at step 4 of install mechanics: fetch the bundle, call the default export, receive an Extension with live BinFunction values in its bins field. The static bins: string[] is never converted directly — it exists for discovery only. The actual functions come from JavaScript evaluation.
Decision:
PackageManifestin types.ts represents the evaluated package, not the static declaration. The static declaration is JSON (inspectable without eval). ThePackageManifesttype captures the result after evaluation — whenBinFunctionreferences exist in memory. These are two views of the same package at different points in its lifecycle. The staticbins: string[]and the livebins: Record<string, BinFunction>are intentionally different types because they serve different purposes.
Fetch Mechanism
How bundles are fetched and evaluated depends on the runtime:
- Node/Bun: dynamic
import()from a CDN URL or local path - Browser:
fetch()the bundle as text, evaluate withnew Function()— the same pattern BinFS already uses for runtime bin creation
Decision: browser evaluation reuses BinFS’s
new Function()pattern. No new evaluation mechanism. Runtime bin creation already solves “take JavaScript text, turn it into a callable function.” Package evaluation is the same operation at a larger scale.
PkgFS
/pkg/ is a fileserver. It stores installed package metadata and bundles:
/pkg/ sqlite/ manifest.json ← package metadata (name, version, what was mounted/registered) index.js ← entry point source data-tools/ manifest.json index.js .cache/ ← downloaded bundles before install sqlite/ bundle.jsPkgFS is a specialized MemoryFS that the pkg bin reads and writes. The manifest tracks what an installed package provided, so pkg remove knows what to undo:
{ "name": "@fishnet/sqlite", "version": "1.0.0", "installed": { "bins": ["sqlite3", "dbsyncd"], "mounts": ["/var/db"], "env": ["DATABASE_URL"], "services": ["dbsyncd"] }}Decision:
/pkg/is a mount point, not a convention on MemoryFS. Making it a fileserver means it can be backed by anything — in-memory for ephemeral sessions, IndexedDB for browser persistence, real filesystem for Node. Thepkgbin doesn’t know or care about the backing store.
Registry
A simple HTTP API for distributing packages. Not full npm — just enough to resolve and fetch.
GET /packages/<name> → manifest (includes asset list)GET /packages/<name>/bundle.js → pre-built JS entry pointGET /packages/<name>/<asset> → additional assets (e.g., vim.wasm)For pure-JS packages, bundle.js is the only file. WASM packages declare additional assets in the manifest (see assets field below), and the pkg command fetches them alongside the bundle.
Manifest Response
The registry serves the package.json fishbowl field directly, plus distribution metadata. This is the static declaration — everything the pkg command needs to validate dependencies and plan the install before fetching the bundle.
{ "name": "@fishnet/sqlite", "version": "1.0.0", "description": "SQLite for fishbowl", "bundle": "/packages/@fishnet/sqlite/bundle.js", "assets": [], "fishbowl": { "type": "extension", "bins": ["sqlite3", "dbsyncd"], "mounts": { "/var/db": { "type": "sqlite", "path": "/var/db" } }, "env": { "DATABASE_URL": "sqlite:///var/db/main.db" }, "services": [ { "name": "dbsyncd", "bin": "dbsyncd", "requires": ["mount:/var/db"], "restart": { "policy": "on-failure", "maxRetries": 5 } } ], "hooks": { "postinstall": "mkdir -p /var/db" } }}An Emscripten WASM package’s manifest includes its binary assets (.wasm + .mjs glue):
{ "name": "@fishnet/vim", "version": "9.1.0", "bundle": "/packages/@fishnet/vim/bundle.js", "assets": [ { "name": "vim.wasm", "url": "/packages/@fishnet/vim/vim.wasm", "size": 5242880 }, { "name": "vim.mjs", "url": "/packages/@fishnet/vim/vim.mjs", "size": 204800 } ], "fishbowl": { "bins": ["vim", "vi"], "env": { "TERM": "xterm-256color" } }}A WASI package’s manifest has only the .wasm binary — no glue file:
{ "name": "@fishnet/wasi-echo", "version": "1.0.0", "bundle": "/packages/@fishnet/wasi-echo/bundle.js", "assets": [ { "name": "test-wasi-echo.wasm", "url": "/packages/@fishnet/wasi-echo/test-wasi-echo.wasm", "size": 51200 } ], "fishbowl": { "bins": ["wasi-echo"] }}The pkg command fetches bundle.js plus all declared assets before evaluating the entry point. The bundle’s bin functions reference assets by name; the runtime resolves them from wherever they were cached (see Asset Resolution below).
Registry Configuration
The registry URL is set via $PKG_REGISTRY:
$ echo $PKG_REGISTRYhttps://registry.js-unix.dev
$ export PKG_REGISTRY=https://my-company.dev/fishbowl/packages$ pkg install @internal/custom-toolsDecision: self-hostable, static-compatible registry. The minimum viable registry is a static file server: a directory of
<name>/manifest.jsonand<name>/bundle.jsfiles. No database, no auth, no publish API needed for v1. A company can host internal packages by putting files on S3 or GitHub Pages. A full registry with publishing, auth, and search can grow from this foundation.
Decision: bundles are pre-built JS files, optionally with binary assets. No bundling at install time. The registry serves ready-to-evaluate JavaScript. WASM packages additionally declare binary assets in the manifest
assetsarray; thepkgcommand fetches these alongside the bundle. Build complexity lives in the publish step, not the install step.
Dependency Resolution
Decision: flat dependencies only for v1. A package declares what it provides, not what it depends on. If
@fishnet/sqliteneeds a specific fileserver utility, it bundles it. No dependency tree, no version resolution, no diamond dependency problem. This matches the “pre-built bundle” model — the bundle is self-contained.
If dependency resolution becomes necessary (package A needs package B installed first), the simplest extension is:
{ "fishbowl": { "depends": ["@fishnet/core-utils"] }}pkg install checks if dependencies are installed, installs them first if not. Still flat — no version ranges, no resolution algorithm. Just “is it there? if not, install it.” This can grow later if needed.
Security Considerations
Runtime package installation evaluates third-party JavaScript in the fishbowl process. The same threat model as runtime bin creation applies (see bins spec):
- Cooperative, not adversarial. Packages run with full access to the fishbowl namespace. A malicious package can read/write any mounted fileserver.
- Namespace scoping contains VFS traversal only. A system configured with restricted mounts limits what packages can access through the kernel (open, read, write, stat, readdir). However, evaluated bundle code runs in the host JavaScript context via
new Function()and dynamicimport()— it bypasses namespace routing entirely and has direct access to host JS capabilities (fetch,process.env,localStorage,indexedDB,await import('node:fs')). Host-level isolation (e.g., Workers,vm.runInNewContext, or process-level sandboxing) is required for untrusted registries. This is the same trust contract asnpm install. - Hooks execute shell commands. Postinstall hooks run arbitrary shell commands inside the fishbowl environment. Even in the cooperative model, this is a risk vector — a package’s hook can modify any writable mount, set env vars, or install additional bins. Review hooks in package.json before installing untrusted packages.
- For adversarial isolation, disable the
pkgbin or don’t mount PkgFS. If runtime extension isn’t needed, don’t provide the mechanism.
Decision: no package signing or verification in v1. The registry is trusted. The host operator controls which registry
$PKG_REGISTRYpoints to. For self-hosted registries, the operator controls what’s published. Signing and verification can be added as a registry-level concern without changing the package format or thepkgbin.
Examples
Bin Pack
A package that just adds commands. The static layer declares bin names (for registry indexing and discovery); the entry point provides the actual BinFunction implementations. Every package that declares bins must have an entry point — JSON cannot contain function references.
{ "name": "@fishnet/data-tools", "fishbowl": { "type": "extension", "bins": ["csvkit", "parquet", "plot", "pivot"] }}// index.js — required: provides the actual bin implementationsexport default function dataTools(): Extension { return { bins: { csvkit, parquet, plot, pivot }, }}Filesystem Adapter (parameterized)
A package that mounts external storage. Requires configuration — the TypeScript function is essential:
{ "name": "@fishnet/s3", "fishbowl": { "type": "extension", "bins": ["s3ctl"], "mounts": { "/mnt/s3": { "type": "s3" } } }}// index.js — config required: bucket and regionexport default function s3(config: { bucket: string, region: string }): Extension { return { mounts: { '/mnt/s3': s3FS(config) }, bins: { s3ctl: s3ControlBin }, env: { S3_BUCKET: config.bucket }, }}Guest-side pkg install @fishnet/s3 would install the bins but the mount requires configuration — the package could prompt for it or read from env vars. Host-side .use(s3({ bucket: 'my-data', region: 'us-east-1' })) provides config directly.
Service (two layers)
The full two-layer pattern. package.json declares defaults, TypeScript function parameterizes:
{ "name": "@fishnet/http-server", "fishbowl": { "type": "extension", "bins": ["httpd", "httpdctl"], "mounts": { "/var/www": { "type": "memory" } }, "services": [ { "name": "httpd", "bin": "httpd", "argv": ["--port", "8080"], "requires": ["mount:/var/www"], "restart": { "policy": "always" } } ], "env": { "HTTP_PORT": "8080" }, "hooks": { "postinstall": "mkdir -p /var/www/public" } }}// index.js — parameterized: port is configurableexport default function httpServer(config?: { port?: number }): Extension { const port = config?.port ?? 8080 return { bins: { httpd: httpdBin, httpdctl: httpdctlBin }, mounts: { '/var/www': memoryFS() }, services: [{ name: 'httpd', bin: 'httpd', argv: ['--port', String(port)], requires: ['mount:/var/www'], restart: { policy: 'always' }, }], env: { HTTP_PORT: String(port) }, }}Guest-side: pkg install @fishnet/http-server → uses package.json defaults (port 8080).
Host-side: .use(httpServer({ port: 3000 })) → customized (port 3000).
Composing Presets
Presets compose naturally because they’re just Extensions:
const sys = await Unix() .use(stdSystem()) .use(dataScience()) .use(sqlite()) .use(httpServer({ port: 3000 })) .mount('/data', s3({ bucket: 'my-data', region: 'us-east-1' })) .env('PROJECT', 'analysis-q4') .file('/home/.profile', 'export PS1="ds$ "') .boot({ tty: { input, output } })This produces a system with: standard Unix tools + data science bins + SQLite with a background sync service + an HTTP server on port 3000 + S3 mounted at /data. All from composing Extensions.
WASM Packages
WASM-compiled programs (vim, sqlite3, python, ratatui-counter, wasi-echo) are packaged identically to pure-JS packages. The package system doesn’t know or care whether a bin is implemented in JavaScript or WebAssembly — it’s an implementation detail of the bin function. Two compilation backends are supported:
| Backend | Target | Glue file | Async I/O | TTY support | Use case |
|---|---|---|---|---|---|
| Emscripten | wasm32-unknown-emscripten | .mjs glue required | Emscripten Asyncify (compiled in) | Full (termios, ioctl) | TUI apps (vim, ratatui) |
| WASI | wasm32-wasip1 | None — standalone .wasm | wasm-opt --asyncify (post-processing) | None (no termios in WASI) | CLI tools (echo, cat, grep) |
Both backends produce packages that are indistinguishable from the outside. The user types a command and it works. The backend is an internal implementation detail.
When to Use Emscripten vs WASI
Use Emscripten when the program needs:
- Raw TTY / interactive terminal (termios, ioctl, select/poll)
- Emscripten’s MEMFS for file I/O (copy-in/copy-out model)
- Emscripten-specific APIs (FS, ENV, TTY runtime methods)
Use WASI when the program:
- Is a standard CLI tool (read stdin, write stdout, process files)
- Does not need terminal control (no curses, no raw mode)
- Benefits from direct
proc.fs.*routing (no MEMFS copy-in/copy-out overhead) - Is compiled with Rust (
wasm32-wasip1) or C via wasi-sdk
JSPI (JS Promise Integration) will eventually replace Asyncify for WASI binaries. Browser support: Chrome 137+, Firefox 139+.
Artifact Distribution
WASM packages follow the same pattern as established npm WASM packages (sql.js, esbuild-wasm, ffmpeg.wasm): assets ship as files in the npm tarball, loaded at runtime via path resolution.
Emscripten package:
@fishnet/vim/ package.json ← standard npm + fishbowl field index.js ← exports bin functions vim.wasm ← Emscripten-compiled binary (~5MB) vim.mjs ← Emscripten JS glue (factory function) termcap_stub.a ← build artifact (not shipped — baked into .wasm)WASI package:
@fishnet/wasi-echo/ package.json ← standard npm + fishbowl field, assets: ["program.wasm"] index.js ← exports bin functions test-wasi-echo.wasm ← wasm32-wasip1 binary, post-processed with wasm-opt --asyncifyWASI packages have no glue file. The .wasm binary is standalone — all WASI syscalls (fd_read, fd_write, path_open, etc.) route directly to proc.fs.* methods via the WasiHost adapter, bypassing Emscripten’s MEMFS entirely.
The assets are loaded by the bin function at invocation time, not at install time. The package registers a thin BinFunction that calls wasmExec() (Emscripten) or wasiExec() (WASI) internally:
// @fishnet/vim/index.jsimport { wasmExec } from '@fishnet/core/runtime/wasm/runtime'import type { AssetResolver, Extension, BinFunction } from '@fishnet/core'
// Lazy-load: factory cached after first invocationlet factory: EmscriptenModuleFactory | undefinedlet cachedWasm: Uint8Array | undefined
// Asset resolver — injected by pkg install (guest-side), or uses import.meta.url (host-side)let assets: AssetResolver | undefined
export default function vim(injectedAssets?: AssetResolver): Extension { if (injectedAssets) assets = injectedAssets return { bins: { vim: vimBin, vi: vimBin }, env: { TERM: 'xterm-256color' }, files: { '/usr/share/vim/vimrc': 'set nocompatible\nset backspace=indent,eol,start\n', }, }}
const vimBin: BinFunction = async (proc) => { if (!factory) { if (assets) { // Guest-side: assets were fetched by pkg install const glueCode = await assets.resolve('vim.mjs') factory = evaluateGlue(glueCode) } else { // Host-side: resolve from node_modules via import.meta.url const modulePath = new URL('./vim.mjs', import.meta.url) factory = (await import(modulePath.href)).default } } if (!cachedWasm) { cachedWasm = assets ? await assets.resolve('vim.wasm') : await readFile(new URL('./vim.wasm', import.meta.url)) }
return wasmExec(proc, { wasmBinary: cachedWasm, glueFactory: factory, args: proc.argv.slice(1), ttyMode: 'raw', preloadPaths: ['/tmp', proc.env.HOME ?? '/home'], })}Emscripten package.json
{ "name": "@fishnet/vim", "version": "9.1.0", "description": "Vim editor for fishbowl", "main": "./index.js", "files": ["index.js", "vim.wasm", "vim.mjs"], "fishbowl": { "bins": ["vim", "vi"], "env": { "TERM": "xterm-256color", "VIMRUNTIME": "/usr/share/vim" }, "files": { "/usr/share/vim/vimrc": "set nocompatible\n" } }}WASI Package Example
A WASI package is simpler — no glue file, no MEMFS. The bin function calls wasiExec() instead of wasmExec(), and WASI syscalls route directly to proc.fs.*:
// @fishnet/wasi-echo/index.jsimport { createWasiBin } from '@fishnet/core/runtime/wasm/create-wasi-bin'import type { AssetResolver, Extension } from '@fishnet/core'
export default function (resolver?: AssetResolver): Extension { const bin = createWasiBin(resolver ?? import.meta.url, 'test-wasi-echo.wasm') return { bins: { 'wasi-echo': bin } }}WASI package.json
{ "name": "@fishnet/wasi-echo", "version": "1.0.0", "description": "Echo tool for fishbowl (WASI)", "main": "./index.js", "files": ["index.js", "test-wasi-echo.wasm"], "fishbowl": { "bins": ["wasi-echo"], "assets": ["test-wasi-echo.wasm"] }}Note: WASI packages declare only a .wasm file in assets — no .mjs glue. The generate-registry tool handles WASI packages with no glue transformation (assets are placed flat alongside the bundle).
From the outside, both Emscripten and WASI packages are indistinguishable from @fishnet/data-tools. The user types a command and it works. The compilation backend is invisible.
Decision: WASM runtime details are internal to the bin function. Asyncify stack size, TTY mode, preload paths, Emscripten module configuration, WASI preopens — all of this lives inside the bin implementation. The package manifest declares the same things any package declares: bins, env, files. This keeps the package system simple and means WASM programs don’t need special treatment.
Decision: WASM asset loading uses the package’s asset resolver, not
import.meta.urldirectly.import.meta.urlworks in Node.js (resolving intonode_modules/) but does not exist innew Function()contexts used for browser registry installs. Instead, the package entry point receives an asset resolver injected by the runtime:
Asset Resolution
WASM packages need to load binary assets (.wasm, .mjs) at invocation time. The resolution strategy differs by context:
Host-side (Node.js, build-time .use()): The package is a real ES module in node_modules/. import.meta.url works. The bin function resolves assets relative to its own module:
const wasmUrl = new URL('./vim.wasm', import.meta.url)const wasmBinary = await readFile(wasmUrl) // host fs, not fishbowl fsGuest-side (runtime pkg install, browser): The bundle is fetched from a registry and evaluated. No import.meta.url available. Instead, the pkg installer fetches all declared assets alongside the bundle, caches them, and injects an assetResolver into the package’s evaluation context:
// pkg install provides this to the evaluated bundleconst assetResolver = { resolve(name: string): Uint8Array | Promise<Uint8Array> url(name: string): string // for passing to Emscripten's locateFile}
// The bin function uses it:const vimBin: BinFunction = async (proc) => { const wasmBinary = await assets.resolve('vim.wasm') const glueUrl = assets.url('vim.mjs') // ...}This follows the pattern established by sql.js (locateFile), esbuild-wasm (wasmURL), and ffmpeg.wasm (separate core package). The asset resolver is the abstraction that unifies host-side path resolution and guest-side cache lookup.
In the browser, assets fetched from the registry are cached in the PkgFS backing store (IndexedDB, memory). WebAssembly.compileStreaming can be used when assets are served with application/wasm content type.
Decision: dual asset resolution —
import.meta.urlfor host-side, injected resolver for guest-side. The package entry point checks which is available and uses the appropriate mechanism. This keeps the host-side path zero-config while supporting browser/registry installs withoutimport.meta.url.
WASM Package Categories
| Program type | Backend | TTY mode | Asyncify | Example |
|---|---|---|---|---|
| Interactive TUI | Emscripten | raw | required (compiled in) | vim (C), ratatui-counter (Rust) |
| CLI filter (Emscripten) | Emscripten | line | optional | sqlite3, python, lua |
| CLI tool (WASI) | WASI | n/a | required (wasm-opt) | test-wasi-echo, test-wasi-cat, test-wasi-env |
| One-shot tool | either | none | not needed | wasm-strip, binaryen |
Emscripten packages use wasmExec() / createWasmBin(). WASI packages use wasiExec() / createWasiBin(). The bin function chooses the right backend.
WASI packages always need Asyncify (via wasm-opt --asyncify post-processing) because all proc.fs.* methods are async — WASI syscalls like fd_read, fd_write, and path_open must suspend the WASM program to await them. The RawAsyncify class in src/runtime/wasm/wasi-asyncify.ts drives the binaryen Asyncify ABI (distinct from Emscripten’s built-in Asyncify wrapper).
Catalog
The catalog mechanism enables pre-bundled but not-yet-installed packages. This is essential for sandboxed environments (LLM tool use, browser, offline) where network fetching isn’t available.
Build-Time Catalog
import coreutils from '@fishnet/coreutils'import vim from '@fishnet/vim'import sqlite from '@fishnet/sqlite'import python from '@fishnet/python'
const image = Unix() .use(coreutils()) // installed: bins available immediately .catalog(vim, sqlite, python) // available: factories, called at `pkg install` time .build()
const instance = image.boot()// instance has coreutils bins// `pkg install vim` works without network — vim is in the catalogHow Catalog Works
.catalog() accepts Extension factories (functions returning Extensions), not Extension instances. This is critical for multi-instance correctness: each image.boot() must produce independent Extension objects with their own memoryFS() instances, not share mutable state across instances.
const image = Unix() .use(coreutils()) .catalog(vim, sqlite, python) // functions, not vim(), sqlite(), python() .build()
const a = image.boot() // pkg install vim → calls vim() → fresh Extensionconst b = image.boot() // pkg install vim → calls vim() → different fresh ExtensionDecision: catalog stores Extension factories (functions), not Extension instances. This is why packages export functions (
export default function vim(): Extension), not objects. If the catalog stored Extension instances, twoimage.boot()calls would share the samememoryFS()references — writes in instance A would appear in instance B. By storing factories,pkg installcalls the factory to get a fresh Extension with fresh fileservers per instance. This mirrors the builder pattern:.use(sqlite())calls the factory eagerly at build time,.catalog(sqlite)defers the call to install time.
Instead of applying them to the system at boot, the catalog holds factories in a side-channel map for deferred installation. The catalog has two layers:
In-memory layer: A Map<string, () => Extension> held by the kernel (or PkgFS implementation). Each entry is a factory function that produces a fresh Extension (with fresh BinFunction references and Fileserver instances) when called. These are JavaScript functions that cannot be serialized to the filesystem.
Filesystem layer: PkgFS writes static metadata to /pkg/.catalog/ so that pkg list --available and pkg search can discover cataloged packages without evaluating code:
/pkg/ .catalog/ @fishnet/vim/ manifest.json ← fishbowl metadata (name, version, bins, env, files) @fishnet/sqlite/ manifest.jsonWhen pkg install @fishnet/vim runs inside the sandbox:
- Check
/pkg/.catalog/@fishnet/vim/manifest.json— found - Call the factory function from the in-memory catalog map → get a fresh Extension
- Apply to live system (mount, register bins via setExec, seed files, start services)
- Write install record to
/pkg/@fishnet/vim/manifest.json - Remove from catalog (both map and filesystem)
This two-layer approach is the same pattern used by the init system: the service graph is an in-memory structure, but service metadata is inspectable via /proc/. The filesystem provides visibility; the in-memory structure provides the live objects.
Decision: catalog is an in-memory map with filesystem metadata for discoverability. Extensions contain live JavaScript objects (functions, fileserver instances) that cannot be serialized. The catalog holds these objects in memory and writes only the static metadata to PkgFS. This is honest about what the filesystem can and cannot store.
Catalog vs Registry
pkg install <name>: 1. Check /pkg/<name>/ → already installed? Done. 2. Check /pkg/.catalog/ → pre-bundled? Install from catalog. 3. Check /pkg/.cache/ → previously fetched? Install from cache. 4. Fetch from registry → download, cache, install.In a fully offline sandbox (browser, LLM), steps 3–4 are unavailable. The catalog is the only source. This is by design — the distro maintainer controls what’s available.