Skip to content

Port Contract

This doc is the source of truth for what a port is in this repo, what every port must contain, and how to add a new one. It exists because ports/ has historically carried several different meanings (packages, fixtures, build staging), and Waves 2–6 of the ports-ontology cleanup need one reference to conform to.

Target length: ~2 pages. If you came here to browse: read the invariant, the template, and the 10-step checklist.

Invariant

A subdirectory of ports/ is a self-contained adaptation of third-party software for the fishnet runtime. It is NOT a workspace package, NOT a build output, and NOT a test fixture. Each port ships src/ (source), build.sh (recipe), package.json (contract), index.ts (TS wrapper), and its compiled artifact — depending on nothing in ports/ outside itself.

Current roster

As of Wave 0 (2026-04-21), ports/ contains four entries that qualify as ports under the invariant:

  • ports/vim/ — Vim editor (Emscripten, raw-TTY)
  • ports/jaq/ — jq-like JSON processor (WASI, Rust)
  • ports/tree/tree(1) directory lister (WASI)
  • ports/micro-editor/ — minimal C editor (Emscripten, raw-TTY)

All four ports conform to the full template as of Wave 6 (2026-04-21): each owns its src/, build.sh, package.json, index.ts, LICENSE, and committed artifact. The historical staging directories ports/wasm-builds/ and ports/wasi-builds/ no longer exist — Rust/C sources live alongside their consumer (per-port for the four real ports; per-fixture under test/fixtures/wasi/test-<name>/src/ for WASI test fixtures).

Template

The canonical on-disk layout for a port:

