Skip to content

WASM HTTP Registry Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make WASM packages installable from an HTTP registry using the same pkg update && pkg install flow as JS packages.

Architecture: Add AssetResolver support to createWasmBin so WASM package factories can receive registry-preloaded assets instead of requiring import.meta.url. Transform Emscripten ESM glue to CJS at registry build time. All existing infrastructure (asset preloading, cachedResolver, evaluateBundle) is unchanged.

Tech Stack: TypeScript, esbuild, vitest, Emscripten glue files

Spec: architecture/packages/wasm-registry.md


Chunk 1: createWasmBin AssetResolver support

Task 1: Promote createWasmBin to src/runtime/wasm/

createWasmBin currently lives in packages/test-helpers/create-wasm-bin.ts (a test utility). Since registry-installed WASM packages will bundle it at runtime, it belongs in src/runtime/wasm/.

Files:

  • Create: src/runtime/wasm/create-wasm-bin.ts

  • Modify: packages/test-helpers/create-wasm-bin.ts (becomes re-export shim)

  • Modify: all packages/*/index.ts that import from create-wasm-bin (update import path)

  • Step 1: Copy create-wasm-bin.ts to src/runtime/wasm/

Copy the file contents from packages/test-helpers/create-wasm-bin.ts to src/runtime/wasm/create-wasm-bin.ts. Adjust the import paths:

  • '../../src/runtime/wasm/runtime.js''./runtime.js'
  • '../../src/kernel/types.js''../../kernel/types.js'

The wasmExec import changes from ../../src/runtime/wasm/runtime.js to ./runtime.js since it’s now in the same directory.

  • Step 2: Convert packages/test-helpers/create-wasm-bin.ts to re-export shim

Replace the entire file with:

// Re-export from canonical location for backwards compatibility
export {
createWasmBin,
derivePreloadPaths,
type CreateWasmBinOptions,
} from '../../src/runtime/wasm/create-wasm-bin.js'
  • Step 3: Run tests to verify nothing broke

Run: npx vitest run --reporter=verbose 2>&1 | tail -20 Expected: All 1418+ tests pass. The re-export shim preserves all existing imports.

  • Step 4: Commit
Terminal window
git add src/runtime/wasm/create-wasm-bin.ts packages/test-helpers/create-wasm-bin.ts
git commit -m "refactor: promote createWasmBin to src/runtime/wasm/"

Task 2: Add AssetResolver overload to createWasmBin

Files:

  • Modify: src/runtime/wasm/create-wasm-bin.ts

  • Create: test/wasm/create-wasm-bin.test.ts

  • Step 1: Write the failing test for resolver-based asset loading

Create test/wasm/create-wasm-bin.test.ts:

import { describe, it, expect, vi } from 'vitest'
import { createWasmBin } from '../../src/runtime/wasm/create-wasm-bin.js'
import type { AssetResolver } from '../../src/runtime/pkg/types.js'
import type { ProcContext } from '../../src/kernel/types.js'
describe('createWasmBin with AssetResolver', () => {
it('calls resolver.resolve() for wasm and resolver.loadModule() for glue', async () => {
const fakeWasm = new Uint8Array([0, 97, 115, 109]) // WASM magic bytes
const fakeFactory = vi.fn()
const resolver: AssetResolver = {
resolve: vi.fn(async (name: string) => {
if (name === 'test.wasm') return fakeWasm
throw new Error(`unexpected resolve: ${name}`)
}),
loadModule: vi.fn(async (name: string) => {
if (name === 'test.mjs') return fakeFactory
throw new Error(`unexpected loadModule: ${name}`)
}),
}
const bin = createWasmBin(resolver, 'test.wasm', 'test.mjs')
// The bin is a function — calling it would invoke wasmExec which needs
// a real Emscripten module. We can't test that here. Instead, verify
// the bin was created (it's a function).
expect(typeof bin).toBe('function')
})
it('accepts a string (import.meta.url) as first arg — existing behavior', () => {
const bin = createWasmBin('file:///fake/path', 'test.wasm', 'test.mjs')
expect(typeof bin).toBe('function')
})
})
  • Step 2: Run test to verify it fails

Run: npx vitest run test/wasm/create-wasm-bin.test.ts --reporter=verbose Expected: FAIL — createWasmBin doesn’t accept an AssetResolver (type error or wrong behavior).

  • Step 3: Add AssetResolver overload to createWasmBin

In src/runtime/wasm/create-wasm-bin.ts, add the import and modify the function signature:

Add import at top:

import type { AssetResolver } from '../pkg/types.js'

Replace the existing createWasmBin function with overloaded signatures:

/** Host-side: resolve assets via import.meta.url */
export function createWasmBin(
metaUrl: string,
wasmFile: string,
glueFile: string,
opts?: CreateWasmBinOptions,
): BinFunction
/** Registry-side: resolve assets via AssetResolver */
export function createWasmBin(
resolver: AssetResolver,
wasmFile: string,
glueFile: string,
opts?: CreateWasmBinOptions,
): BinFunction
export function createWasmBin(
source: string | AssetResolver,
wasmFile: string,
glueFile: string,
opts?: CreateWasmBinOptions,
): BinFunction {
let cachedWasm: Uint8Array | undefined
let cachedFactory: EmscriptenModuleFactory | undefined
const bin: BinFunction = async (proc): Promise<ExitCode> => {
if (cachedWasm === undefined || cachedFactory === undefined) {
if (typeof source === 'string') {
const assets = await loadAssets(source, wasmFile, glueFile)
cachedWasm = assets.wasmBinary
cachedFactory = assets.glueFactory
} else {
cachedWasm = await source.resolve(wasmFile)
cachedFactory = await source.loadModule(glueFile) as EmscriptenModuleFactory
}
}
const preloadPaths = typeof opts?.preloadPaths === 'function'
? opts.preloadPaths(proc)
: (opts?.preloadPaths ?? [])
const execOpts: Parameters<typeof wasmExec>[0] = {
wasmBinary: cachedWasm,
glueFactory: cachedFactory,
proc,
args: proc.argv,
ttyMode: opts?.ttyMode ?? 'line',
preloadPaths,
}
if (opts?.env !== undefined) {
execOpts.env = opts.env
}
return wasmExec(execOpts)
}
return bin
}

