Files
zeekayandGitHub 8d4e50c665 feat(runtime): embed goja + esbuild; ZAP-over-HTTP transport (#9)
* feat(runtime): embed goja + esbuild; add ZAP-over-HTTP transport

Establish zip as the canonical Hanzo Go web framework with embedded
in-process JS execution and a native ZAP RPC HTTP transport. One Go
binary mounts native handlers, legacy TS/JS handlers, WebSocket, and
the ZAP RPC plane — no separate runtime service, no inter-service RPC,
no container-per-service.

zip/runtime/ (new):
- jsvm.go    NewJSRuntime / Eval / RegisterHostFn / LoadModule, backed
             by a pool of *goja.Runtime (pure Go, no CGO). Pool pattern
             lifted from hanzoai/base/plugins/gojavm, slimmed to run +
             forEach so late host-fn / module registration reaches warm
             VMs.
- pool.go    fixed-size goja VM pool; saturated calls fall back to a
             fully-provisioned one-off VM.
- require.go CommonJS-style require(): per-VM module sources + memoized
             exports in a Go-side side-table (never visible to JS).
- esbuild.go TranspileToES5: TS/modern-JS -> goja-ready JS via esbuild's
             pure-Go API (ES2015 target, CommonJS format).
- handler.go JSHandler / JSModule: mount an Express-shaped (req, res) JS
             function as a fiber.Handler; req mirrors method/path/query/
             headers/body, res mirrors status/set/json/send.
- json.go    encode/decode through internal/jsonenc — one wire format.

zip/zaprpc/ (new on existing Registry seam):
- envelope.go canonical ZAP RPC frame (service/method/payload) over the
              zap-proto/go wire codec.
- http.go     HTTPHandler(reg): serves the ZAP RPC plane over HTTP POST;
              binary envelope canonical, X-ZAP-* headers are a hint only.

examples/express-in-zip/ (new): legacy app.ts -> real esbuild -> real
goja -> real Fiber -> real HTTP, with an integration test proving
GET /legacy/foo and POST /legacy/bar body echo.

README: embedded-JS migration path + single-binary (HIP-0106) sections.

go build ./... and go test ./... (incl. -race) pass.

* feat(runtime): ctx-aware Eval + multi-file BundleToES2015

Two consumer-integration follow-ups so callers (base's gojavm) stop
reaching past the runtime abstraction.

EvalContext(ctx, src): interrupt a running goja VM when ctx fires.
A watcher goroutine calls vm.Interrupt(ctx.Err()) on ctx.Done(); the
resulting *goja.InterruptedError is unwrapped back to ctx.Err(). The
watcher is joined and ClearInterrupt() is called before the VM is
returned to the pool, so a borrowed VM is never left interrupted.
Eval routes through EvalContext(context.Background()). InvokeFunc(ctx,
fnName, args...) calls a named global JS function under the same
cancellation discipline. This lets base drop the goroutine+timeout race
around Invoke.

BundleToES2015(entry, files, opts): transpile + bundle a multi-file
TS/JS tree from an in-memory files map using esbuild's pure-Go Build
API with an onResolve/onLoad plugin serving a virtual namespace — no
real FS access, no CGO. Relative imports resolve against the importer's
dir; opts.External keeps bare specifiers as require()/import calls for
goja to resolve at runtime. This replaces consumers importing esbuild's
api package directly to bundle.

Tests (go test -race clean):
- EvalContext(50ms, while(true){}) returns DeadlineExceeded in ~50ms
- successful eval returns the value; VM reusable afterwards
- two-file CommonJS bundle runs in goja, entry.run() === 42
- external dep stays require("external") and resolves via host module

* feat(runtime): unified multi-language Runner over the extruntime SPI

Add Runner — a registry of language → Engine that executes arbitrary-
language source in goroutine-scoped, context.Context-bound tasks. Each
Run spawns a worker (engine.Eval) and a watcher (ctx.Done() → engine
interrupt), joining both before return so a borrowed VM is never handed
back to its pool while still interrupted. This generalizes the settled
JSRuntime.EvalContext cancellation contract (one worker, one watcher,
joined, engine-specific interrupt) across every backend a host registers.

zip ships exactly one in-tree engine: the pure-Go goja engine, surfaced
via (*JSRuntime).Engine() and registered as "js". It self-manages ctx
inside EvalContext (per-call interrupt on the exact borrowed VM), so the
Runner's watcher is a benign no-op for it. base's backends (pyvm, v8vm,
wasmvm, starkvm) plug in through the same Engine seam via thin extruntime
adapters at app startup — the registry is the seam, not an import edge.
zip never imports base; the dependency direction stays base → zip.

Engine is the zip-side projection of base's extruntime backends: any
value implementing Eval(ctx, src, args) satisfies it. Optional
Interruptible lets the watcher abort a runaway eval out-of-band.

Wire the unified runner into examples/express-in-zip: POST /runtime/:lang
reads the body as source, dispatches to the engine for :lang, returns
{result} or 404 {error:"unknown language"}.

Tests (go test -race, all green):
  - JS happy path: Run(ctx, "js", "1+2") → int64(3)
  - JS function call: define + invoke in one eval
  - JS ctx deadline: while(true){} interrupted, DeadlineExceeded, ~50ms
  - unknown language: ErrUnknownLanguage sentinel, no panic
  - concurrent: 100 goroutines, registry RWMutex + per-call VM isolation
  - example: POST /runtime/js "40+2" → {"result":42}; cobol → 404

No cgo. No base import. README documents the Runner + SPI registration.
2026-06-16 16:05:27 -07:00

3.5 KiB

zip/runtime — embedded multi-language code runner

runtime embeds language backends into a zip service so a request can run arbitrary-language source in-process — no separate runtime service, no inter-service RPC, no container-per-language.

Runner

Runner is a registry of language → Engine plus a goroutine-scoped, context.Context-bound dispatch loop:

type Runner interface {
    Run(ctx context.Context, lang string, src []byte, args ...any) (any, error)
    Register(lang string, engine Engine) error
    Languages() []string
}

func NewRunner() Runner

Each Run call:

  1. Looks up the engine for lang. No engine → ErrUnknownLanguage.
  2. Spawns a worker goroutine that calls engine.Eval(ctx, src, args...).
  3. Spawns a watcher goroutine on ctx.Done() that, for an Interruptible engine, calls Interrupt(ctx.Err()) so a runaway eval unwinds instead of leaking.
  4. Joins both goroutines before returning, so a borrowed VM is never handed back to its pool while still interrupted. Returns the engine result, or ctx.Err() if cancellation won the race.

This generalizes the settled JSRuntime.EvalContext contract (one worker, one watcher, joined before return, engine-specific interrupt on cancel) across every backend a host registers.

Engine SPI

// Implemented by every backend.
type Engine interface {
    Eval(ctx context.Context, src []byte, args ...any) (any, error)
}

// Optional: backend whose running eval can be aborted out-of-band
// (goja vm.Interrupt, pyvm PyErr_SetInterrupt, wasm trap).
type Interruptible interface {
    Interrupt(cause error)
}

An engine that fully manages cancellation inside Eval (zip's goja engine does — EvalContext owns the per-call interrupt watcher and targets only the borrowed VM) marks itself so the Runner's watcher is a benign no-op and the not-interruptible warning is suppressed. An engine that is neither Interruptible nor self-managed gets a one-time registration warning: its Run still returns ctx.Err() promptly, but the worker runs to completion.

Dependency direction — the registry is the seam, not an import edge

zip does not import hanzoai/base. Engine here is a zip-side projection of base's extruntime backends; any value implementing Eval satisfies it. base imports zip and registers its backends at app startup — zip exposes the registry and never names base:

// In the host binary (which already imports base) — NOT in zip:
runner := zipruntime.NewRunner()

// zip ships exactly one in-tree engine: pure-Go goja, no cgo.
js, _ := zipruntime.NewJSRuntime(zipruntime.JSOptions{PoolSize: 8})
runner.Register("js", js.Engine())

// base's backends plug in through the same seam via thin extruntime
// adapters — zip stays base-free:
runner.Register("py",    pyvmEngine())    // base/plugins/pyvm   (cgo CPython)
runner.Register("js-v8", v8vmEngine())    // base/plugins/v8vm   (cgo V8)
runner.Register("wasm",  wasmvmEngine())  // base/plugins/wasmvm (pure-Go wazero)
runner.Register("stark", starkvmEngine()) // base/plugins/starkvm

gojavm (base/plugins/gojavm) is the canonical first backend: it wraps this package's *JSRuntime, so there is exactly one goja engine in the stack and base, cloud, and every other zip consumer share it.

See also

  • jsvm.go — pooled goja VMs + EvalContext cancellation contract.
  • esbuild.go — TS/modern-JS → ES5 for the goja engine.
  • examples/express-in-zip/main.go — POST /runtime/:lang mounts the Runner over HTTP.