Skip to content

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

UserRolePrimary interaction
Distro maintainerCurates the @fishnet/* package set, decides what ships in standard imagesPublishes packages, defines catalogs
App developerEmbeds fishbowl, composes a system from packages at build timenpm install + .use()
End user / LLMRuns inside the sandbox, installs and uses tools at runtimepkg install, pkg list
Package authorWrites a new tool (JS or WASM) and publishes itnpm publish with fishbowl field

Functional Requirements

#RequirementNotes
F1Install — resolve package by name, register bins, seed files, set env defaultsMust work build-time (.use()) and runtime (pkg install)
F2Remove — unregister bins, clean seeded files, stop servicesRuntime only. Refuse if open FDs on mounts
F3List / Search — show installed and available packagesInstalled = active bins. Available = catalog + registry
F4Bin resolution — PATH resolves package-provided bins transparentlyUser types vim, shell finds it. No difference from a JS bin
F5File seeding — packages declare files placed in virtual FS on installvimrc, runtime data, man pages, etc.
F6Env defaults — packages declare env vars, sandbox env takes precedenceTERM=xterm-256color unless already set
F7Dependencies — package A requires package Bnpm deps for build-time. Runtime: flat check-and-install
F8Catalog — pre-bundle packages as “available” without installingFor sandboxes where network isn’t available
F9Services — packages can declare supervised servicesInit system integration (already exists)
F10Registry — configurable package source, npm-compatible by defaultDefault @fishbowl scope on npmjs.com
F11WASM transparency — WASM-backed bins are indistinguishable from JS binsPackage system doesn’t know or care about implementation
F12Version pinningpkg install name@versionRegistry resolves latest if unspecified

Future Requirements

These are deferred but the architecture must not preclude them:

#RequirementDesign constraint
V2-1Semver resolution — version ranges, conflict detectionPackage manifest already has version. Registry can serve version lists. depends field supports name@range syntax
V2-2Upgrade in-placepkg upgrade nameRemove + install is the v1 path. Upgrade needs state migration hooks (preupgrade, postupgrade)
V2-3Lockfile — reproducible runtime installsBuild-time uses npm lockfile. Runtime lockfile is a /pkg/lock.json listing exact versions
V2-4Package signing — verify bundle integrityRegistry-level concern. Add signature field to manifest response. pkg verifies before eval
V2-5Post-install scripts (sandboxed) — richer lifecycleCurrent hooks are shell commands. Could add JS hooks that run in a restricted namespace
V2-6Transitive dependencies — full dependency treeFlat 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/sqlite
import sqlite from '...' fetch from registry
Unix().use(sqlite()) evaluate → Extension
builder accumulates apply to live namespace
.boot() → init → services + shell bins available immediately

A 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:httpd works against package.json metadata alone.
  • Pre-install validation. pkg install can check whether a package’s requires are satisfiable before fetching the bundle. “This package requires mount:/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:

index.js
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 overrides package.json scripts.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 usedNo entry point, registry indexing, static analysis, discoveryHost-side .use(), guest-side pkg install with entry point
ConfigurableNo — fixed defaultsYes — accepts arguments
Inspectable without evalYesNo
PrecedenceFallback when no entry point existsFull 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 (fishbowl field, 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 a name field 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 install reads 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, since BinFunction references cannot exist in JSON.

Package Categories

Packages are categorized by what they provide:

CategoryExampleTypical contents
Bin pack@fishnet/data-toolsbins only — new commands
FS adapter@fishnet/s3mounts + maybe config bins
Environment@fishnet/python-sandboxbins + mounts + env + files
Service@fishnet/http-serverbins + services
Driver@fishnet/webrtc-ttyTtyFS 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 uses rm. 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 call memoryFS(), set up directories, and configure services directly in code. Hooks exist for the guest-side pkg install path where the user has only a shell. This matches npm’s model: postinstall runs after npm install in a terminal, not when your bundler resolves an import.

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:

Terminal window
$ pkg install @fishnet/sqlite
fetching @fishnet/[email protected]...
mounting /var/db
registering bins: sqlite3, dbsyncd
starting service: dbsyncd
running postinstall hook...
done.
$ pkg install @fishnet/[email protected]
fetching @fishnet/[email protected]...
...
$ pkg install @fishnet/s3 --bucket=my-data --region=us-east-1
fetching @fishnet/[email protected]...
mounting /mnt/s3
registering bins: s3ctl
done.
$ pkg list
NAME 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/sqlite
running preremove hook...
stopping service: dbsyncd
unmounting /var/db
unregistering bins: sqlite3, dbsyncd
done.

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:

Terminal window
$ pkg install @fishnet/s3 --bucket=my-data --region=us-east-1

This 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:

Terminal window
$ pkg install @fishnet/better-grep
registering bins: grep, egrep
warning: grep: overriding @fishnet/coreutils
done.
$ pkg remove @fishnet/better-grep
unregistering bins: grep, egrep
restoring grep from @fishnet/coreutils
done.

Decision: last-installed wins for bin name conflicts, with warning and restore on remove. This follows the Unix convention where /usr/local/bin shadows /usr/bin in PATH — the later entry wins. The installed manifest (at /pkg/<name>/manifest.json) records which bins were overridden and by whom, so pkg remove can restore the previous package’s bin. This is simple, predictable, and matches user expectations from both Unix (update-alternatives) and npm (npx precedence).

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:

Terminal window
$ pkg remove @fishnet/sqlite
error: cannot unmount /var/db 2 open file descriptors
pid 5 (sqlite3): /var/db/main.db
hint: stop services and close files first: svc stop dbsyncd

Services 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.json

Step 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, by pkg search for discovery, and by pre-install validation to detect conflicts. No code evaluation needed.
  • Evaluated layer (PackageManifest in types.ts): bins: Record<string, BinFunction> — live callable functions. Produced by evaluating the bundle’s default export, which returns an Extension containing real BinFunction references.

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: PackageManifest in types.ts represents the evaluated package, not the static declaration. The static declaration is JSON (inspectable without eval). The PackageManifest type captures the result after evaluation — when BinFunction references exist in memory. These are two views of the same package at different points in its lifecycle. The static bins: string[] and the live bins: 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 with new 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.js

PkgFS 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. The pkg bin 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 point
GET /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:

Terminal window
$ echo $PKG_REGISTRY
https://registry.js-unix.dev
$ export PKG_REGISTRY=https://my-company.dev/fishbowl/packages
$ pkg install @internal/custom-tools

Decision: self-hostable, static-compatible registry. The minimum viable registry is a static file server: a directory of <name>/manifest.json and <name>/bundle.js files. 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 assets array; the pkg command 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/sqlite needs 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 dynamic import() — 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 as npm 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 pkg bin 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_REGISTRY points 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 the pkg bin.

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 implementations
export 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 region
export 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 configurable
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) },
}
}

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:

BackendTargetGlue fileAsync I/OTTY supportUse case
Emscriptenwasm32-unknown-emscripten.mjs glue requiredEmscripten Asyncify (compiled in)Full (termios, ioctl)TUI apps (vim, ratatui)
WASIwasm32-wasip1None — standalone .wasmwasm-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 --asyncify

WASI 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.js
import { wasmExec } from '@fishnet/core/runtime/wasm/runtime'
import type { AssetResolver, Extension, BinFunction } from '@fishnet/core'
// Lazy-load: factory cached after first invocation
let factory: EmscriptenModuleFactory | undefined
let 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.js
import { 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.url directly. import.meta.url works in Node.js (resolving into node_modules/) but does not exist in new 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 fs

Guest-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 bundle
const 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.url for 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 without import.meta.url.

WASM Package Categories

Program typeBackendTTY modeAsyncifyExample
Interactive TUIEmscriptenrawrequired (compiled in)vim (C), ratatui-counter (Rust)
CLI filter (Emscripten)Emscriptenlineoptionalsqlite3, python, lua
CLI tool (WASI)WASIn/arequired (wasm-opt)test-wasi-echo, test-wasi-cat, test-wasi-env
One-shot tooleithernonenot neededwasm-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 catalog

How 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 Extension
const b = image.boot() // pkg install vim → calls vim() → different fresh Extension

Decision: 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, two image.boot() calls would share the same memoryFS() references — writes in instance A would appear in instance B. By storing factories, pkg install calls 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.json

When pkg install @fishnet/vim runs inside the sandbox:

  1. Check /pkg/.catalog/@fishnet/vim/manifest.json — found
  2. Call the factory function from the in-memory catalog map → get a fresh Extension
  3. Apply to live system (mount, register bins via setExec, seed files, start services)
  4. Write install record to /pkg/@fishnet/vim/manifest.json
  5. 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.