Remove the old non-overloaded createWasmBin function. Keep loadAssets, loadAssetsNode, loadAssetsBrowser unchanged.

  • Step 4: Run test to verify it passes

Run: npx vitest run test/wasm/create-wasm-bin.test.ts --reporter=verbose Expected: PASS

  • Step 5: Run full test suite

Run: npx vitest run --reporter=verbose 2>&1 | tail -20 Expected: All tests pass. Existing callers still work via the string overload.

  • Step 6: Commit
Terminal window
git add src/runtime/wasm/create-wasm-bin.ts test/wasm/create-wasm-bin.test.ts
git commit -m "feat: add AssetResolver overload to createWasmBin"

Chunk 2: Update WASM package entry points

Task 3: Update ratatui-counter to accept resolver

Files:

  • Modify: packages/ratatui-counter/index.ts

  • Step 1: Update ratatui-counter factory to accept AssetResolver

The current code creates counterBin at module level. Move it inside the factory and accept an optional resolver:

import type { Extension } from '../../src/kernel/types.js'
import type { AssetResolver } from '../../src/runtime/pkg/types.js'
import type { TestSpec } from '../test-helpers/test-spec.js'
import { createWasmBin } from '../test-helpers/create-wasm-bin.js'
export default function ratauiCounter(resolver?: AssetResolver): Extension {
const counterBin = createWasmBin(
resolver ?? import.meta.url,
'counter.wasm', 'counter.mjs',
{ ttyMode: 'raw', preloadPaths: ['/tmp'] },
)
return {
bins: { counter: counterBin },
}
}
export function testSpec(): TestSpec {
// ... unchanged ...
}
  • Step 2: Run WASM package tests

Run: npx vitest run test/wasm/packages.test.ts --reporter=verbose 2>&1 | tail -30 Expected: ratatui-counter test passes (catalog path still uses import.meta.url fallback).

  • Step 3: Commit
Terminal window
git add packages/ratatui-counter/index.ts
git commit -m "feat: ratatui-counter accepts AssetResolver for registry install"

Task 4: Update ratatui-files, ratatui-sysmon, micro-editor

Files:

  • Modify: packages/ratatui-files/index.ts

  • Modify: packages/ratatui-sysmon/index.ts

  • Modify: packages/micro-editor/index.ts

  • Step 1: Update ratatui-files

Same pattern as counter — move createWasmBin inside factory, accept optional resolver:

import type { Extension } from '../../src/kernel/types.js'
import type { AssetResolver } from '../../src/runtime/pkg/types.js'
import type { TestSpec } from '../test-helpers/test-spec.js'
import { createWasmBin } from '../test-helpers/create-wasm-bin.js'
export default function ratauiFiles(resolver?: AssetResolver): Extension {
const filesBin = createWasmBin(
resolver ?? import.meta.url,
'files.wasm', 'files.mjs',
{ ttyMode: 'raw', preloadPaths: ['/bin', '/tmp', '/home', '/etc', '/usr'] },
)
return {
bins: { files: filesBin },
}
}
export function testSpec(): TestSpec {
// ... unchanged ...
}
  • Step 2: Update ratatui-sysmon
import type { Extension } from '../../src/kernel/types.js'
import type { AssetResolver } from '../../src/runtime/pkg/types.js'
import type { TestSpec } from '../test-helpers/test-spec.js'
import { createWasmBin } from '../test-helpers/create-wasm-bin.js'
export default function ratauiSysmon(resolver?: AssetResolver): Extension {
const sysmonBin = createWasmBin(
resolver ?? import.meta.url,
'sysmon.wasm', 'sysmon.mjs',
{ ttyMode: 'raw', preloadPaths: ['/proc', '/bin', '/tmp', '/home', '/etc', '/dev', '/usr'] },
)
return {
bins: { sysmon: sysmonBin },
}
}
export function testSpec(): TestSpec {
// ... unchanged ...
}
  • Step 3: Update micro-editor
import type { Extension } from '../../src/kernel/types.js'
import type { AssetResolver } from '../../src/runtime/pkg/types.js'
import type { TestSpec } from '../test-helpers/test-spec.js'
import { createWasmBin, derivePreloadPaths } from '../test-helpers/create-wasm-bin.js'
const files = { '/tmp/micro-test.txt': 'hello' }
export default function microEditor(resolver?: AssetResolver): Extension {
const bin = createWasmBin(
resolver ?? import.meta.url,
'micro-editor.wasm', 'micro-editor.mjs',
{ ttyMode: 'raw', preloadPaths: derivePreloadPaths(files) },
)
return {
bins: { 'micro-editor': bin },
files,
}
}
export function testSpec(): TestSpec {
// ... unchanged ...
}

Note: files stays at module level (it’s static data, not a mutable resource). Only createWasmBin moves inside.

  • Step 4: Run WASM package tests

Run: npx vitest run test/wasm/packages.test.ts --reporter=verbose 2>&1 | tail -30 Expected: All WASM package tests pass.

  • Step 5: Commit
Terminal window
git add packages/ratatui-files/index.ts packages/ratatui-sysmon/index.ts packages/micro-editor/index.ts
git commit -m "feat: ratatui-files, ratatui-sysmon, micro-editor accept AssetResolver"

Task 5: Update vim package

Files:

  • Modify: packages/vim/index.ts

Vim is more complex — it wraps createWasmBin output with vimBin for --cmd injection. Both must move inside the factory. The files, computePreloadPaths, extractFilePaths, flagsWithArg, and SUPPRESS_QUERIES constants stay at module level (they’re static).

  • Step 1: Update vim factory to accept AssetResolver

Move baseBin and vimBin creation inside the vim() factory. Keep all static constants, helper functions, and files at module level. Add AssetResolver import and optional parameter:

import type { AssetResolver } from '../../src/runtime/pkg/types.js'

Replace the module-level baseBin and vimBin declarations and the vim() export:

// Remove these module-level declarations:
// const baseBin = createWasmBin(import.meta.url, 'vim.wasm', 'vim.js', { ... })
// export const vimBin: BinFunction = async (proc): Promise<ExitCode> => { ... }
// Replace with:
export default function vim(resolver?: AssetResolver): Extension {
const baseBin = createWasmBin(resolver ?? import.meta.url, 'vim.wasm', 'vim.js', {
ttyMode: 'raw',
env: {
TERM: 'xterm-256color',
VIMRUNTIME: '/usr/share/vim',
VIM: '/usr/share/vim',
},
preloadPaths: computePreloadPaths,
})
const vimBin: BinFunction = async (proc): Promise<ExitCode> => {
const injectedArgv = [proc.argv[0]!, '--cmd', SUPPRESS_QUERIES, ...proc.argv.slice(1)]
return baseBin({ ...proc, argv: injectedArgv })
}
return {
bins: { vim: vimBin, vi: vimBin },
env: {
TERM: 'xterm-256color',
VIMRUNTIME: '/usr/share/vim',
VIM: '/usr/share/vim',
HOME: '/home',
},
files,
}
}