ports/<name>/
src/ source tree (Cargo.toml + src/*.rs for Rust, *.c for C, etc.)
lib/ pre-built static deps NOT produced by build.sh (optional)
build.sh executable recipe; cwd = port dir; produces artifact at root
package.json name, version, scripts.build, upstream block, fishbowl block
index.ts TS wrapper (createWasiBin / createWasmBin)
<name>.wasm compiled artifact (committed until a later LFS migration)
LICENSE copy of upstream license

Contributors start by copying one of two canonical templates:

  • WASI reference — ports/jaq/ (pure Rust compiled to wasm32-wasip1, post-processed with wasm-opt --asyncify).
  • Emscripten reference — ports/micro-editor/ (simple C compiled through emcc with Asyncify for raw-TTY).

Pick the template whose runtime matches what you’re porting. Do not invent a third layout; the invariant is there so CI can iterate ports/* uniformly.

Extension: large upstream trees via submodule

Ports that adapt a large upstream tree (vim, emacs-shaped projects, anything where vendoring 100k+ lines of source as regular tracked files would balloon the monorepo) MAY have src/ BE a git submodule pointing at the upstream repo. In that case, in-house additions (shim sources, glue code, patches) go in shims/ as a sibling of src/, not nested inside it — the submodule is read-only from our perspective, and our additions need a writable home outside it. Pre-built artifacts produced from those shims still live in lib/. Worked example: ports/vim/ (Wave 3). Three peers under the port root: src/ (upstream submodule), shims/ (our sources), lib/ (pre-built libs).

Build contract

Every port’s package.json declares its build recipe identically:

{
"scripts": { "build": "bash build.sh" }
}

The literal string "bash build.sh" is required so CI and grep can rely on a single uniform verb. package.json#scripts.build is the mount point; build.sh is the server behind it. CI invokes pnpm --dir ports/<p> run build, which resolves to bash build.sh.

build.sh must:

  1. Exit 0 on success.
  2. Produce every file listed in package.json#fishbowl.assets at the port root (e.g. <name>.wasm, <name>.mjs for Emscripten).
  3. Be idempotent — re-running on clean or dirty inputs yields the same artifacts.
  4. Precondition-check its compiler (command -v cargo, command -v emcc) and fail with a clear error message if it’s missing, rather than emitting a cryptic compiler failure.
  5. Live at the port root with cwd = "$(dirname "$0")" so it can be invoked from anywhere in the tree.

Note: the "bash build.sh" mandate is here for uniformity at four ports, not as doctrine. If a future pure-TS or pure-cargo port has real reason to skip the bash shim, revisit this clause rather than wrapping build.ts in a one-line shim to satisfy grep.

Rules

Plain-prose rules that follow from the invariant. Waves 2+ enforce these through path fixes and moves; future CI work may add mechanical checks.

  • Self-contained. A port depends on nothing in ports/ outside its own directory. It does not import (or #include, or source) from any sibling port.

  • Third-party. A port adapts software that originates outside this repo. Internal-only components (product features, test harnesses, shared test utilities) live in packages/ or test/, not ports/.

  • One identifier. A port’s directory name, its package.json#name (minus the @fishnet/ scope), and its registryName in registry.config.json (if registered) all agree. ports/jaq/@fishnet/jaq"registryName": "jaq".

  • One primary artifact. Each port produces exactly one primary artifact, declared in package.json#fishbowl.assets. Secondary files (Emscripten glue .mjs, upstream license) are included as supporting assets, not co-equal outputs.

  • Upstream pinned in package.json. Origin, version, and license of the adapted software go in package.json#upstream as a structured object — parseable for future license-audit / version-drift checks — not in a free-form UPSTREAM.txt.

    "upstream": {
    "source": "https://github.com/vim/vim",
    "version": "v9.1.0",
    "license": "Vim"
    }
  • LICENSE file. Each port ships a LICENSE file copying the upstream license verbatim. package.json#upstream.license is the SPDX identifier; LICENSE is the legal text.

  • Retirement is deletion. Retiring a port means deleting ports/<name>/ and removing its entry from registry.config.json in a single commit. No ports/_archive/ — git history is the archive.

Distribution pipeline

How a port reaches a user’s shell via pkg install:

ports/<name>/ ports/<name>/package.json
src/, build.sh, index.ts, <name>.wasm has fishbowl.{type,bins,assets}
│ │
│ pnpm --dir ports/<name> run build │
▼ │
artifacts committed at ports/<name>/root │
│ │
▼ │
registry.config.json ◄── port declared by name/registryName ───┘
│ pnpm run registry:rebuild
packages/registry-tools/ (TS: @fishnet/registry-tools)
│ esbuild bundles index.ts + copies flat assets
registry/ registry/index.json
index.json, packages/<name>/bundle.mjs + assets
│ HTTP fetch (Node/browser)
pkg install <name> user shell prompt

Key references:

  • packages/registry-tools/ — first-class workspace package; stays put.
  • registry.config.json — root-level, consumed by pnpm run registry:rebuild.
  • registry/ — generated output (currently sibling to source; may migrate under dist/ in a follow-up).

Adding a port — 10-step checklist

Copy-pasteable commands for adding a new port <name>. Replace <name> and adjust the source/license stanzas. The commands below assume the post-Wave-6 layout — every port owns its own src/ tree at ports/<name>/src/.

Terminal window
# 1. Copy the canonical template for your runtime.
# WASI/Rust:
cp -r ports/jaq ports/<name>
# Emscripten/C:
cp -r ports/micro-editor ports/<name>
# 2. Update package.json: name, version, upstream, and fishbowl blocks.
# Replace every occurrence of the template's name. Set upstream.source /
# upstream.version / upstream.license to the adapted software's origin.
$EDITOR ports/<name>/package.json
# 3. Confirm scripts.build is the literal "bash build.sh". Do not rename.
jq -r '.scripts.build' ports/<name>/package.json
# → "bash build.sh"
# 4. Swap in upstream source under src/. Clean out any template leftovers.
rm -rf ports/<name>/src
git clone --depth 1 --branch <version-tag> <upstream-url> ports/<name>/src
# Or: extract a tarball, vendor a subset, etc.
# 5. Adapt build.sh to invoke your compiler against src/. Keep the
# `cd "$(dirname "$0")"` guard and preserve `set -euo pipefail`.
$EDITOR ports/<name>/build.sh
# 6. Build once and verify the artifact lands at the port root.
cd ports/<name> && bash build.sh && cd -
ls ports/<name>/<name>.wasm
# 7. Write index.ts: pick createWasiBin (WASI) or createWasmBin (Emscripten),
# keep the `(resolver?: AssetResolver)` signature, export a testSpec.
$EDITOR ports/<name>/index.ts
# 8. Copy the upstream LICENSE file into the port root verbatim.
cp <upstream-license-source> ports/<name>/LICENSE
# 9. (Optional, if you want the port installable via `pkg install`.)
# Register in registry.config.json — name, directory, and registryName
# must all agree. Then rebuild the registry.
$EDITOR registry.config.json
pnpm run registry:rebuild
# 10. Commit — include src/, build.sh, package.json, index.ts, LICENSE,
# and the compiled <name>.wasm artifact in a single commit.
git add ports/<name>
git commit -m "ports(<name>): adapt <upstream@version>"

After commit, run the relevant test suite (pnpm run test:wasm for WASM/WASI ports) to confirm your testSpec passes.