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.tsthat import fromcreate-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 compatibilityexport { 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
git add src/runtime/wasm/create-wasm-bin.ts packages/test-helpers/create-wasm-bin.tsgit 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
git add src/runtime/wasm/create-wasm-bin.ts test/wasm/create-wasm-bin.test.tsgit 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
git add packages/ratatui-counter/index.tsgit 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
git add packages/ratatui-files/index.ts packages/ratatui-sysmon/index.ts packages/micro-editor/index.tsgit 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.binsif (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
git add packages/vim/index.ts demo/shared.tsgit commit -m "feat: vim package accepts AssetResolver for registry install"Task 6: Update test WASM packages
Files:
- Modify: All
packages/test-*/index.tsthat usecreateWasmBin
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
git add packages/test-*/index.tsgit 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
git add tools/generate-registry/src/glue-transform.ts tools/generate-registry/test/glue-transform.test.tsgit 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
git add tools/generate-registry/src/bundler.tsgit 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
git add packages/ratatui-counter/package.json packages/ratatui-files/package.json packages/ratatui-sysmon/package.jsongit 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()
// 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
git add test/pkg/wasm-registry.test.tsgit 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:
cd tools/generate-registrynode -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:
cd /path/to/projectnpx 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
mkdir -p demo/web/public/registry/packages/counter/1.0.0cp packages/ratatui-counter/counter.wasm demo/web/public/registry/packages/counter/1.0.0/For the glue, apply the CJS transformation:
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
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
cd demo/web && npx vite --port 3000- Step 2: Test the full flow
Open the browser and run:
pkg updatepkg searchpkg install countercounterVerify:
pkg updateshows the counter package in the indexpkg searchlists counter with descriptionpkg install counterfetches bundle + assets, installs successfullycounterlaunches 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.