Also export vimBin as a standalone for demo/shared.ts which imports it directly. Since vimBin now lives inside the factory, demo/shared.ts needs updating too — it currently does import { vimBin as vimBinBase } from '../packages/vim/index.js'. The simplest approach: call vim() (no resolver → import.meta.url) and extract the bin:

// In demo/shared.ts, change:
// import { vimBin as vimBinBase } from '../packages/vim/index.js'
// To:
// import vimDefault from '../packages/vim/index.js'
// const vimExt = vimDefault()
// const vimBinBase = vimExt.bins!['vim']!
  • Step 2: Update demo/shared.ts import

Modify demo/shared.ts to get vimBin from the factory:

import vimFactory from '../packages/vim/index.js'
// Get vim bin from the factory (host-side, uses import.meta.url)
const vimExt = vimFactory()
const vimBins = vimExt.bins
if (vimBins === undefined) throw new Error('vim extension missing bins')
const vimBinBase = vimBins['vim']
if (vimBinBase === undefined) throw new Error('vim bin not found in extension')
  • Step 3: Run tests

Run: npx vitest run --reporter=verbose 2>&1 | tail -20 Expected: All tests pass including vim E2E tests.

  • Step 4: Commit
Terminal window
git add packages/vim/index.ts demo/shared.ts
git commit -m "feat: vim package accepts AssetResolver for registry install"

Task 6: Update test WASM packages

Files:

  • Modify: All packages/test-*/index.ts that use createWasmBin

All 19+ test WASM packages follow the same pattern. Each needs the same transformation: move createWasmBin inside the default export function, accept optional AssetResolver.

  • Step 1: Identify all test packages that use createWasmBin

Run: grep -rl 'createWasmBin' packages/test-*/index.ts packages/*/index.ts | sort

  • Step 2: Update each test package

For each package, apply the same transformation. Example for test-echo:

Before:

const bin = createWasmBin(import.meta.url, 'test-echo.wasm', 'test-echo.mjs')
export default function (): Extension {
return { bins: { 'test-echo': bin } }
}

After:

import type { AssetResolver } from '../../src/runtime/pkg/types.js'
// ...
export default function (resolver?: AssetResolver): Extension {
const bin = createWasmBin(resolver ?? import.meta.url, 'test-echo.wasm', 'test-echo.mjs')
return { bins: { 'test-echo': bin } }
}

For packages with files or env in their extension, keep those at module level — only createWasmBin moves inside.

For packages using derivePreloadPaths(files), keep that call alongside createWasmBin inside the factory (both use the module-level files constant).

  • Step 3: Run WASM package tests

Run: npx vitest run test/wasm/packages.test.ts --reporter=verbose 2>&1 | tail -30 Expected: All 19 WASM test packages pass.

  • Step 4: Run full test suite

Run: npx vitest run --reporter=verbose 2>&1 | tail -20 Expected: All 1418+ tests pass.

  • Step 5: Commit
Terminal window
git add packages/test-*/index.ts
git commit -m "feat: all test WASM packages accept AssetResolver"

Chunk 3: Emscripten glue transformation

Task 7: Write transformGlueToCjs

Files:

  • Create: tools/generate-registry/src/glue-transform.ts
  • Create: tools/generate-registry/test/glue-transform.test.ts

The Emscripten .mjs glue file uses ESM syntax that new Function() cannot parse. This function transforms it to CJS at registry build time.

Two known Emscripten output variants:

Pattern A (older Emscripten — ratatui-counter, vim):

async function Module(moduleArg={}) { ... }
export default Module;

Pattern B (newer Emscripten — micro-editor, test packages):

var createModule = (() => { ... return (async function(moduleArg={}) { ... }); })();
export default createModule;

Both contain:

  • import.meta.url (3 occurrences)

  • await import("module") (1 occurrence, in Node.js branch)

  • export default <Name> (1 occurrence, always last statement)

  • Step 1: Write the failing test with real glue snippets

Create tools/generate-registry/test/glue-transform.test.ts:

