Files
zeekayandHanzo Dev fb32c54715 runtime: the JS toolchain is opt-in — Loader/Module are declared at the root
The root zip package imported zip/runtime for exactly ONE thing: the type of
Config.Loader. zip/runtime imports dop251/goja (a JS interpreter) and
evanw/esbuild/pkg/api (a JS bundler) directly, so every consumer of zip
compiled a JavaScript toolchain to name an interface — and the interface was
itself only a type ALIAS of a dependency-free leaf, internal/runtime.

Loader and Module now live where they are consumed: the zip root, next to
App.Module, which is the only thing that calls them. internal/runtime and the
zip/runtime aliases are deleted — one home, one name. zip/runtime keeps goja
and esbuild and stays the JavaScript implementation; it is simply a separate
import now, so the cost lands on the binaries that ask for JS.

There is no hot-path coupling to invert: the root only ever calls through the
interface (a.loader.LoadOne) into a loader the consumer constructs and passes
in via Config.Loader. That contract is unchanged.

Measured with `go list -deps . | wc -l` in a CLEAN module importing only zip,
outside any other module graph:

    before  315 packages, 33 of them goja + esbuild
    after   258 packages,  0
    opt-in  314 packages, 33   (a consumer that DOES import zip/runtime)

57 packages leave and none arrive: esbuild 25, goja 8, x/text 9, regexp2 3,
sourcemap 2, pprof 1, go/ast+token+scanner, archive/zip, encoding/base32,
hash/maphash — plus the two zip packages no longer reachable.

hanzoai/cloud, which imports zip/runtime nowhere, measured against this commit
vs its parent (same zip baseline, so the delta is only this change):

    cmd/cloud        402 -> 346    (JS 33 -> 0)
    cloud root       603 -> 547
    plugin/crm       605 -> 549
    plugin/storage   716 -> 664

cmd/cloud builds, and `go build ./...` yields the byte-identical error set
before and after (3 pre-existing k8s/autoscaling failures, unrelated).

Also corrected a doc claim that was already false: Config.Loader said it "is
satisfied by *extruntime.Loader". It is not, and was not before this commit —
extruntime.LoadDir returns map[string]extruntime.Module, and Go requires
IDENTICAL result types, so an equivalent method set does not satisfy the
interface. The docs now say an adapter returning zip.Module is required.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 17:06:24 -07:00

86 lines
3.0 KiB
Go

// module-routes example — mount HIP-0105 extension modules as routes.
//
// The loader is constructed by the embedding binary (typically
// *extruntime.Loader from hanzoai/base/plugins/extruntime, with
// gojavm + wasmvm + pyvm + starlarkvm registered). zip is a CONSUMER
// of the loader — it accepts anything that satisfies zip.Loader, which is
// declared at the zip root so this example compiles with no JS toolchain.
//
// This example stubs a tiny in-memory loader so the example compiles
// without pulling hanzoai/base. In production, back it with
// *extruntime.Loader through an adapter that returns zip.Module — Go
// requires identical result types, so extruntime's own Module type does
// not satisfy zip.Loader by direct assignment.
package main
import (
"context"
"encoding/json"
"log"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
// stubLoader is a zero-dependency zip.Loader that echoes the
// envelope back. Replace with *extruntime.Loader in a real service.
type stubLoader struct{}
func (stubLoader) Runtimes() []string {
return []string{"goja", "wazero", "pyvm", "starlark", "native"}
}
func (stubLoader) LoadDir(_ context.Context, _ string) (map[string]zip.Module, error) {
return map[string]zip.Module{}, nil
}
func (stubLoader) LoadOne(_ context.Context, dir string) (zip.Module, error) {
return &stubModule{dir: dir, rt: "goja"}, nil
}
type stubModule struct {
dir string
rt string
}
func (m *stubModule) Name() string { return m.dir }
func (m *stubModule) Runtime() string { return m.rt }
func (m *stubModule) Exports() []string { return []string{"handler"} }
func (m *stubModule) Close() error { return nil }
func (m *stubModule) Invoke(_ context.Context, fn string, payload []byte) ([]byte, error) {
return json.Marshal(map[string]any{
"status": 200,
"headers": map[string]string{"X-Module-Stub": "1"},
"body": map[string]any{"fn": fn, "echo": json.RawMessage(payload)},
})
}
func main() {
app := zip.New(zip.Config{
AppName: "module-routes",
Loader: stubLoader{},
AllowedRuntimes: []string{"goja", "wazero", "pyvm", "starlark"},
})
app.Use(middleware.Recover(), middleware.RequestID())
// Mount one module per runtime. In a real deployment each `./ext/*`
// directory contains an extension.json manifest + compiled artifact.
mustMount(app, "POST /v1/policy/eval", "wazero", "./ext/policy")
mustMount(app, "POST /v1/transform", "pyvm", "./ext/transform")
mustMount(app, "POST /v1/webhook", "goja", "./ext/webhook")
mustMount(app, "POST /v1/route", "starlark", "./ext/route")
log.Fatal(app.Listen("http://:8080"))
}
func mustMount(app *zip.App, methodPath, rt, modulePath string) {
// The stub loader claims rt="goja" for every directory; in this
// demo we re-register stubModule with the right rt so Module
// passes the runtime-match check. In production the real
// extruntime loader reads extension.json and the manifest decides.
if err := app.Module(methodPath, "goja", modulePath); err != nil {
log.Fatalf("mount %s: %v", methodPath, err)
}
_ = rt
}