import { describe, it, expect } from 'vitest'
import { transformGlueToCjs } from '../src/glue-transform.js'
describe('transformGlueToCjs', () => {
it('transforms Pattern A (older Emscripten) — export default FunctionName', () => {
const input = [
'async function Module(moduleArg={}){',
'var ENVIRONMENT_IS_NODE=typeof process=="object";',
'if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");var require=createRequire(import.meta.url)}',
'var _scriptName=import.meta.url;',
'var scriptDirectory="";',
'/* ... body ... */',
'return moduleRtn}export default Module;',
].join('')
const result = transformGlueToCjs(input)
// Must not contain ESM syntax
expect(result).not.toContain('export default')
expect(result).not.toContain('import.meta.url')
expect(result).not.toContain('await import("module")')
// Must export via CJS
expect(result).toContain('module.exports')
// Must be evaluable by new Function
expect(() => {
const moduleExports: Record<string, unknown> = {}
const fn = new Function('module', 'exports', result)
const mod = { exports: moduleExports }
fn(mod, moduleExports)
}).not.toThrow()
})
it('transforms Pattern B (newer Emscripten) — IIFE + export default', () => {
const input = [
'var createModule=(()=>{',
'var _scriptName=import.meta.url;',
'return(async function(moduleArg={}){',
'var Module=moduleArg;',
'if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");var require=createRequire(import.meta.url)}',
'/* ... body ... */',
'return moduleRtn});})();',
'export default createModule;',
].join('')
const result = transformGlueToCjs(input)
expect(result).not.toContain('export default')
expect(result).not.toContain('import.meta.url')
expect(result).not.toContain('await import("module")')
expect(result).toContain('module.exports')
expect(() => {
const moduleExports: Record<string, unknown> = {}
const fn = new Function('module', 'exports', result)
const mod = { exports: moduleExports }
fn(mod, moduleExports)
}).not.toThrow()
})
it('preserves ENVIRONMENT_IS_NODE guard structure', () => {
const input = 'if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("module");var require=createRequire(import.meta.url)}var _scriptName=import.meta.url;export default Module;'
const result = transformGlueToCjs(input)
// The Node.js branch should be neutralized but not break surrounding code
expect(result).not.toContain('import.meta.url')
})
it('handles single-quoted await import (vim.js format)', () => {
const input = "if(ENVIRONMENT_IS_NODE){const{createRequire}=await import('module');var require=createRequire(import.meta.url)}export default Module;"
const result = transformGlueToCjs(input)
expect(result).not.toContain("await import('module')")
expect(result).not.toContain('import.meta.url')
expect(result).toContain('module.exports')
})
})
  • Step 2: Run test to verify it fails

Run: cd tools/generate-registry && npx vitest run test/glue-transform.test.ts --reporter=verbose Expected: FAIL — module not found.

  • Step 3: Implement transformGlueToCjs

Create tools/generate-registry/src/glue-transform.ts:

/**
* Transform Emscripten ESM glue code to CJS-compatible format.
*
* Emscripten outputs ESM with:
* - `export default <Name>` (final export)
* - `import.meta.url` (script location, 3 occurrences)
* - `await import("module")` (Node.js createRequire)
*
* These are parse-time syntax errors in `new Function()` context.
* This transform makes the glue evaluable as CJS.
*/
export function transformGlueToCjs(source: string): string {
let result = source
// 1. Replace `export default <Name>;` with `module.exports = <Name>;`
// Always appears at the end of the file.
result = result.replace(
/export\s+default\s+(\w+)\s*;?\s*$/,
'module.exports = $1;',
)
// 2. Replace `import.meta.url` with empty string.
// In browser context, the glue uses scriptDirectory from URL() constructor
// which handles empty string gracefully (falls back to page URL).
result = result.replace(/import\.meta\.url/g, '""')
// 3. Neutralize `await import("module")` Node.js branch.
// Pattern: `const{createRequire}=await import("module");var require=createRequire(...)`
// Emscripten uses double quotes in minified output (counter.mjs) and
// single quotes in multiline output (vim.js). Match both.
// This is inside an `if(ENVIRONMENT_IS_NODE){...}` guard, so the surrounding
// code structure is preserved. We just need to remove the await import.
result = result.replace(
/const\s*\{\s*createRequire\s*\}\s*=\s*await\s+import\(\s*['"]module['"]\s*\)\s*;/g,
'var createRequire=undefined;',
)
return result
}
  • Step 4: Run test to verify it passes

Run: cd tools/generate-registry && npx vitest run test/glue-transform.test.ts --reporter=verbose Expected: PASS

  • Step 5: Test against real Emscripten glue files

Add a test that reads the actual counter.mjs from disk and verifies the transform works:

Add to tools/generate-registry/test/glue-transform.test.ts:

import * as fs from 'node:fs/promises'
import * as path from 'node:path'
it('transforms real counter.mjs from packages/ratatui-counter/', async () => {
const gluePath = path.resolve(__dirname, '../../../packages/ratatui-counter/counter.mjs')
let glueSource: string
try {
glueSource = await fs.readFile(gluePath, 'utf-8')
} catch {
// Skip if .mjs file not built
return
}
const result = transformGlueToCjs(glueSource)
expect(result).not.toContain('export default')
expect(result).not.toContain('import.meta.url')
expect(result).not.toContain('await import("module")')
expect(result).toContain('module.exports')
// Must be parseable by new Function (no syntax errors)
expect(() => new Function('module', 'exports', result)).not.toThrow()
})
it('transforms real vim.js from packages/vim/ (multiline, single quotes)', async () => {
const gluePath = path.resolve(__dirname, '../../../packages/vim/vim.js')
let glueSource: string
try {
glueSource = await fs.readFile(gluePath, 'utf-8')
} catch {
// Skip if .js file not built
return
}
const result = transformGlueToCjs(glueSource)
expect(result).not.toContain('export default')
expect(result).not.toContain('import.meta.url')
expect(result).not.toContain("await import('module')")
expect(result).not.toContain('await import("module")')
expect(result).toContain('module.exports')
expect(() => new Function('module', 'exports', result)).not.toThrow()
})
  • Step 6: Run the expanded test

Run: cd tools/generate-registry && npx vitest run test/glue-transform.test.ts --reporter=verbose Expected: PASS (or skip if counter.mjs not built).

  • Step 7: Commit
Terminal window
git add tools/generate-registry/src/glue-transform.ts tools/generate-registry/test/glue-transform.test.ts
git commit -m "feat: transformGlueToCjs converts Emscripten ESM glue to CJS"

Task 8: Integrate glue transform into bundlePackage

Files:

  • Modify: tools/generate-registry/src/bundler.ts

  • Step 1: Add glue transform to asset copying

In bundler.ts, import the transform and apply it when copying .mjs assets:

import { transformGlueToCjs } from './glue-transform.js'

In the asset-copying loop inside bundlePackage(), replace the fs.copyFile call with transform logic for .mjs files:

for (const asset of declaredAssets) {
const srcPath = path.join(packageDir, asset)
const assetRelPath = `packages/${packageName}/${version}/${asset}`
const assetOutPath = path.join(outputDir, assetRelPath)
await fs.mkdir(path.dirname(assetOutPath), { recursive: true })
if (asset.endsWith('.mjs') || asset.endsWith('.js')) {
// Transform Emscripten glue ESM → CJS for new Function() evaluation
const source = await fs.readFile(srcPath, 'utf-8')
const transformed = transformGlueToCjs(source)
await fs.writeFile(assetOutPath, transformed)
} else {
await fs.copyFile(srcPath, assetOutPath)
}
copiedAssets.push(assetRelPath)
}
  • Step 2: Add external: ['node:*'] to esbuild config

In the esbuild.build() call, add:

external: ['node:*'],

This prevents esbuild from failing on node:url, node:path, node:fs/promises imports in create-wasm-bin.ts.

  • Step 3: Run existing generate-registry tests

Run: cd tools/generate-registry && npx vitest run --reporter=verbose Expected: All existing tests pass.

  • Step 4: Commit
Terminal window
git add tools/generate-registry/src/bundler.ts
git commit -m "feat: generate-registry transforms glue to CJS, adds node:* external"

Task 9: Add package.json to WASM packages

Files:

  • Create: packages/ratatui-counter/package.json
  • Create: packages/ratatui-files/package.json
  • Create: packages/ratatui-sysmon/package.json

No WASM packages currently have package.json files. The generate-registry tool reads fishbowl.assets from package.json to know which assets to copy. Without these, the automated tooling path won’t work for WASM packages.

  • Step 1: Create package.json for ratatui-counter

Create packages/ratatui-counter/package.json:

{
"name": "@fishnet/counter",
"version": "1.0.0",
"description": "Ratatui counter TUI demo (WASM)",
"main": "./index.ts",
"fishbowl": {
"type": "extension",
"bins": ["counter"],
"assets": ["counter.wasm", "counter.mjs"]
}
}
  • Step 2: Create package.json for ratatui-files

Create packages/ratatui-files/package.json:

{
"name": "@fishnet/files",
"version": "1.0.0",
"description": "Ratatui file browser TUI (WASM)",
"main": "./index.ts",
"fishbowl": {
"type": "extension",
"bins": ["files"],
"assets": ["files.wasm", "files.mjs"]
}
}
  • Step 3: Create package.json for ratatui-sysmon

Create packages/ratatui-sysmon/package.json:

{
"name": "@fishnet/sysmon",
"version": "1.0.0",
"description": "Ratatui system monitor TUI (WASM)",
"main": "./index.ts",
"fishbowl": {
"type": "extension",
"bins": ["sysmon"],
"assets": ["sysmon.wasm", "sysmon.mjs"]
}
}
  • Step 4: Commit
Terminal window
git add packages/ratatui-counter/package.json packages/ratatui-files/package.json packages/ratatui-sysmon/package.json
git commit -m "feat: add package.json with fishbowl.assets to WASM packages"

Task 10: WASM registry integration test

Files:

  • Create: test/pkg/wasm-registry.test.ts

This test exercises the full pkg install flow for a WASM package from a file:// registry — the automated equivalent of the manual browser test.

  • Step 1: Write the integration test

Create test/pkg/wasm-registry.test.ts:

import { describe, it, expect } from 'vitest'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { Unix, stdSystem } from '../../src/index.js'
import { pkgManager } from '../../src/presets/pkg-manager.js'
import { transformGlueToCjs } from '../../tools/generate-registry/src/glue-transform.js'
describe('WASM package install from file:// registry', () => {
it('installs a WASM package with assets from a file registry', async () => {
// Build a temp file:// registry with counter package
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'jsunix-wasm-reg-'))
const pkgDir = path.join(tmpDir, 'packages', 'counter', '1.0.0')
await fs.mkdir(pkgDir, { recursive: true })
// Copy counter.wasm
const wasmSrc = path.resolve(__dirname, '../../packages/ratatui-counter/counter.wasm')
try {
await fs.access(wasmSrc)
} catch {
// Skip test if WASM not built
return
}
await fs.copyFile(wasmSrc, path.join(pkgDir, 'counter.wasm'))
// Transform and copy counter.mjs
const glueSrc = await fs.readFile(
path.resolve(__dirname, '../../packages/ratatui-counter/counter.mjs'),
'utf-8',
)
await fs.writeFile(path.join(pkgDir, 'counter.mjs'), transformGlueToCjs(glueSrc))
// Generate a minimal bundle that uses the resolver
// (This is what generate-registry would produce)
const bundleDir = path.join(tmpDir, 'packages', 'counter')
const bundleContent = `exports.default = function(assetResolver) {
var wasmExec = require('../../src/runtime/wasm/runtime.js').wasmExec;
// Simplified: just return an extension with a bin that verifies assets load
return {
bins: {
counter: async function(proc) {
// Verify assets are resolvable
var wasm = await assetResolver.resolve('counter.wasm');
if (!(wasm instanceof Uint8Array) || wasm.length === 0) {
await proc.stderr.write('asset resolve failed\\n');
return 1;
}
await proc.stdout.write('wasm-asset-ok:' + wasm.length + '\\n');
return 0;
}
}
};
};`
await fs.writeFile(path.join(bundleDir, '1.0.0.bundle.js'), bundleContent)
// Write index.json
const index = {
schemaVersion: 1,
packages: {
counter: {
name: 'counter',
versions: [{
version: '1.0.0',
description: 'WASM counter test',
depends: {},
bins: ['counter'],
bundle: 'packages/counter/1.0.0.bundle.js',
assets: [
'packages/counter/1.0.0/counter.wasm',
'packages/counter/1.0.0/counter.mjs',
],
}],
},
},
}
await fs.writeFile(path.join(tmpDir, 'index.json'), JSON.stringify(index))
// Boot a system with file:// registry
const image = await Unix()
.use(stdSystem())
.use(pkgManager({ sources: [`file://${tmpDir}`] }))
.build()
const sys = await image.boot()
let stdout = ''
// pkg update
const updateHandle = await sys.spawn('sh', ['sh', '-c', 'pkg update'], {
stdout: { write: async (s: string) => { stdout += s } },
stderr: { write: async () => {} },
})
await updateHandle.wait()
expect(stdout).toContain('1 packages')
// pkg install counter
stdout = ''
const installHandle = await sys.spawn('sh', ['sh', '-c', 'pkg install counter'], {
stdout: { write: async (s: string) => { stdout += s } },
stderr: { write: async (s: string) => { stdout += s } },
})
await installHandle.wait()
expect(stdout).toContain('installed [email protected]')
// Run counter bin (simplified — just checks asset resolution)
stdout = ''
const runHandle = await sys.spawn('counter', ['counter'], {
stdout: { write: async (s: string) => { stdout += s } },
stderr: { write: async (s: string) => { stdout += s } },
})
await runHandle.wait()
expect(stdout).toContain('wasm-asset-ok:')
// Cleanup
await fs.rm(tmpDir, { recursive: true })
})
})
  • Step 2: Run the test

Run: npx vitest run test/pkg/wasm-registry.test.ts --reporter=verbose Expected: PASS (or skip if counter.wasm not built).

  • Step 3: Commit
Terminal window
git add test/pkg/wasm-registry.test.ts
git commit -m "test: WASM package install from file:// registry integration test"

Chunk 4: Demo registry + E2E verification

Task 11: Add counter to demo web registry

Files:

  • Modify: demo/web/public/registry/index.json

  • Create: demo/web/public/registry/packages/counter/1.0.0.bundle.js

  • Create: demo/web/public/registry/packages/counter/1.0.0/counter.wasm (copy)

  • Create: demo/web/public/registry/packages/counter/1.0.0/counter.mjs (transformed)

  • Step 1: Generate the counter bundle using generate-registry

First, verify esbuild can bundle the counter package:

Terminal window
cd tools/generate-registry
node -e "
const { bundlePackage } = require('./src/bundler.js');
// If this fails, use ts-node or build the tool first
"

If the tool isn’t built, generate the bundle manually:

Terminal window
cd /path/to/project
npx esbuild packages/ratatui-counter/index.ts \
--bundle --format=iife --global-name=__pkg \
--platform=browser --target=es2020 \
--external:'node:*' \
--banner:js='exports.default = function(assetResolver) {' \
--footer:js=$'\nreturn __pkg.default(assetResolver);\n};' \
--outfile=demo/web/public/registry/packages/counter/1.0.0.bundle.js
  • Step 2: Copy and transform counter assets
Terminal window
mkdir -p demo/web/public/registry/packages/counter/1.0.0
cp packages/ratatui-counter/counter.wasm demo/web/public/registry/packages/counter/1.0.0/

For the glue, apply the CJS transformation:

Terminal window
node -e "
const fs = require('fs');
let src = fs.readFileSync('packages/ratatui-counter/counter.mjs', 'utf-8');
src = src.replace(/export\s+default\s+(\w+)\s*;?\s*$/, 'module.exports = \$1;');
src = src.replace(/import\.meta\.url/g, '\"\"');
src = src.replace(/const\s*\{\s*createRequire\s*\}\s*=\s*await\s+import\(\s*\"module\"\s*\)\s*;/g, 'var createRequire=undefined;');
fs.writeFileSync('demo/web/public/registry/packages/counter/1.0.0/counter.mjs', src);
"
  • Step 3: Update demo registry index.json

Add the counter package entry to demo/web/public/registry/index.json:

"counter": {
"name": "counter",
"versions": [{
"version": "1.0.0",
"description": "Ratatui counter TUI demo (WASM)",
"depends": {},
"bins": ["counter"],
"bundle": "packages/counter/1.0.0.bundle.js",
"assets": [
"packages/counter/1.0.0/counter.wasm",
"packages/counter/1.0.0/counter.mjs"
]
}]
}
  • Step 4: Commit
Terminal window
git add demo/web/public/registry/
git commit -m "feat: add counter WASM package to demo HTTP registry"

Task 12: E2E verification in browser

Files: None (testing only)

  • Step 1: Start the web demo
Terminal window
cd demo/web && npx vite --port 3000
  • Step 2: Test the full flow

Open the browser and run:

pkg update
pkg search
pkg install counter
counter

Verify:

  1. pkg update shows the counter package in the index
  2. pkg search lists counter with description
  3. pkg install counter fetches bundle + assets, installs successfully
  4. counter launches the ratatui counter TUI (interactive raw-mode app)
  • Step 3: Verify network requests

In browser DevTools Network tab, confirm:

  • GET /registry/index.json (pkg update)

  • GET /registry/packages/counter/1.0.0.bundle.js (bundle fetch)

  • GET /registry/packages/counter/1.0.0/counter.wasm (asset preload)

  • GET /registry/packages/counter/1.0.0/counter.mjs (asset preload)

  • Step 4: Run full test suite one final time

Run: npx vitest run --reporter=verbose 2>&1 | tail -20 Expected: All 1418+ tests pass. No regressions.