feat: zip canonical home — the ZAP-native web framework, consolidated

zip is shared protocol infra (the fiber-over-ZAP web framework used by every
Lux/Hanzo/Zoo Go binary — nodes, gateway, cloud, iam, kms, o11y, ai, …), so its
home is the ZAP protocol family (zap-proto/{go,http,zapd,zip}), NOT any one org's
brand namespace. This consolidates the rich framework (formerly hanzoai/zip v0.5.0
— Ctx/Handler/Errorf/Err*/HTTPError, typed handlers, middleware, runtime/extension
loading, openapi, validate, zaprpc) with the real ZAP transport (ListenZAP/
ListenHTTP/Serve over zap-proto/http) into ONE canonical module.

Module path github.com/hanzoai/zip -> github.com/zap-proto/zip. Code identical to
hanzoai/zip v0.5.0; promoted to v1.0.0 as the stable shared-infra framework. The
prior hanzoai/zip lineage is retired — one home, one import path, one way.

Verified: go build/vet/test ./... green; the ZAP round-trip integration test serves
/v1/health over the real ZAP transport (200 + handler JSON).
This commit is contained in:
2026-07-01 23:08:35 -07:00
parent 4bf404bfc6
commit c581e0af7b
69 changed files with 6734 additions and 211 deletions
+75
View File
@@ -0,0 +1,75 @@
# HANZO_CHANGES.md — diff from upstream zeekay/zip
`zeekay/zip` was a tiny `net/http` wrapper (~250 LOC) experimenting with
Sinatra-style routing in Go circa 2014. Hanzo adopted the name and
rebuilt the framework from scratch on **Fiber v3 / fasthttp** for the
canonical Hanzo Go web framework.
## What's gone
Every file from upstream has been deleted. We kept the name.
- `handlers.go` — old net/http handler registry. Replaced by
`zip.App.Get/Post/...` plus internal `routerAdapter` over fiber.Router.
- `request.go`, `response.go` — old `Req` / `Res` types. Replaced by
`*zip.Ctx` wrapping `fiber.Ctx`.
- `websocket.go` — old gorilla/websocket adapter. Replaced by package
`wsx` over `fasthttp/websocket`.
- `examples/{hello,json,websocket}` — old examples. Replaced by 9 new
examples covering Sinatra-style, typed handlers, OpenAPI, module
routes, websocket, SSE, and three migration paths.
- Module path: `zeekay.io/zip` → `github.com/hanzoai/zip`.
## What's new
| Area | Implementation |
|---|---|
| Core | `*fiber.App` under the hood; `zip.App` is the public type. |
| Ctx | `fiber.Ctx` interface wrapped + `Org/User/UserEmail/IsAdmin/RequestID` accessors. |
| Typed handlers | Generic `zip.Get[In, Out](app, path, fn)` etc. with reflection-build OpenAPI 3.1. |
| Validation | ~120 LOC reflection: `required`, `min/max`, `minlen/maxlen` struct tags. |
| OpenAPI | Auto-generated at `/.well-known/openapi.json`; Swagger UI at `/docs`. |
| Extension routes | `app.Module(method+path, runtime, dir)` — HIP-0105 surface. Loader is duck-typed (no hanzoai/base dep). |
| Middleware | `Recover`, `Logger`, `RequestID`, `Timeout`, `MaxBody`, `CORS`, `RateLimit`, `Telemetry`. Auth + StripIdentityHeaders moved to hanzoai/gateway/middleware (HIP-0106). |
| Adapters | `AdaptNetHTTP / AdaptNetHTTPFunc / AdaptNetHTTPMiddleware`; `app.Mount(prefix, http.Handler)`. |
| WebSocket | `wsx.Upgrade(fn)` over fasthttp/websocket. |
| Streaming | `c.SendStream(reader)` + `c.SendStreamWriter(fn)`. |
| ZAP transport | `app.ListenZAP(addr)` serves the whole app over ZAP (via zap-proto/http); `app.ListenHTTP(addr)` is the plain-HTTP extra; `app.Serve(zapAddr, httpAddr)` runs both. Routes ARE the ZAP surface. |
| Named-service RPC (optional) | `zaprpc.Service`/`Registry`/`Dispatch` + `zaprpc.HTTPHandler(reg)` for a gRPC-style named-service surface on top of the transport. |
## Dependencies
- `github.com/gofiber/fiber/v3 v3.2.0` — Fiber v3.
- `github.com/valyala/fasthttp v1.70.0` — transport.
- `github.com/fasthttp/websocket v1.5.12` — WebSocket.
- `github.com/luxfi/log v1.4.3` — canonical Hanzo logging. Forces
Go 1.26.3 floor.
No `uber-go/zap`, no `log/slog`, no chi/gin/echo deps. Adapters use
Fiber's own `middleware/adaptor` package which is part of the v3 core.
## Policy adherence
- luxfi/log everywhere — zero `zap` or `slog` imports.
- No `.capnp` anywhere — ZAP only on the RPC plane.
- No major version bumps — all deps pinned to specific patches on
existing major lines.
- luxfi canonical packages preferred — log was already on luxfi; no
other Hanzo-canonical alternative existed for the Fiber surface.
- Go 1.26.3 floor — bumped from the brief's 1.23.6 only because
luxfi/log canonical (the mandatory dep) requires it. Every other
dep is on its current patch.
- HIP-0026 identity headers — `c.Org/User/UserEmail/IsAdmin` map
directly to gateway-minted `X-Org-Id / X-User-Id / X-User-Email /
X-User-IsAdmin`.
- HIP-0105 extension contract — duck-typed via `zip/runtime.Loader`
so the framework stays decoupled from `hanzoai/base`.
- HIP-0106 Mount() contract — `examples/subsystem-mount` is the
reference; the binary composes N subsystems via the same `Mount(app,
deps) error` signature commerce/checkout uses today.
## Backwards compatibility
None. zeekay/zip and hanzoai/zip share only the name. The module path
changed (`zeekay.io/zip` → `github.com/hanzoai/zip`) so go-modules
treats them as distinct packages — no transitive collision is possible.
+17
View File
@@ -0,0 +1,17 @@
The MIT License (MIT)
Copyright (c) 2025 - present, Hanzo AI, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+230
View File
@@ -0,0 +1,230 @@
# zip
Hanzo's canonical Go web framework. Built on **Fiber v3 / fasthttp**.
Sinatra-style API. ZAP-typed handlers. Multi-language extension support
via [HIP-0105](https://github.com/hanzoai/hips).
**ONE framework. ZERO escape hatches. zip IS fast.**
```go
package main
import (
"github.com/hanzoai/zip"
"github.com/hanzoai/zip/middleware"
)
func main() {
app := zip.New(zip.Config{})
app.Use(middleware.Recover(), middleware.RequestID())
app.Get("/health", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"status": "ok"})
})
v1 := app.Group("/v1")
v1.Get("/users/:id", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{
"id": c.Param("id"),
"org": c.Org(), // HIP-0026 gateway-minted X-Org-Id
"user": c.User(), // X-User-Id
})
})
_ = app.Serve(":9653", ":8080") // ZAP primary + HTTP extra
}
```
## Features
- **Sinatra/Express idiom** — `app.Get(path, fn)` is the primary API.
- **Typed handlers** — `zip.Get[In, Out](app, path, fn)` generates
OpenAPI 3.1 spec and serves Swagger UI at `/docs`.
- **Hanzo identity built-in** — `c.Org() / c.User() / c.UserEmail()`
pull JWT-validated values from gateway X-* headers per
[HIP-0026](https://github.com/hanzoai/hips).
- **luxfi/log** for all logging — no `slog` or `zap` in zip.
- **Extension routes** — `app.Module("POST /v1/eval", "wasm", "./policy")`
mounts any [HIP-0105](https://github.com/hanzoai/hips) extension as a
route. Supports wasm (wazero) / goja / pyvm / starlark / v8go / native.
- **Embedded JS runtime** — `runtime.NewJSRuntime` runs TS/JS handlers
in-process via [goja](https://github.com/dop251/goja) (pure Go, no
CGO). `runtime.TranspileToES5` compiles TS → goja-ready JS via
[esbuild](https://github.com/evanw/esbuild). `runtime.JSHandler` /
`runtime.JSModule` mount an Express-shaped JS function as a route.
- **WebSocket** — `wsx.Upgrade(fn)` via fasthttp/websocket.
- **SSE / streaming** — `c.SendStreamWriter` (Fiber v3 native).
- **Drop-in migration** — `app.Mount("/legacy", chiRouter)` for any
`http.Handler` (chi, gin, beego, net/http).
- **ZAP transport** — `app.ListenZAP(":9653")` serves the whole app over
ZAP (TLS 1.3 + post-quantum, gRPC's replacement); `app.ListenHTTP(":8080")`
is the optional plain-HTTP extra; `app.Serve(zapAddr, httpAddr)` runs both.
Your routes ARE the ZAP surface — same handlers, middleware, and auth over
either transport, no separate RPC registration.
- **Named-service RPC (optional)** — `zaprpc.Registry` + `zaprpc.HTTPHandler(reg)`
exposes generated `zapc` services by name at a route, for a gRPC-style
service surface on top of the transport.
## Install
```bash
go get github.com/hanzoai/zip
```
Module path: `github.com/hanzoai/zip`. Go version: 1.26.3 (forced by
luxfi/log).
## JSON: encoding/json/v2 at the edge
zip routes every JSON path — `c.JSON`, `c.Bind().Body`, the typed
`zip.Get[In,Out]` round-trip, the HIP-0105 module envelope, the
auto-OpenAPI spec — through one internal helper
(`internal/jsonenc`). When the binary is compiled with
`GOEXPERIMENT=jsonv2` (Go 1.25+), that helper is backed by the stdlib
`encoding/json/v2`; otherwise it falls back to `encoding/json` v1.
There is no third-party JSON library: stdlib only, per HIP-0106's
canonical Hanzo Go stack.
```bash
# Compile with v2 (preferred — ~10% faster on the edge,
# ~25% fewer allocations per request)
GOEXPERIMENT=jsonv2 go build ./...
# Without the experiment, v1 is selected:
go build ./...
```
`zip.JSONVariant` is a build-time constant exposing which impl is
active. `zip.New` logs it once at startup so operators can confirm v2
is on in CI/prod logs:
```
{"level":"info","module":"zip","json_variant":"encoding/json/v2","message":"zip new"}
```
Benchmarks (Apple M1 Max, Go 1.26, fiber/v3):
| Bench | json v1 ns/op | json/v2 ns/op | v1 allocs | v2 allocs | Δ |
|---|---|---|---|---|---|
| Edge POST + JSON roundtrip | 14972 | 13631 | 73 | 56 | -9% time, -23% allocs |
| Marshal-only | 10798 | 7924 | 34 | 34 | -27% time |
| Unmarshal-only | 13803 | 12729 | 67 | 50 | -8% time, -25% allocs |
Reproduce with `go test -bench=BenchmarkJSON -benchmem ./...` and
again with `GOEXPERIMENT=jsonv2`.
Per HIP-0106 "Wire protocol stack": JSON marshalling happens at most
ONCE per request (at the subsystem handler boundary, through zip).
Inter-subsystem calls use ZAP-typed Go values via `cloud.Deps`. JSON
is the edge format only.
## Embedded JS runtime — the TS migration path
zip embeds a JavaScript runtime so legacy TS/JS handlers run **in the
same Go process** — no separate `hanzo/runtime` service, no
inter-service RPC, no container-per-service. Combined with the
single-binary architecture below, this is where the cloud savings come
from: one process mounts everything.
```
TS source --esbuild target=ES2015--> ES JS --drop into--> embedded goja --in-process--> Fiber route
```
```go
// 1. Transpile legacy TS to goja-runnable JS (pure Go, no CGO).
js, _ := runtime.TranspileToES5(tsSource, runtime.ESOptions{Loader: "ts"})
// 2. Pool-backed embedded VM; register the CommonJS module.
rt, _ := runtime.NewJSRuntime(runtime.JSOptions{PoolSize: 8})
_ = rt.LoadModule("app", string(js)) // module.exports = (req, res) => ...
// 3. Express-shaped handler -> fiber.Handler, mounted on zip.
h, _ := runtime.JSModule(rt, "app")
app.Fiber().All("/legacy/*", h)
```
The JS handler sees an Express-shaped `(req, res)` pair —
`req.method / req.path / req.query / req.headers / req.body` and
`res.status(n) / res.set(k,v) / res.json(v) / res.send(v)`. JSON
encode/decode routes through the same `internal/jsonenc` impl as the
edge, so there is one wire format.
**Migrate in place, incrementally:** start with the TS handler running
in goja (zero rewrite), then rewrite hot routes to native Go
`func(c *zip.Ctx) error` one at a time. Both styles coexist on the same
`App`.
> esbuild's pure-Go API emits ES2015 as its lowest target (it does not
> emit literal ES5); goja runs ES2015 output. `TranspileToES5` is named
> for the migration intent — "down to what the embedded VM runs."
A pool of `*goja.Runtime` (lifted from `hanzoai/base/plugins/gojavm`)
keeps VMs hot so requests don't pay per-request VM-creation cost;
goja VMs are single-threaded, so each request borrows an isolated VM.
See `examples/express-in-zip/` for the full esbuild → goja → Fiber
proof point with an integration test.
## Single-binary architecture (HIP-0106)
zip is the seam that lets `hanzoai/cloud` mount every Hanzo subsystem
into ONE Go process. Each subsystem exposes `Mount(app *zip.App, deps)`
(see `examples/subsystem-mount`); cloud builds the dependency bag once
and threads it through every mount. Native Go subsystems, embedded-JS
legacy handlers, WebSocket endpoints, and the ZAP RPC plane all run on
the same `App`. No microservice overhead, no inter-service RPC, no
container-per-service — JSON only at the edge, ZAP-typed Go values
between subsystems.
## Architecture
- `zip.App` wraps `*fiber.App`. One binary, one server, no escape
hatches — no `.Fast` variant API, no second router.
- `zip.Ctx` wraps `fiber.Ctx` and adds Hanzo identity sugar
(`c.Org() / c.User() / c.IsAdmin() / c.RequestID() / c.Log()`).
- Handlers are `func(c *zip.Ctx) error`. Returning a `*zip.HTTPError`
controls the response status; everything else becomes 500 JSON.
- Middleware lives in `zip/middleware/` — `Recover`, `Logger`,
`RequestID`, `RateLimit`, `CORS`, `MaxBody`, `Telemetry`. Auth-
specific middleware (`Auth`, `StripIdentityHeaders`) lives in
`github.com/hanzoai/gateway/middleware` per HIP-0106.
- Adapters in `zip/adapt.go` — `AdaptNetHTTP / AdaptNetHTTPFunc /
AdaptNetHTTPMiddleware`. Migration tools only — replace adapted
routes with native handlers when feasible.
- Extension runtime contract in `zip/runtime/` — duck-typed
`runtime.Loader` interface; zip does NOT pull `hanzoai/base` as a
dep. Service binaries construct `*extruntime.Loader` and inject via
`zip.Config.Loader`.
## Examples
| Example | Demonstrates |
|---|---|
| `examples/hello` | Minimal Sinatra-style API |
| `examples/zap-typed` | Generic typed handler + auto-OpenAPI |
| `examples/express-in-zip` | Legacy TS handler: esbuild → goja → Fiber |
| `examples/subsystem-mount` | HIP-0106 `Mount(*App, deps)` idiom |
| `examples/module-routes` | `app.Module()` over a runtime.Loader |
| `examples/websocket` | `wsx.Upgrade` echo server |
| `examples/sse-streaming` | Server-Sent Events via SendStreamWriter |
| `examples/migrate-from-gin` | gin→zip mechanical port |
| `examples/migrate-from-chi` | chi→zip via AdaptNetHTTP |
| `examples/migrate-from-beego` | beego→zip via AdaptNetHTTP |
## Migration
| From | Adapter |
|---|---|
| net/http | `zip.AdaptNetHTTP(httpHandler)` |
| chi | `zip.AdaptNetHTTP(chiRouter)` (chi.Router IS http.Handler) |
| gin | `zip.AdaptNetHTTP(ginEngine)` (gin.Engine IS http.Handler) |
| beego | `app.Mount("/legacy/iam", beeApp.Handlers)` |
Each adapter is doc'd "MIGRATION TOOL — costs ~5% perf vs native Fiber
dispatch. Replace with native handlers when feasible."
See `docs/MIGRATION.md` for per-framework recipes.
## License
Apache-2.0 (carry-forward from upstream zeekay/zip license metadata).
+17
View File
@@ -0,0 +1,17 @@
# Security Policy
## Reporting a vulnerability
Email security@hanzo.ai with details. Encrypt with our PGP key (fingerprint TBD).
We respond within 48 hours. Critical issues receive same-day acknowledgment.
## Scope
This policy covers code in this repository. For the broader Hanzo platform threat model, see [hanzoai/HIPs](https://github.com/hanzoai/HIPs).
## Sandbox boundary
`zip` is a Go web framework with no privileged surface of its own — it executes only handler code registered by its consumer. Trust is delegated to the calling service: middlewares for JWT validation, identity-header strip+mint, rate limiting, and CORS must be configured by the consumer per the Hanzo platform contract.
For runtime sandbox guarantees, see HIP-0105 (in-process extension runtimes).
+54
View File
@@ -0,0 +1,54 @@
// Adapters for migrating existing chi / gin / net-http code onto zip
// without rewriting handlers. These are MIGRATION TOOLS — each adapter
// costs ~5% perf vs native Fiber dispatch and exists so a service can
// roll onto zip incrementally. Replace adapted routes with native
// zip handlers when feasible.
package zip
import (
"net/http"
"github.com/gofiber/fiber/v3/middleware/adaptor"
)
// AdaptNetHTTP wraps an http.Handler so it can be mounted on a zip
// router. Use for stdlib code:
//
// app.Mount("/legacy/net", zip.AdaptNetHTTP(httpHandler))
//
// Migration tool — costs ~5% perf vs native Fiber. Replace with native
// zip handlers when feasible.
func AdaptNetHTTP(h http.Handler) Handler {
wrapped := adaptor.HTTPHandler(h)
return func(c *Ctx) error { return wrapped(c.fc) }
}
// AdaptNetHTTPFunc wraps an http.HandlerFunc.
//
// Migration tool — costs ~5% perf vs native Fiber.
func AdaptNetHTTPFunc(h http.HandlerFunc) Handler {
wrapped := adaptor.HTTPHandlerFunc(h)
return func(c *Ctx) error { return wrapped(c.fc) }
}
// AdaptNetHTTPMiddleware wraps a stdlib middleware (func(http.Handler) http.Handler).
//
// Migration tool — costs ~5% perf vs native Fiber.
func AdaptNetHTTPMiddleware(mw func(http.Handler) http.Handler) Handler {
wrapped := adaptor.HTTPMiddleware(mw)
return func(c *Ctx) error { return wrapped(c.fc) }
}
// Mount registers an http.Handler at the given prefix. Implicit
// chi.Router and gin.Engine support flows through AdaptNetHTTP via
// their respective ServeHTTP methods — both are http.Handlers.
//
// app.Mount("/legacy/chi", zip.AdaptNetHTTP(chiRouter))
// app.Mount("/legacy/gin", zip.AdaptNetHTTP(ginEngine))
// app.Mount("/legacy/iam", zip.AdaptNetHTTP(beegoApp.HandlerWrapper()))
//
// Migration tool — costs ~5% perf vs native Fiber.
func (a *App) Mount(prefix string, h http.Handler) {
a.All(prefix+"/*", AdaptNetHTTP(h))
}
+21
View File
@@ -0,0 +1,21 @@
package zip
import "context"
// appendCloser registers fn to run during Shutdown(). Used by Module()
// to release extension-runtime modules.
func (a *App) appendCloser(fn func() error) {
a.closers = append(a.closers, fn)
}
// runClosers runs all registered closers and returns the first error.
func (a *App) runClosers(_ context.Context) error {
var firstErr error
for _, fn := range a.closers {
if err := fn(); err != nil && firstErr == nil {
firstErr = err
}
}
a.closers = nil
return firstErr
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright © 2026 Hanzo AI. MIT License.
// Per-connection memory profile for the zip framework itself —
// canonical regression test cited by SCALE_STANDARD.md §3 and §8.
// Every Hanzo Go service that exposes an HTTP listener copies this
// file into its own repo and tunes the route registration to the
// service shape; the budget assertion stays the same.
//
// Budget (verified on Apple M1 Max / Go 1.26.3 / Fiber v3 v3.2.0):
// per_conn_heap <= 12 KiB (target: 8 KiB)
// goroutines_per_conn == 1.00 (tolerance ±0.05)
//
// Run with:
// go test -mod=mod -run=TestConnMemory -v -conn-count=10000
package zip_test
import (
"context"
"flag"
"fmt"
"net"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
var connCount = flag.Int("conn-count", 1000, "concurrent connections to hold")
// Budget assertions, expressed in bytes / ratio. SCALE_STANDARD.md §8
// pins these — do not loosen them without updating the doc.
const (
maxPerConnHeapBytes = 12 * 1024 // 12 KiB ceiling
goroutinesPerConnLow = 0.95
goroutinesPerConnHigh = 1.05
settleBeforeBaselineMs = 200
maxWaitForAcceptSeconds = 30
)
func TestConnMemory(t *testing.T) {
if testing.Short() {
t.Skip("skipping memory profile in -short mode")
}
n := *connCount
app := zip.New(zip.Config{
Logger: luxlog.New("test", "zip-conn-memory"),
DisableStartupMessage: true,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var holding atomic.Int64
app.Get("/hold", func(c *zip.Ctx) error {
holding.Add(1)
defer holding.Add(-1)
<-ctx.Done()
return nil
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
go func() { _ = app.Fiber().Listener(ln) }()
defer func() { _ = app.Shutdown() }()
time.Sleep(settleBeforeBaselineMs * time.Millisecond)
var baseline, peak runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&baseline)
req := []byte("GET /hold HTTP/1.1\r\nHost: x\r\n\r\n")
conns := make([]net.Conn, 0, n)
var connsMu sync.Mutex
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
c, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return
}
if _, err := c.Write(req); err != nil {
_ = c.Close()
return
}
connsMu.Lock()
conns = append(conns, c)
connsMu.Unlock()
}()
}
wg.Wait()
defer func() {
connsMu.Lock()
for _, c := range conns {
_ = c.Close()
}
connsMu.Unlock()
}()
deadline := time.Now().Add(maxWaitForAcceptSeconds * time.Second)
for holding.Load() < int64(n) && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
if got := holding.Load(); got < int64(n) {
t.Logf("only %d/%d conns accepted before deadline", got, n)
n = int(got)
}
if n == 0 {
t.Fatal("no conns accepted — listener never came up")
}
runtime.GC()
runtime.ReadMemStats(&peak)
delta := int64(peak.HeapAlloc) - int64(baseline.HeapAlloc)
perConn := float64(delta) / float64(n)
totalGoroutines := runtime.NumGoroutine()
goroutinesPerConn := float64(totalGoroutines) / float64(n)
fmt.Printf("\n=== Per-connection memory profile (zip) ===\n")
fmt.Printf("conns held : %d\n", n)
fmt.Printf("baseline heap : %s\n", humanBytes(int64(baseline.HeapAlloc)))
fmt.Printf("peak heap : %s\n", humanBytes(int64(peak.HeapAlloc)))
fmt.Printf("delta : %s\n", humanBytes(delta))
fmt.Printf("per-conn heap : %.0f B (%.2f KiB)\n", perConn, perConn/1024)
fmt.Printf("goroutines total : %d\n", totalGoroutines)
fmt.Printf("goroutines / conn: %.2f\n", goroutinesPerConn)
fmt.Printf("==================================================\n\n")
if perConn > maxPerConnHeapBytes {
t.Errorf("per-conn heap %.0f B exceeds budget %d B (see SCALE_STANDARD.md §3)",
perConn, maxPerConnHeapBytes)
}
if goroutinesPerConn < goroutinesPerConnLow || goroutinesPerConn > goroutinesPerConnHigh {
t.Errorf("goroutines/conn %.2f outside [%.2f, %.2f] (see SCALE_STANDARD.md §3)",
goroutinesPerConn, goroutinesPerConnLow, goroutinesPerConnHigh)
}
cancel()
wg.Wait()
}
func humanBytes(n int64) string {
const unit = 1024
if n < 0 {
return "-" + humanBytes(-n)
}
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
+214
View File
@@ -0,0 +1,214 @@
package zip
import (
"bufio"
"context"
"errors"
"io"
"github.com/gofiber/fiber/v3"
luxlog "github.com/luxfi/log"
)
// Ctx wraps fiber.Ctx and adds the Hanzo identity surface (Org/User/Email
// from gateway-minted X-* headers per HIP-0026), a per-request luxfi/log
// logger, and typed Deps access.
type Ctx struct {
fc fiber.Ctx
app *App
log luxlog.Logger
}
// Fiber returns the underlying fiber.Ctx for one-off escape into Fiber-only APIs.
func (c *Ctx) Fiber() fiber.Ctx { return c.fc }
// App returns the parent App.
func (c *Ctx) App() *App { return c.app }
// Context returns the standard context.Context (deadline + cancellation).
func (c *Ctx) Context() context.Context { return c.fc.Context() }
// Log returns the request-scoped logger. Middleware that adds request_id,
// org, user, etc. via Locals can enrich this by calling SetLog.
func (c *Ctx) Log() luxlog.Logger { return c.log }
// SetLog replaces the request logger (typically by middleware that wants
// to attach request-id / org / user fields).
func (c *Ctx) SetLog(l luxlog.Logger) { c.log = l }
// ----- Hanzo identity (HIP-0026 / gateway X-* headers) ---------------------
// Org returns the X-Org-Id from the JWT-validated gateway. Empty when
// no gateway is in front (local dev / direct ingress).
func (c *Ctx) Org() string { return c.fc.Get("X-Org-Id") }
// User returns the X-User-Id from the JWT-validated gateway.
func (c *Ctx) User() string { return c.fc.Get("X-User-Id") }
// UserEmail returns the X-User-Email from the JWT-validated gateway.
func (c *Ctx) UserEmail() string { return c.fc.Get("X-User-Email") }
// IsAdmin returns the X-User-IsAdmin gateway claim as a bool.
func (c *Ctx) IsAdmin() bool { return c.fc.Get("X-User-IsAdmin") == "true" }
// RequestID returns the value of X-Request-Id (set by the RequestID middleware).
func (c *Ctx) RequestID() string { return c.fc.Get("X-Request-Id") }
// ----- request basics ------------------------------------------------------
// Method returns the request method.
func (c *Ctx) Method() string { return c.fc.Method() }
// Path returns the request path.
func (c *Ctx) Path() string { return c.fc.Path() }
// Param returns a URL path parameter.
func (c *Ctx) Param(name string) string { return c.fc.Params(name) }
// Query returns a URL query parameter.
func (c *Ctx) Query(name string) string { return c.fc.Query(name) }
// Header returns a request header.
func (c *Ctx) Header(name string) string { return c.fc.Get(name) }
// SetHeader sets a response header.
func (c *Ctx) SetHeader(name, value string) { c.fc.Set(name, value) }
// Body returns the raw request body.
func (c *Ctx) Body() []byte { return c.fc.Body() }
// Bind parses the request body into v based on Content-Type (JSON by
// default) and runs struct-tag validation (required/min/max/minlen/maxlen).
// Returns a *HTTPError(400) when either step fails so handlers can
// return the error directly.
func (c *Ctx) Bind(v any) error {
if err := c.fc.Bind().Body(v); err != nil {
return ErrBadRequest("invalid body: " + err.Error())
}
if err := validate(v); err != nil {
return ErrBadRequest(err.Error())
}
return nil
}
// BindQuery parses query parameters into v and runs validation.
func (c *Ctx) BindQuery(v any) error {
if err := c.fc.Bind().Query(v); err != nil {
return ErrBadRequest("invalid query: " + err.Error())
}
if err := validate(v); err != nil {
return ErrBadRequest(err.Error())
}
return nil
}
// BindURI parses URL params into v and runs validation.
func (c *Ctx) BindURI(v any) error {
if err := c.fc.Bind().URI(v); err != nil {
return ErrBadRequest("invalid uri: " + err.Error())
}
if err := validate(v); err != nil {
return ErrBadRequest(err.Error())
}
return nil
}
// ----- response writers ----------------------------------------------------
// Status sets the response status. Chains.
func (c *Ctx) Status(code int) *Ctx { c.fc.Status(code); return c }
// JSON writes the value as JSON with status code.
func (c *Ctx) JSON(code int, v any) error {
c.fc.Status(code)
return c.fc.JSON(v)
}
// String writes a plain-text response.
func (c *Ctx) String(code int, s string) error {
c.fc.Status(code)
return c.fc.SendString(s)
}
// Bytes writes raw bytes.
func (c *Ctx) Bytes(code int, b []byte) error {
c.fc.Status(code)
return c.fc.Send(b)
}
// NoContent writes the status code with no body.
func (c *Ctx) NoContent(code int) error {
c.fc.Status(code)
return nil
}
// SendStream streams data from r to the client (e.g. for SSE).
func (c *Ctx) SendStream(r io.Reader) error {
return c.fc.SendStream(r)
}
// SendStreamWriter writes streaming output via a bufio.Writer (Server-Sent
// Events / chunked transfer). Forwards to fiber.Ctx.SendStreamWriter.
func (c *Ctx) SendStreamWriter(fn func(w *bufio.Writer)) error {
return c.fc.SendStreamWriter(fn)
}
// Locals returns or sets a per-request value.
func (c *Ctx) Locals(key any, value ...any) any {
return c.fc.Locals(key, value...)
}
// Next yields to the next handler in the chain. Use sparingly from
// zip middleware — middleware bodies usually call c.Continue() at the
// end, not Next() mid-handler.
func (c *Ctx) Next() error { return c.fc.Next() }
// Continue is an alias for Next() with the standard middleware idiom.
func (c *Ctx) Continue() error { return c.fc.Next() }
// =============================================================================
// Errors — handlers return one of these to control status code
// =============================================================================
// HTTPError is the canonical error type zip understands. Returning one
// causes the error handler to send a JSON {error, code, status} body.
type HTTPError struct {
Status int `json:"status"`
Code string `json:"code,omitempty"`
Msg string `json:"error"`
}
func (e *HTTPError) Error() string { return e.Msg }
// Errorf builds an HTTPError with the given status and message.
func Errorf(status int, format string, args ...any) *HTTPError {
return &HTTPError{Status: status, Msg: sprintf(format, args...)}
}
// Common shortcuts.
func ErrBadRequest(msg string) *HTTPError { return &HTTPError{Status: 400, Msg: msg} }
func ErrUnauthorized(msg string) *HTTPError { return &HTTPError{Status: 401, Msg: msg} }
func ErrForbidden(msg string) *HTTPError { return &HTTPError{Status: 403, Msg: msg} }
func ErrNotFound(msg string) *HTTPError { return &HTTPError{Status: 404, Msg: msg} }
func ErrConflict(msg string) *HTTPError { return &HTTPError{Status: 409, Msg: msg} }
func ErrInternal(msg string) *HTTPError { return &HTTPError{Status: 500, Msg: msg} }
// errorHandler is the default fiber.ErrorHandler — converts HTTPError
// into a JSON response and falls back to 500 for anything else.
func errorHandler(c fiber.Ctx, err error) error {
var he *HTTPError
if errors.As(err, &he) {
if he.Status == 0 {
he.Status = 500
}
c.Status(he.Status)
return c.JSON(he)
}
var fe *fiber.Error
if errors.As(err, &fe) {
c.Status(fe.Code)
return c.JSON(&HTTPError{Status: fe.Code, Msg: fe.Message})
}
c.Status(500)
return c.JSON(&HTTPError{Status: 500, Msg: err.Error()})
}
+128
View File
@@ -0,0 +1,128 @@
# Middleware reference
All zip middleware is a `zip.Handler` (not raw `fiber.Handler`) so the
user-facing handler signature stays uniform. Install via `app.Use(...)`.
```go
import "github.com/hanzoai/zip/middleware"
app.Use(
middleware.Recover(),
middleware.RequestID(),
middleware.Logger(app.Logger()),
)
```
## Recover
Catches handler panics, logs the stack via luxfi/log, and returns a 500
JSON response. Always include first.
```go
app.Use(middleware.Recover())
```
## RequestID
Injects `X-Request-Id`. If the incoming request carries one, it's
preserved; otherwise zip mints a 16-byte hex value. Available via
`c.RequestID()`.
```go
app.Use(middleware.RequestID())
```
## Logger
Wraps each request with a per-request luxfi/log child logger that
includes `request_id`, `org`, `user`. Logs the request line at
completion with `status` + `dur_ms`.
```go
app.Use(middleware.Logger(app.Logger()))
```
## Timeout
Per-request context deadline. Best-effort — Fiber's fasthttp-backed Ctx
does not natively propagate context cancellation through the request
lifetime; downstream code that consumes `c.Context()` (DB calls, HTTP
clients) gets the deadline.
```go
app.Use(middleware.Timeout(30 * time.Second))
```
## MaxBody
Rejects request bodies larger than `n` bytes with 413.
```go
app.Use(middleware.MaxBody(1 << 20)) // 1 MiB
```
## CORS
```go
app.Use(middleware.CORS(middleware.CORSConfig{
AllowOrigins: []string{"https://app.hanzo.ai"},
AllowMethods: []string{"GET", "POST"},
AllowHeaders: []string{"Content-Type", "Authorization"},
AllowCreds: true,
MaxAge: 86400,
}))
```
## Auth & StripIdentityHeaders — moved
Auth-specific middleware (JWT validation, identity-header stripping)
has moved to
[`github.com/hanzoai/gateway/middleware`](https://github.com/hanzoai/gateway/tree/main/middleware).
Rationale: JWT validation + identity-header minting are the gateway
subsystem's responsibility per HIP-0106. Other subsystems mounted
inside the unified `cloud` binary trust the gateway-minted `X-Org-Id`
header and do not re-validate JWTs themselves. The trust-assertion
helper `gateway.AssertGatewayMinted(c)` lets a downstream handler
defend against deployment misconfiguration where it is accidentally
exposed to direct (non-gateway) traffic.
## RateLimit
Per-org (or per-IP fallback) in-memory token bucket. Single-pod only —
multi-pod deployments must rate-limit at the gateway.
```go
app.Use(middleware.RateLimit(middleware.RateLimitConfig{
Limit: 100,
Window: time.Minute,
}))
```
## Telemetry
Plumbs request metrics to any `O11ySink` implementation. nil sink is a
no-op.
```go
app.Use(middleware.Telemetry(myO11ySink))
```
## Order
Recommended order for a Hanzo service behind hanzoai/gateway:
```go
app.Use(
middleware.Recover(), // 1. always first
middleware.RequestID(), // 2. mint request id
middleware.Logger(app.Logger()), // 3. log with request id
middleware.Telemetry(o11y), // 4. metrics
middleware.RateLimit(rlCfg), // 5. limit
middleware.CORS(corsCfg), // 6. last — close to response
)
```
JWT validation + identity-header stripping are owned by the gateway
subsystem; see
[`github.com/hanzoai/gateway/middleware`](https://github.com/hanzoai/gateway/tree/main/middleware).
+133
View File
@@ -0,0 +1,133 @@
# Migration guide
zip ships drop-in adapters for chi, gin, beego, and stdlib `net/http`.
Each adapter is documented as a **migration tool** — they exist so a
service can roll onto zip incrementally. Adapter dispatch costs ~5%
versus native Fiber routing; replace adapted routes with native zip
handlers when feasible.
## From gin
gin handlers take `*gin.Context`; zip handlers take `*zip.Ctx`. The
shapes are deliberately close.
```go
// gin
r := gin.Default()
r.GET("/users/:id", func(c *gin.Context) {
c.JSON(200, gin.H{"id": c.Param("id")})
})
// zip
app := zip.New(zip.Config{})
app.Get("/users/:id", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"id": c.Param("id")})
})
```
Common mappings:
| gin | zip |
|---|---|
| `gin.H{...}` | `map[string]any{...}` or a typed struct |
| `c.Param("id")` | `c.Param("id")` |
| `c.Query("q")` | `c.Query("q")` |
| `c.GetHeader("X-Foo")` | `c.Header("X-Foo")` |
| `c.BindJSON(&v)` | `c.Bind(&v)` |
| `c.AbortWithStatusJSON(400, ...)` | `return zip.ErrBadRequest("...")` |
| `gin.Recovery()` | `middleware.Recover()` |
| `gin.Logger()` | `middleware.Logger(app.Logger())` |
For routes that are too complex to port today, mount the gin engine as
a legacy adapter:
```go
gin := buildExistingGinApp()
app.Mount("/legacy/gin", gin)
```
## From chi
chi routers implement `http.Handler`, so `zip.AdaptNetHTTP` works
unchanged.
```go
chiRouter := chi.NewRouter()
chiRouter.Get("/users", listUsers)
app := zip.New(zip.Config{})
app.Mount("/legacy/chi", chiRouter)
```
Common mappings:
| chi | zip |
|---|---|
| `chi.NewRouter()` | `zip.New(zip.Config{})` |
| `r.Route("/v1", fn)` | `app.Route("/v1", fn)` |
| `r.Group(fn)` | `app.Group("/", h1, h2)` |
| `chi.URLParam(r, "id")` | `c.Param("id")` |
| middleware (func(http.Handler) http.Handler) | `middleware.AdaptNetHTTPMiddleware(mw)` |
## From beego
beego `*web.HttpServer` exposes its handler chain via `BeeApp.Handlers`,
which is an `http.Handler`. Mount it via `app.Mount`.
```go
import "github.com/beego/beego/v2/server/web"
beeApp := web.NewHttpServer()
// ... existing beego config
app := zip.New(zip.Config{})
app.Mount("/legacy/iam", beeApp.Handlers)
```
Notes:
- beego's auto-routing magic (controllers reflected from struct names)
has no zip equivalent — port to native zip handlers when feasible.
- beego's `app.conf` and beego-specific filters do not carry over;
re-implement filters as zip middleware.
## From net/http
The zero-config path: any `http.Handler` works.
```go
existing := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
app.Mount("/legacy", existing)
```
For middleware that takes `func(http.Handler) http.Handler`:
```go
mw := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Custom", "1")
next.ServeHTTP(w, r)
})
}
app.Use(zip.AdaptNetHTTPMiddleware(mw))
```
## Replacing adapters with native handlers
Once a route's traffic warrants it, rewrite from `Mount` to native zip:
```go
// before
app.Mount("/legacy/chi", chiRouter)
// after (per-route migration)
v1 := app.Group("/v1")
v1.Get("/users", listUsers) // was chiRouter.Get("/users", ...)
v1.Post("/users", createUser)
```
The ~5% perf delta compounds at high RPS and the adapter chain is a
sharp edge for debugging — native handlers see fiber.Ctx directly,
adapters bounce through fasthttp ↔ net/http translation.
+112
View File
@@ -0,0 +1,112 @@
# Module handlers (HIP-0105)
zip mounts HIP-0105 extension modules as routes via `app.Module()`:
```go
app.Module("POST /v1/policy/eval", "wazero", "./ext/policy")
app.Module("POST /v1/transform", "pyvm", "./ext/transform")
app.Module("POST /v1/webhook", "goja", "./ext/webhook") // recommended for multi-tenant JS
app.Module("POST /v1/route", "starlark", "./ext/route")
```
Each `./ext/<name>` directory contains an `extension.json` manifest and
the compiled artifact (e.g. `validate.wasm` or `webhook.js`). See
[HIP-0105](https://github.com/hanzoai/hips) for the manifest spec and
the calling conventions per runtime.
## Wire format
zip builds one canonical JSON envelope per request and hands it to the
guest. Same shape regardless of runtime:
```json
{
"method": "POST",
"path": "/v1/policy/eval",
"params": {},
"query": {"q": "x"},
"headers": {"...": "..."},
"body": {"...arbitrary JSON..."},
"org": "hanzo",
"user": "z",
"userEmail": "[email protected]"
}
```
The guest returns:
```json
{
"status": 200,
"headers": {"X-Foo": "bar"},
"body": {"...response..."}
}
```
If the guest returns a bare JSON value (not the envelope shape), zip
sends it as the response body with status 200.
## Native runtimes
Per HIP-0105 the runtime names are:
| Runtime | Languages | Sandbox | Notes |
|---|---|---|---|
| `native` | Go | none | compile-time linked; default for Hanzo-authored code |
| `goja` | JS | soft | recommended for multi-tenant JS at scale (~9 KB/module) |
| `wazero` | wasm | hard | language-agnostic (Rust/AS/TinyGo/Zig/C); pool default 4 |
| `pyvm` | Python | soft | full CPython incl. numpy/pandas; **single-tenant only** |
| `starlark` | starlark | soft | config DSL |
| `v8go` | JS | hard | **experimental — not production** (libv8 SIGSEGVs at scale) |
## AllowedRuntimes
For multi-tenant deployments, restrict which runtimes are accepted:
```go
app := zip.New(zip.Config{
Loader: loader,
AllowedRuntimes: []string{"goja", "wazero"}, // no pyvm, no v8go, no native
})
```
`app.Module()` returns an error if the manifest's runtime is not in
the allowed list.
## Loader injection
zip does NOT depend on `hanzoai/base/plugins/extruntime`. The
`zip/runtime.Loader` interface is duck-typed:
```go
type Loader interface {
LoadDir(ctx context.Context, dir string) (map[string]Module, error)
LoadOne(ctx context.Context, dir string) (Module, error)
Runtimes() []string
}
```
A real service binary constructs `*extruntime.Loader` from
`hanzoai/base/plugins/extruntime` with the runtimes it cares about
(goja + wazero + pyvm + starlark + native), and passes it in:
```go
loader := extruntime.NewLoader(
nativevm.New(),
gojavm.New(),
wasmvm.New(),
pyvm.New(),
)
app := zip.New(zip.Config{Loader: loader})
```
The duck-typed interface keeps zip lightweight (no transitive
hanzoai/base dep) while letting the unified Hanzo binary wire every
runtime once and share the loader across mounted subsystems.
## Lifecycle
`app.Module()` loads the module at registration time. The module is
closed on `app.Shutdown()` via an automatically-registered closer.
There's no per-request load / hot-reload today; if a real consumer
asks for it, the loader interface gains a `Watch()` method.
+27
View File
@@ -0,0 +1,27 @@
# zip — horizontal-scale audit
Branch: `feat/edge-scale-cleanup`. Coordinates with `feat/extract-auth-middleware` (auth.go already removed before this audit).
## Checklist
| Item | Status | Notes / fix |
|---|---|---|
| No global state | PASS | No package-level mutable vars. Confirmed by `grep -n "^var " *.go middleware/*.go` returning empty. |
| No package-level mutable maps | PASS | Same as above. |
| Middleware stack composable & pure | PASS | Each middleware in `middleware/` is a stateless constructor returning a `zip.Handler`. `RateLimit` keeps state in a closure tied to one constructor call (one limiter == one bucket table), not in a global. |
| `zip.App` is a value type carrying its own router | PASS | `App` (zip.go:98) holds its own `*fiber.App`. `New()` constructs a fresh fiber app per call. Verified by `TestTwoAppsNoStateBleed` (isolation_test.go). |
| No timestamps in handlers without an injectable clock | PASS | The only `time.Now()` calls live in middleware where they belong; `BreakerConfig.Now` is now injectable for tests. `RateLimit` does call `time.Now()` directly — acceptable for production but tests cannot fake time. Followup ticket would be to make `RateLimitConfig.Now` injectable too; intentionally not in this scope (RL is not on the gateway hot path). |
| Two `zip.App` instances in same process don't bleed state | PASS | New test `TestTwoAppsNoStateBleed` (isolation_test.go) registers disjoint routes on two Apps and asserts each App only serves its own. Also `TestTwoAppsIndependentShutdown` proves shutting down A leaves B serving. |
| Built on Fiber v3 | PASS | go.mod pins `github.com/gofiber/fiber/v3 v3.2.0`. `grep -rn "gofiber/fiber/v2"` returns empty. |
| No accidental cgo dependencies pulled in by middleware | PASS | Module compiles `CGO_ENABLED=0`. No `import "C"` anywhere. |
## What changed in this branch
1. `middleware/breaker.go` — new `Breaker` primitive (closed/open/half-open) + `zip.Handler` adapter. Per-process by design: N replicas each run their own breaker, no coordination, no shared state. Cross-replica coordination would be a bug. Default failure classifier treats non-nil handler errors and 5xx as failures; 4xx is the caller's fault and does not trip.
2. `middleware/breaker_test.go` — covers open after threshold, half-open transition, half-open re-open on failure, middleware happy path, middleware short-circuit, state-change callback, counter snapshot.
3. `isolation_test.go` — proves disjoint App instances do not leak routes or middleware to each other. This is the load-bearing horizontal-scale invariant for zip — every other check builds on it.
## Things deliberately NOT done in this branch
- `middleware/auth.go` removal — already done on `feat/extract-auth-middleware` (commit ffd591e). Do not duplicate.
- New `middleware/Auth()` re-export pointing at `hanzoai/gateway/middleware/` — that package does not yet exist as a public Go import path; gateway's auth lives at `hanzoai/gateway` root (auth_middleware.go). When the auth middleware is split into its own importable package, zip's middleware README's pointer is correct.
+14
View File
@@ -0,0 +1,14 @@
// app.ts — a legacy Express-shaped handler. Source is TypeScript; at
// startup main.go runs it through esbuild (target ES2015) and drops the
// result into zip's embedded goja VM, where JSHandler invokes it with an
// Express-shaped (req, res) pair on every request.
//
// This is the proof point of zip's TS migration path: real TS source,
// real esbuild, real goja, real Fiber, real HTTP — no rewrite required.
module.exports = function (req: any, res: any) {
res.json({
ok: true,
path: req.path,
body: req.body,
});
};
+116
View File
@@ -0,0 +1,116 @@
// express-in-zip — the proof point: a legacy Express-shaped TypeScript
// handler running inside zip with ZERO rewrite.
//
// app.ts (TS) --esbuild--> ES2015 JS --goja--> JSHandler --> Fiber route
//
// go run ./examples/express-in-zip
// curl http://localhost:8080/legacy/foo
// curl -XPOST -d '{"x":1}' -H content-type:application/json \
// http://localhost:8080/legacy/bar
package main
import (
_ "embed"
"errors"
"log"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
"github.com/zap-proto/zip/runtime"
)
//go:embed app.ts
var appTS []byte
// setup wires esbuild -> goja -> JSHandler and mounts the legacy handler
// at /legacy/*. Returned as a *zip.App so the integration test can drive
// it via app.Fiber().Test(...) without binding a port.
func setup() (*zip.App, error) {
// 1. Transpile the legacy TS to goja-runnable JS.
js, err := runtime.TranspileToES5(appTS, runtime.ESOptions{
Loader: "ts",
Sourcefile: "app.ts",
})
if err != nil {
return nil, err
}
// 2. Embedded JS runtime, register the module (module.exports = fn).
rt, err := runtime.NewJSRuntime(runtime.JSOptions{PoolSize: 8})
if err != nil {
return nil, err
}
if err := rt.LoadModule("app", string(js)); err != nil {
return nil, err
}
// 3. Fiber handler from the module's exported function.
h, err := runtime.JSModule(rt, "app")
if err != nil {
return nil, err
}
// 4. Mount on zip. JSModule returns a fiber.Handler, so it goes on
// the underlying Fiber router via app.Fiber(); native zip routes
// sit alongside it on the same App. stripPrefix rewrites the
// request path so the legacy handler sees /foo, not /legacy/foo —
// the same path-stripping an Express sub-router does on mount.
app := zip.New(zip.Config{AppName: "express-in-zip"})
app.Use(middleware.Recover(), middleware.RequestID())
app.Fiber().All("/legacy/*", stripPrefix("/legacy", h))
// 5. Unified multi-language runner. The request body is the source,
// :lang selects the backend. zip ships the goja "js" engine in-tree;
// a host that imports base additionally registers pyvm/v8vm/wasmvm/
// starkvm here at startup — zip never imports base (see runtime/README).
runner := runtime.NewRunner()
if err := runner.Register("js", rt.Engine()); err != nil {
return nil, err
}
app.Fiber().Post("/runtime/:lang", runtimeHandler(runner))
return app, nil
}
// runtimeHandler reads the request body as source, dispatches it to the
// engine registered for :lang, and returns {result, error} as JSON. An
// unregistered language is a 404; an evaluation error is a 200 carrying
// the error string in the body so the caller sees the engine's message.
func runtimeHandler(runner runtime.Runner) fiber.Handler {
return func(c fiber.Ctx) error {
lang := c.Params("lang")
res, err := runner.Run(c.Context(), lang, c.Body())
if err != nil {
if errors.Is(err, runtime.ErrUnknownLanguage) {
return c.Status(fiber.StatusNotFound).
JSON(fiber.Map{"error": "unknown language"})
}
return c.JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"result": res})
}
}
// stripPrefix rewrites the request path to drop prefix before delegating
// to next, mirroring Express sub-router mount semantics.
func stripPrefix(prefix string, next fiber.Handler) fiber.Handler {
return func(c fiber.Ctx) error {
p := strings.TrimPrefix(c.Path(), prefix)
if p == "" {
p = "/"
}
c.Path(p)
return next(c)
}
}
func main() {
app, err := setup()
if err != nil {
log.Fatal(err)
}
log.Fatal(app.ListenHTTP(":8080"))
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"io"
"net/http/httptest"
"strings"
"testing"
)
// TestExpressInZip is the proof point: the legacy TS handler in app.ts,
// transpiled by real esbuild, loaded into real goja, mounted on real
// Fiber via zip, exercised over a real HTTP roundtrip.
func TestExpressInZip(t *testing.T) {
app, err := setup()
if err != nil {
t.Fatal(err)
}
t.Run("GET /legacy/foo", func(t *testing.T) {
resp, err := app.Fiber().Test(httptest.NewRequest("GET", "/legacy/foo", nil))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
assertContainsAll(t, string(body), `"ok":true`, `"path":"/foo"`, `"body":null`)
})
t.Run("POST /legacy/bar echoes body", func(t *testing.T) {
req := httptest.NewRequest("POST", "/legacy/bar", strings.NewReader(`{"x":1}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
assertContainsAll(t, string(body), `"ok":true`, `"path":"/bar"`, `"x":1`)
})
}
// TestRuntimeRoute drives the unified runner over HTTP: the request body
// is the source, :lang selects the backend. js evaluates in real goja; an
// unregistered language is a 404 with a structured error.
func TestRuntimeRoute(t *testing.T) {
app, err := setup()
if err != nil {
t.Fatal(err)
}
t.Run("POST /runtime/js evaluates source", func(t *testing.T) {
req := httptest.NewRequest("POST", "/runtime/js", strings.NewReader("40+2"))
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
// goja Export() of an integer arithmetic result is int64; JSON
// encodes it as the bare number 42.
assertContainsAll(t, string(body), `"result":42`)
})
t.Run("POST /runtime/cobol is 404 unknown language", func(t *testing.T) {
req := httptest.NewRequest("POST", "/runtime/cobol", strings.NewReader("DISPLAY 'HI'."))
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 404 {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
assertContainsAll(t, string(body), `"error":"unknown language"`)
})
}
func assertContainsAll(t *testing.T, body string, wants ...string) {
t.Helper()
for _, w := range wants {
if !strings.Contains(body, w) {
t.Fatalf("body %q missing %q", body, w)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
// Hello, zip — minimal example.
//
// go run ./examples/hello
// curl http://localhost:8080/hello
package main
import (
"log"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
func main() {
app := zip.New(zip.Config{AppName: "hello"})
app.Use(middleware.Recover(), middleware.RequestID())
app.Get("/hello", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"message": "hello world"})
})
app.Get("/health", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"status": "ok"})
})
log.Fatal(app.ListenHTTP(":8080"))
}
+46
View File
@@ -0,0 +1,46 @@
// migrate-from-beego example — beego → zip migration via http.Handler
// adapter. beego.BeeApp exposes Handlers as an http.Handler-compatible
// surface; the same Mount() entry that fronted chi handles beego too.
//
// In a real port:
//
// import (
// "github.com/beego/beego/v2/server/web"
// "github.com/zap-proto/zip"
// )
//
// beeApp := web.NewHttpSever() // your existing beego app
// zipApp := zip.New(zip.Config{AppName: "iam"})
// zipApp.Mount("/legacy/iam", beeApp.Handlers)
//
// This example uses a stand-in http.Handler so the file builds without
// pulling beego — same Mount() pattern in either case.
package main
import (
"log"
"net/http"
"github.com/zap-proto/zip"
)
type beegoStub struct{}
func (beegoStub) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"legacy_beego":true}`))
}
func main() {
app := zip.New(zip.Config{AppName: "migrate-from-beego"})
// New native zip routes:
app.Get("/v1/iam/healthz", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"status": "ok"})
})
// Legacy beego app under /legacy/iam:
app.Mount("/legacy/iam", beegoStub{})
log.Fatal(app.ListenHTTP(":8080"))
}
+42
View File
@@ -0,0 +1,42 @@
// migrate-from-chi example — chi → zip via stdlib adapter.
//
// Use zip.AdaptNetHTTP(chiRouter) to mount an existing chi.Router as a
// migration step. Replace adapted routes with native zip handlers when
// feasible; the adapter costs ~5% perf vs native Fiber dispatch.
//
// chi is NOT a dep of this example — we adapt any http.Handler, and a
// chi.Router satisfies that interface natively.
package main
import (
"log"
"net/http"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
// legacyHandler stands in for an existing chi.Router. Same shape
// (http.Handler) so zip.AdaptNetHTTP works without changes.
type legacyHandler struct{}
func (legacyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"legacy":true}`))
}
func main() {
app := zip.New(zip.Config{AppName: "migrate-from-chi"})
app.Use(middleware.Recover())
// Native zip routes for new work.
app.Get("/v1/users/:id", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"id": c.Param("id")})
})
// Mount the legacy chi router under /legacy/chi for incremental
// migration. Replace one path at a time with native zip handlers.
app.Mount("/legacy/chi", legacyHandler{})
log.Fatal(app.ListenHTTP(":8080"))
}
+44
View File
@@ -0,0 +1,44 @@
// migrate-from-gin example — mechanical port of a gin-style API to zip.
//
// gin before:
//
// r := gin.Default()
// r.GET("/users/:id", func(c *gin.Context) {
// c.JSON(200, gin.H{"id": c.Param("id")})
// })
//
// zip after — Sinatra/Express idiom is preserved; gin.Context becomes
// *zip.Ctx; gin.H becomes any map-shaped value (or a typed struct).
package main
import (
"log"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
func main() {
app := zip.New(zip.Config{AppName: "migrate-from-gin"})
app.Use(middleware.Recover(), middleware.RequestID(), middleware.Logger(app.Logger()))
app.Get("/users/:id", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{
"id": c.Param("id"),
})
})
v1 := app.Group("/v1")
v1.Post("/users", func(c *zip.Ctx) error {
var body struct {
Name string `json:"name" validate:"required"`
}
if err := c.Bind(&body); err != nil {
// c.Bind already returns *zip.HTTPError(400) on failure.
return err
}
return c.JSON(201, body)
})
log.Fatal(app.ListenHTTP(":8080"))
}
+83
View File
@@ -0,0 +1,83 @@
// 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/runtime.Loader.
//
// This example stubs a tiny in-memory loader so the example compiles
// without pulling hanzoai/base. Replace with real *extruntime.Loader
// in production.
package main
import (
"context"
"encoding/json"
"log"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
"github.com/zap-proto/zip/runtime"
)
// stubLoader is a zero-dependency runtime.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]runtime.Module, error) {
return map[string]runtime.Module{}, nil
}
func (stubLoader) LoadOne(_ context.Context, dir string) (runtime.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.ListenHTTP(":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
}
+37
View File
@@ -0,0 +1,37 @@
// sse-streaming example — Server-Sent Events via c.SendStreamWriter.
// LLM-gateway pattern: stream tokens as they arrive from upstream.
//
// go run ./examples/sse-streaming
// curl -N http://localhost:8080/v1/stream
package main
import (
"bufio"
"fmt"
"log"
"time"
"github.com/zap-proto/zip"
)
func main() {
app := zip.New(zip.Config{AppName: "sse-streaming"})
app.Get("/v1/stream", func(c *zip.Ctx) error {
c.SetHeader("Content-Type", "text/event-stream")
c.SetHeader("Cache-Control", "no-cache")
c.SetHeader("Connection", "keep-alive")
return c.SendStreamWriter(func(w *bufio.Writer) {
for i := 0; i < 10; i++ {
_, _ = fmt.Fprintf(w, "data: tick %d\n\n", i)
if err := w.Flush(); err != nil {
return
}
time.Sleep(200 * time.Millisecond)
}
})
})
log.Fatal(app.ListenHTTP(":8080"))
}
+63
View File
@@ -0,0 +1,63 @@
// subsystem-mount example — HIP-0106 Mount(app, deps) idiom.
//
// One zip.App is composed from N subsystems via the Mount(...) contract.
// Each subsystem is a Go package that exposes:
//
// func Mount(app *zip.App, deps Deps) error
//
// matching the gin-side hanzoai/commerce/checkout.MountPublic pattern.
package main
import (
"context"
"log"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
// Deps is the typed dependency bag a unified Hanzo binary builds once
// and threads through every Mount(). Real deployments inject IAM, KMS,
// DocDB, ClickHouse, etc. — this example uses an opaque struct.
type Deps struct {
OrgScoped func(ctx context.Context, org string) any
}
// usersSubsystem mounts user routes at /v1/users.
type usersSubsystem struct{}
func (usersSubsystem) Mount(app *zip.App, _ Deps) error {
users := app.Group("/v1/users")
users.Get("/:id", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{
"id": c.Param("id"),
"org": c.Org(),
})
})
return nil
}
// healthSubsystem mounts health probes.
type healthSubsystem struct{}
func (healthSubsystem) Mount(app *zip.App, _ Deps) error {
app.Get("/healthz", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"status": "ok"})
})
return nil
}
func main() {
app := zip.New(zip.Config{AppName: "subsystem-mount"})
app.Use(middleware.Recover(), middleware.RequestID())
deps := Deps{}
if err := (healthSubsystem{}).Mount(app, deps); err != nil {
log.Fatal(err)
}
if err := (usersSubsystem{}).Mount(app, deps); err != nil {
log.Fatal(err)
}
log.Fatal(app.ListenHTTP(":8080"))
}
+34
View File
@@ -0,0 +1,34 @@
// websocket example — chat-style echo server.
//
// go run ./examples/websocket
// wscat -c ws://localhost:8080/ws
package main
import (
"log"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
"github.com/zap-proto/zip/wsx"
)
func main() {
app := zip.New(zip.Config{AppName: "websocket"})
app.Use(middleware.Recover(), middleware.RequestID())
app.Get("/ws", wsx.Upgrade(func(c *wsx.Conn) error {
log.Printf("ws connection from %s", c.RemoteAddr())
for {
mt, msg, err := c.ReadMessage()
if err != nil {
log.Printf("ws read: %v", err)
return err
}
if err := c.WriteMessage(mt, msg); err != nil {
return err
}
}
}))
log.Fatal(app.ListenHTTP(":8080"))
}
+56
View File
@@ -0,0 +1,56 @@
// zap-typed example — typed handler with auto-generated OpenAPI spec.
//
// go run ./examples/zap-typed
// curl -X POST http://localhost:8080/v1/validate \
// -H 'Content-Type: application/json' \
// -d '{"email":"[email protected]","age":30}'
// curl http://localhost:8080/.well-known/openapi.json | jq
// open http://localhost:8080/docs
package main
import (
"context"
"log"
"strings"
"github.com/zap-proto/zip"
)
// ValidateRequest is the input type. zip derives an OpenAPI schema
// from these struct tags.
type ValidateRequest struct {
Email string `json:"email" validate:"required,minlen=3,maxlen=255"`
Age int `json:"age" validate:"required,min=18,max=120"`
}
// ValidateResponse is the output type. Same auto-schema derivation.
type ValidateResponse struct {
OK bool `json:"ok"`
Normalized string `json:"normalized"`
}
func main() {
app := zip.New(zip.Config{
AppName: "zap-typed",
OpenAPI: zip.OpenAPIConfig{
Title: "Validate API",
Description: "Example of zip's typed handler + auto-OpenAPI",
Version: "v0.1.0",
},
})
zip.Post(app, "/v1/validate", validate,
zip.WithSummary("Validate an email and age"),
zip.WithTags("validation"),
)
log.Fatal(app.ListenHTTP(":8080"))
}
func validate(ctx context.Context, in *ValidateRequest) (*ValidateResponse, error) {
_ = ctx
return &ValidateResponse{
OK: true,
Normalized: strings.ToLower(in.Email),
}, nil
}
+11
View File
@@ -0,0 +1,11 @@
package zip
import "fmt"
// sprintf isolates the fmt import to one file for hot-path swapping.
func sprintf(format string, args ...any) string {
if len(args) == 0 {
return format
}
return fmt.Sprintf(format, args...)
}
+12 -5
View File
@@ -1,29 +1,36 @@
module github.com/zap-proto/zip
go 1.26
go 1.26.4
require (
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
github.com/evanw/esbuild v0.28.1
github.com/fasthttp/websocket v1.5.12
github.com/gofiber/fiber/v3 v3.2.0
github.com/luxfi/log v1.4.3
github.com/valyala/fasthttp v1.70.0
github.com/zap-proto/http v0.0.0
github.com/zap-proto/go v1.3.0
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/zap-proto/go v1.1.0 // indirect
github.com/zap-proto/http v0.1.0
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)
replace github.com/zap-proto/http => ../http
+27 -2
View File
@@ -1,19 +1,37 @@
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/evanw/esbuild v0.28.1 h1:ds+yuRyUaZGx++GR56CrCeuXh8PVhVM4xq8v7PNELFc=
github.com/evanw/esbuild v0.28.1/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofiber/fiber/v3 v3.2.0 h1:g9+09D320foINPpCnR3ibQ5oBEFHjAWRRfDG1te54u8=
github.com/gofiber/fiber/v3 v3.2.0/go.mod h1:FHOsc2Db7HhHpsE62QAaJlXVV1pNkbZEptZ4jtti7m4=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
@@ -22,6 +40,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc=
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -36,15 +56,20 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/zap-proto/go v1.1.0 h1:DDGhuTBNqkmNax7QV/VvkInJR7hiOcybwUjN3YBt8fg=
github.com/zap-proto/go v1.1.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
github.com/zap-proto/http v0.1.0 h1:aA8NmU/a0yM7QFU5tjXPYHFiBUcoHn92wjMy181o50M=
github.com/zap-proto/http v0.1.0/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+22
View File
@@ -0,0 +1,22 @@
// Package jsonenc is zip's single JSON entry point.
//
// All JSON marshalling in zip — c.JSON(), c.Bind().Body(), typed.go's
// generic In/Out handlers, module.go's extension envelope — goes
// through Marshal / Unmarshal here. There is no other JSON path.
//
// Implementation: two build-tag-gated files swap the body. When the
// binary is compiled with GOEXPERIMENT=jsonv2, the import resolver
// can reach the stdlib's encoding/json/v2 package and the v2.go file
// is selected; otherwise v1.go is selected and we fall back to
// stdlib encoding/json. Either way callers see Marshal / Unmarshal —
// no v1/v2 branching in zip's own code.
//
// Per HIP-0106 "Wire protocol stack": JSON is the boundary format
// (ingress → gateway → subsystem handler). Inter-subsystem calls use
// ZAP-typed Go values. JSON marshalling happens at most once per
// request, at the subsystem handler boundary, through THIS package.
//
// Brand policy: no third-party JSON library is allowed. Stdlib only.
// goccy/go-json, sonic, jsoniter, and friends are NOT permitted in
// the Hanzo Go stack — see HIP-0106 canonical Hanzo Go stack.
package jsonenc
+20
View File
@@ -0,0 +1,20 @@
//go:build !goexperiment.jsonv2
package jsonenc
import (
"encoding/json"
)
// Variant reports which JSON implementation backs this build. v2 is
// reachable only under `GOEXPERIMENT=jsonv2`; without that flag the
// stdlib encoding/json/v2 import does not resolve at compile time and
// zip falls back to encoding/json v1. zip.New() logs Variant once at
// startup so operators can spot a missing GOEXPERIMENT in CI.
const Variant = "encoding/json"
// Marshal encodes v as JSON via encoding/json (v1).
func Marshal(v any) ([]byte, error) { return json.Marshal(v) }
// Unmarshal decodes data into v via encoding/json (v1).
func Unmarshal(data []byte, v any) error { return json.Unmarshal(data, v) }
+17
View File
@@ -0,0 +1,17 @@
//go:build goexperiment.jsonv2
package jsonenc
import (
jsonv2 "encoding/json/v2"
)
// Variant reports which JSON implementation backs this build. zip.New()
// logs this once at startup so operators can confirm v2 is active.
const Variant = "encoding/json/v2"
// Marshal encodes v as JSON via encoding/json/v2.
func Marshal(v any) ([]byte, error) { return jsonv2.Marshal(v) }
// Unmarshal decodes data into v via encoding/json/v2.
func Unmarshal(data []byte, v any) error { return jsonv2.Unmarshal(data, v) }
+40
View File
@@ -0,0 +1,40 @@
// Package runtime defines the minimal Loader / Module interface zip
// consumes from any HIP-0105 extension runtime implementation
// (hanzoai/base/plugins/extruntime is the reference).
//
// zip does NOT pull hanzoai/base as a dependency — instead the
// service binary constructs its own *extruntime.Loader and passes it
// in via zip.Config.Loader. The Loader interface here is duck-typed:
// any type that implements LoadDir + Close (and whose Modules
// implement Invoke + Close) satisfies it.
package runtime
import "context"
// Module is one loaded extension (HIP-0105 Module interface, projected).
type Module interface {
Name() string
Runtime() string
Exports() []string
Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error)
Close() error
}
// Loader is the projection of *extruntime.Loader zip consumes.
// Construct your loader in hanzoai/base/plugins/extruntime and pass
// it to zip.Config.Loader.
type Loader interface {
// LoadDir scans a directory for extension manifests and returns
// loaded modules keyed by manifest name.
LoadDir(ctx context.Context, dir string) (map[string]Module, error)
// LoadOne loads a single extension by directory. zip uses this for
// app.Module() which mounts ONE extension at one route. Implementers
// may implement this by calling LoadDir on the parent and selecting
// the result.
LoadOne(ctx context.Context, dir string) (Module, error)
// Runtimes returns the registered runtime names ("goja", "wazero",
// "v8go", "pyvm", "starlark", "native").
Runtimes() []string
}
+112
View File
@@ -0,0 +1,112 @@
package zip_test
import (
"io"
"net/http"
"testing"
"github.com/zap-proto/zip"
)
// TestTwoAppsNoStateBleed proves that two zip.App instances in the same
// process maintain disjoint route tables, disjoint middleware chains,
// and disjoint internal state. This is the structural invariant that
// makes horizontal scale safe: N replicas of the same binary running N
// independent App instances must never coordinate via globals.
//
// The contract this test enforces:
// - Routes registered on app A are NOT visible to app B
// - Middleware applied to app A is NOT applied to app B's requests
// - A panic recovered in app A does not affect app B
//
// If this test fails, there is a package-level mutable map somewhere
// in the zip module that needs to move into App.
func TestTwoAppsNoStateBleed(t *testing.T) {
a := zip.New(zip.Config{AppName: "a", DisableStartupMessage: true})
b := zip.New(zip.Config{AppName: "b", DisableStartupMessage: true})
// Route registered on A only.
a.Get("/from-a", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"from": "a"})
})
// Route registered on B only.
b.Get("/from-b", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"from": "b"})
})
// Middleware only on A — sets a marker header on every A request.
a.Use(func(c *zip.Ctx) error {
c.SetHeader("X-App", "a")
return c.Continue()
})
// Middleware only on B — sets a different marker on B.
b.Use(func(c *zip.Ctx) error {
c.SetHeader("X-App", "b")
return c.Continue()
})
// A's route is reachable on A.
reqA, _ := http.NewRequest("GET", "/from-a", nil)
resA, err := a.Fiber().Test(reqA)
if err != nil {
t.Fatalf("a.Test: %v", err)
}
if resA.StatusCode != 200 {
t.Fatalf("a /from-a: status=%d, want 200", resA.StatusCode)
}
body, _ := io.ReadAll(resA.Body)
if got := string(body); got != `{"from":"a"}` {
t.Fatalf("a /from-a body=%s", got)
}
// A's route is NOT reachable on B.
reqA2B, _ := http.NewRequest("GET", "/from-a", nil)
resA2B, err := b.Fiber().Test(reqA2B)
if err != nil {
t.Fatalf("b.Test: %v", err)
}
if resA2B.StatusCode != 404 {
t.Fatalf("b /from-a: status=%d, want 404 (route leaked from A)", resA2B.StatusCode)
}
// B's route is reachable on B.
reqB, _ := http.NewRequest("GET", "/from-b", nil)
resB, err := b.Fiber().Test(reqB)
if err != nil {
t.Fatalf("b.Test: %v", err)
}
if resB.StatusCode != 200 {
t.Fatalf("b /from-b: status=%d, want 200", resB.StatusCode)
}
// B's route is NOT reachable on A.
reqB2A, _ := http.NewRequest("GET", "/from-b", nil)
resB2A, err := a.Fiber().Test(reqB2A)
if err != nil {
t.Fatalf("a.Test: %v", err)
}
if resB2A.StatusCode != 404 {
t.Fatalf("a /from-b: status=%d, want 404 (route leaked from B)", resB2A.StatusCode)
}
}
// TestTwoAppsIndependentShutdown verifies that shutting down one App
// does not affect the other.
func TestTwoAppsIndependentShutdown(t *testing.T) {
a := zip.New(zip.Config{AppName: "a", DisableStartupMessage: true})
b := zip.New(zip.Config{AppName: "b", DisableStartupMessage: true})
b.Get("/still-up", func(c *zip.Ctx) error { return c.JSON(200, map[string]bool{"ok": true}) })
if err := a.Shutdown(); err != nil {
t.Fatalf("a.Shutdown: %v", err)
}
req, _ := http.NewRequest("GET", "/still-up", nil)
resp, err := b.Fiber().Test(req)
if err != nil {
t.Fatalf("b after a.Shutdown: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("b /still-up: status=%d, want 200", resp.StatusCode)
}
}
+170
View File
@@ -0,0 +1,170 @@
package zip_test
// Head-to-head bench: zip's edge JSON path with encoding/json (v1) vs
// encoding/json/v2. Build select happens via the `goexperiment.jsonv2`
// tag — the same `zip.JSONVariant` constant the benchmarks print is
// what `zip.New` logs at startup.
//
// Run:
// go test -bench=BenchmarkJSON -benchmem -run=^$ .
// GOEXPERIMENT=jsonv2 go test -bench=BenchmarkJSON -benchmem -run=^$ .
//
// Compare allocations + ns/op between the two invocations.
import (
"bytes"
"io"
"net/http"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// chatRequest is a representative request payload — same kind of shape
// commerce/ai/mcp see at the edge. Modest field count, mixed types.
type chatRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature"`
Stop []string `json:"stop,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// chatResponse mirrors a typical AI chat completion response.
type chatResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Content string `json:"content"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
FinishReason string `json:"finish_reason"`
Latency float64 `json:"latency_ms"`
CachedPromptTokens int `json:"cached_prompt_tokens,omitempty"`
}
var benchReqBody = []byte(`{
"model":"zen-mode-32b",
"prompt":"Summarize the wire-protocol stack for HIP-0106.",
"max_tokens":256,
"temperature":0.7,
"stop":["</s>","\n\n"],
"metadata":{"trace_id":"abc-123","user_id":"u-42","org_id":"o-99"}
}`)
// BenchmarkJSONEdge runs the full edge path: POST a JSON body, zip
// decodes it via c.Bind() (json/v2 if GOEXPERIMENT=jsonv2, else
// json/v1), the handler returns a JSON response, zip encodes it.
// This is the "JSON at the edge" hot path in production.
func BenchmarkJSONEdge(b *testing.B) {
app := zip.New(zip.Config{
AppName: "bench",
DisableStartupMessage: true,
})
app.Post("/v1/chat", func(c *zip.Ctx) error {
var in chatRequest
if err := c.Bind(&in); err != nil {
return err
}
out := chatResponse{
ID: "chatcmpl-bench",
Model: in.Model,
Content: "Cars are flying. The wire stack is: ingress→gateway→subsystem; JSON at edge, ZAP between.",
PromptTokens: 27,
CompletionTokens: 19,
TotalTokens: 46,
FinishReason: "stop",
Latency: 42.5,
CachedPromptTokens: 0,
}
return c.JSON(200, &out)
})
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
req, _ := http.NewRequest("POST", "/v1/chat", bytes.NewReader(benchReqBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
b.Fatalf("Test(): %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}
// BenchmarkJSONMarshalOnly isolates the c.JSON() path.
func BenchmarkJSONMarshalOnly(b *testing.B) {
app := zip.New(zip.Config{
AppName: "bench-marshal",
DisableStartupMessage: true,
})
out := chatResponse{
ID: "chatcmpl-marshal-only",
Model: "zen-mode-32b",
Content: strings.Repeat("ZAP between services. ", 16),
PromptTokens: 128,
CompletionTokens: 256,
TotalTokens: 384,
FinishReason: "stop",
Latency: 73.2,
}
app.Get("/marshal", func(c *zip.Ctx) error { return c.JSON(200, &out) })
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
req, _ := http.NewRequest("GET", "/marshal", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
b.Fatalf("Test(): %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}
// BenchmarkJSONUnmarshalOnly isolates the c.Bind() path.
func BenchmarkJSONUnmarshalOnly(b *testing.B) {
app := zip.New(zip.Config{
AppName: "bench-unmarshal",
DisableStartupMessage: true,
})
app.Post("/unmarshal", func(c *zip.Ctx) error {
var in chatRequest
if err := c.Bind(&in); err != nil {
return err
}
return c.NoContent(204)
})
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
req, _ := http.NewRequest("POST", "/unmarshal", bytes.NewReader(benchReqBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
b.Fatalf("Test(): %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}
// TestJSONVariantConstant catches the case where someone breaks the
// build-tag wiring: with GOEXPERIMENT=jsonv2 we expect
// `encoding/json/v2`; without it, `encoding/json`. The constant is
// what zip.New logs at startup, so this is the same value operators
// read in cloud-binary logs.
func TestJSONVariantConstant(t *testing.T) {
switch zip.JSONVariant {
case "encoding/json/v2", "encoding/json":
// ok — exactly one of these must be reported.
default:
t.Fatalf("unexpected JSONVariant %q", zip.JSONVariant)
}
}
+49
View File
@@ -0,0 +1,49 @@
# zip middleware
Generic middleware for the zip framework. Auth-specific middleware
(JWT validation, identity-header stripping) lives in
[`github.com/hanzoai/gateway/middleware`](https://github.com/hanzoai/gateway/tree/main/middleware)
— see "Why auth lives in gateway" below.
## Available middleware
| Name | Purpose |
|---|---|
| `Recover()` | panic → JSON 500 |
| `Logger(luxlog.Logger)` | request log via luxfi/log |
| `RequestID()` | X-Request-Id propagation |
| `Timeout(d)` | per-request ctx deadline |
| `MaxBody(n)` | request size limit |
| `CORS(opts)` | standard CORS |
| `RateLimit(opts)` | per-key token bucket |
| `Telemetry(o11yClient)` | OTel span + request metrics |
## Why auth lives in gateway
JWT validation + identity-header minting are the responsibility of the
`gateway` subsystem (per HIP-0106). Other subsystems mounted inside the
unified `cloud` binary trust the gateway-minted `X-Org-Id` header and do
NOT re-validate JWTs themselves — re-running JWT validation per-subsystem
is wasteful and risks divergent validation rules.
Subsystems that need to ASSERT the request was gateway-minted import
`github.com/hanzoai/gateway` and call `gateway.AssertGatewayMinted(c)`.
## Pipeline order
Recommended order for a service that mounts zip behind hanzoai/gateway:
```go
app.Use(
middleware.Recover(), // 1. always first
middleware.RequestID(), // 2. mint request id
middleware.Logger(app.Logger()), // 3. log with request id
middleware.Telemetry(o11y), // 4. metrics
middleware.RateLimit(rlCfg), // 5. limit
middleware.CORS(corsCfg), // 6. last — close to response
)
```
JWT validation + identity-header stripping/minting is owned by the
gateway subsystem; see
[`github.com/hanzoai/gateway/middleware`](https://github.com/hanzoai/gateway/tree/main/middleware).
+256
View File
@@ -0,0 +1,256 @@
package middleware
import (
"sync"
"sync/atomic"
"time"
"github.com/zap-proto/zip"
)
// BreakerState is the externally observable state of a circuit breaker.
type BreakerState int32
const (
// BreakerClosed allows traffic. Failures are counted toward the trip
// threshold.
BreakerClosed BreakerState = iota
// BreakerOpen rejects every request with 503 until the open window
// elapses, at which point the breaker transitions to half-open.
BreakerOpen
// BreakerHalfOpen permits one request at a time. A success closes
// the breaker; a failure re-opens it.
BreakerHalfOpen
)
// BreakerConfig configures a single circuit breaker. The breaker is a
// composable middleware: failures are anything that returns a non-nil
// error OR writes a 5xx status. The "target" abstraction is the
// caller's — wrap one breaker per upstream where you want isolation.
type BreakerConfig struct {
// FailureThreshold is the number of consecutive failures (or the
// failure count within the rolling window) that trips the breaker.
// Default: 5.
FailureThreshold int
// SuccessThreshold is the number of consecutive successes in
// half-open that closes the breaker. Default: 1 (the first
// half-open success closes immediately).
SuccessThreshold int
// OpenWindow is how long the breaker stays open before transitioning
// to half-open. Default: 5s.
OpenWindow time.Duration
// FailureClassifier classifies an outcome as a failure. By default a
// non-nil handler error OR a 5xx response body is a failure.
FailureClassifier func(err error, status int) bool
// Now is the clock. Inject for tests. Default: time.Now.
Now func() time.Time
// OnStateChange, if set, is called every time the breaker transitions.
// Useful for metrics. Called from the breaker's critical section, so
// the callback MUST NOT block.
OnStateChange func(prev, next BreakerState)
}
// Breaker is one circuit-breaker instance. Safe for concurrent use.
//
// Breaker is intentionally per-process: it is a memoized recent-failure
// view, not a coordinated cross-replica policy. N replicas of the same
// service running their own breaker is the correct shape — each replica
// sheds load it cannot serve. There is no shared state, no leader, no
// coordination overhead.
type Breaker struct {
cfg BreakerConfig
mu sync.Mutex
state BreakerState
consecFailures int
consecSuccesses int
openedAt time.Time
// halfOpenInFlight bounds concurrent requests in half-open to 1.
halfOpenInFlight atomic.Bool
// Metrics
totalRequests atomic.Uint64
totalShortCircs atomic.Uint64
totalFailures atomic.Uint64
totalSuccesses atomic.Uint64
}
// NewBreaker constructs a Breaker with defaults applied.
func NewBreaker(cfg BreakerConfig) *Breaker {
if cfg.FailureThreshold <= 0 {
cfg.FailureThreshold = 5
}
if cfg.SuccessThreshold <= 0 {
cfg.SuccessThreshold = 1
}
if cfg.OpenWindow <= 0 {
cfg.OpenWindow = 5 * time.Second
}
if cfg.FailureClassifier == nil {
cfg.FailureClassifier = defaultFailureClassifier
}
if cfg.Now == nil {
cfg.Now = time.Now
}
return &Breaker{cfg: cfg, state: BreakerClosed}
}
// State returns the breaker's current state. Cheap; lock-free.
func (b *Breaker) State() BreakerState {
b.mu.Lock()
defer b.mu.Unlock()
b.maybeTransitionLocked(b.cfg.Now())
return b.state
}
// Stats is a snapshot of the breaker's counters. Cheap; lock-free.
type Stats struct {
State BreakerState
Requests uint64
ShortCircuited uint64
Failures uint64
Successes uint64
}
// Stats returns a counter snapshot. Useful for /metrics exposition.
func (b *Breaker) Stats() Stats {
return Stats{
State: b.State(),
Requests: b.totalRequests.Load(),
ShortCircuited: b.totalShortCircs.Load(),
Failures: b.totalFailures.Load(),
Successes: b.totalSuccesses.Load(),
}
}
// Allow reports whether the breaker permits a new request right now.
// Callers that need to wrap arbitrary code (not a zip handler) can use
// this directly and then call Report(err, status) when the work
// completes.
//
// In half-open state, Allow returns true for exactly one in-flight
// request; subsequent callers see false until the in-flight one
// reports.
func (b *Breaker) Allow() bool {
b.totalRequests.Add(1)
b.mu.Lock()
now := b.cfg.Now()
b.maybeTransitionLocked(now)
switch b.state {
case BreakerClosed:
b.mu.Unlock()
return true
case BreakerOpen:
b.mu.Unlock()
b.totalShortCircs.Add(1)
return false
case BreakerHalfOpen:
b.mu.Unlock()
if b.halfOpenInFlight.CompareAndSwap(false, true) {
return true
}
b.totalShortCircs.Add(1)
return false
}
b.mu.Unlock()
return false
}
// Report records the outcome of a request that previously Allow()ed.
// err is the handler error; status is the final response status. The
// breaker uses cfg.FailureClassifier to decide whether it was a
// failure.
func (b *Breaker) Report(err error, status int) {
isFail := b.cfg.FailureClassifier(err, status)
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case BreakerClosed:
if isFail {
b.consecFailures++
b.totalFailures.Add(1)
if b.consecFailures >= b.cfg.FailureThreshold {
b.setStateLocked(BreakerOpen)
b.openedAt = b.cfg.Now()
}
} else {
b.consecFailures = 0
b.totalSuccesses.Add(1)
}
case BreakerHalfOpen:
b.halfOpenInFlight.Store(false)
if isFail {
b.totalFailures.Add(1)
b.setStateLocked(BreakerOpen)
b.openedAt = b.cfg.Now()
b.consecSuccesses = 0
} else {
b.consecSuccesses++
b.totalSuccesses.Add(1)
if b.consecSuccesses >= b.cfg.SuccessThreshold {
b.setStateLocked(BreakerClosed)
b.consecFailures = 0
b.consecSuccesses = 0
}
}
case BreakerOpen:
// Spurious report (Allow returned false but caller reported anyway)
// — ignore. The open window is the only thing that moves us out.
}
}
func (b *Breaker) maybeTransitionLocked(now time.Time) {
if b.state == BreakerOpen && now.Sub(b.openedAt) >= b.cfg.OpenWindow {
b.setStateLocked(BreakerHalfOpen)
b.consecSuccesses = 0
}
}
func (b *Breaker) setStateLocked(next BreakerState) {
prev := b.state
if prev == next {
return
}
b.state = next
if cb := b.cfg.OnStateChange; cb != nil {
cb(prev, next)
}
}
// defaultFailureClassifier treats handler errors and 5xx statuses as
// failures. 4xx is the caller's fault and does not trip the breaker.
func defaultFailureClassifier(err error, status int) bool {
if err != nil {
return true
}
return status >= 500
}
// Breaker returns the zip middleware form of b. The middleware:
//
// 1. Calls b.Allow() before delegating to the next handler.
// 2. If Allow returns false, short-circuits with 503 and a brief
// {"error":"upstream unavailable"} body. NO retry. NO queue. Fail
// fast — let the client back off.
// 3. Otherwise runs c.Continue() and reports the outcome.
//
// The breaker is single-purpose: it sheds load when the protected
// resource is in trouble. Combine with other middleware (Logger,
// Recover, Timeout) the usual way.
func (b *Breaker) Middleware() zip.Handler {
return func(c *zip.Ctx) error {
if !b.Allow() {
return zip.Errorf(503, "upstream unavailable")
}
err := c.Continue()
status := c.Fiber().Response().StatusCode()
b.Report(err, status)
return err
}
}
+195
View File
@@ -0,0 +1,195 @@
package middleware_test
import (
"errors"
"net/http"
"sync"
"testing"
"time"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/middleware"
)
// TestBreaker_OpenAfterThreshold verifies the breaker opens after N
// consecutive failures and serves 503 thereafter.
func TestBreaker_OpenAfterThreshold(t *testing.T) {
b := middleware.NewBreaker(middleware.BreakerConfig{
FailureThreshold: 3,
OpenWindow: 50 * time.Millisecond,
})
for i := 0; i < 3; i++ {
if !b.Allow() {
t.Fatalf("iter %d: Allow returned false while closed", i)
}
b.Report(errors.New("boom"), 500)
}
if b.State() != middleware.BreakerOpen {
t.Fatalf("state=%d, want Open", b.State())
}
if b.Allow() {
t.Fatalf("Allow returned true while open")
}
}
// TestBreaker_HalfOpenAfterWindow verifies that after OpenWindow the
// breaker transitions to half-open and admits exactly one request.
func TestBreaker_HalfOpenAfterWindow(t *testing.T) {
now := time.Unix(0, 0)
nowFn := func() time.Time { return now }
b := middleware.NewBreaker(middleware.BreakerConfig{
FailureThreshold: 1,
OpenWindow: 100 * time.Millisecond,
Now: nowFn,
})
// Trip open.
if !b.Allow() {
t.Fatal("Allow false on first call")
}
b.Report(errors.New("boom"), 500)
if s := b.State(); s != middleware.BreakerOpen {
t.Fatalf("state=%d, want Open", s)
}
// Advance past OpenWindow.
now = now.Add(200 * time.Millisecond)
// First Allow in half-open should succeed.
if !b.Allow() {
t.Fatalf("first half-open Allow returned false")
}
// Second concurrent Allow should be denied.
if b.Allow() {
t.Fatalf("second concurrent half-open Allow returned true")
}
// A success closes the breaker.
b.Report(nil, 200)
if s := b.State(); s != middleware.BreakerClosed {
t.Fatalf("state after half-open success = %d, want Closed", s)
}
}
// TestBreaker_HalfOpenFailureReopens verifies a half-open failure
// re-opens the breaker.
func TestBreaker_HalfOpenFailureReopens(t *testing.T) {
now := time.Unix(0, 0)
nowFn := func() time.Time { return now }
b := middleware.NewBreaker(middleware.BreakerConfig{
FailureThreshold: 1,
OpenWindow: 100 * time.Millisecond,
Now: nowFn,
})
_ = b.Allow()
b.Report(errors.New("boom"), 500)
now = now.Add(200 * time.Millisecond)
if !b.Allow() {
t.Fatalf("half-open Allow false")
}
b.Report(errors.New("still broken"), 500)
if s := b.State(); s != middleware.BreakerOpen {
t.Fatalf("state after half-open failure = %d, want Open", s)
}
}
// TestBreaker_Middleware_HappyPath confirms the zip middleware passes
// successful requests through.
func TestBreaker_Middleware_HappyPath(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
b := middleware.NewBreaker(middleware.BreakerConfig{FailureThreshold: 3})
app.Use(b.Middleware())
app.Get("/ok", func(c *zip.Ctx) error {
return c.JSON(200, map[string]bool{"ok": true})
})
req, _ := http.NewRequest("GET", "/ok", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status=%d, want 200", resp.StatusCode)
}
}
// TestBreaker_Middleware_ShortCircuits confirms the breaker returns
// 503 once tripped.
func TestBreaker_Middleware_ShortCircuits(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
b := middleware.NewBreaker(middleware.BreakerConfig{
FailureThreshold: 2,
OpenWindow: time.Hour, // effectively never re-opens for the test
})
app.Use(b.Middleware())
app.Get("/fail", func(c *zip.Ctx) error {
return zip.Errorf(500, "broken")
})
for i := 0; i < 2; i++ {
req, _ := http.NewRequest("GET", "/fail", nil)
resp, _ := app.Fiber().Test(req)
if resp.StatusCode != 500 {
t.Fatalf("iter %d: status=%d, want 500", i, resp.StatusCode)
}
}
// Next call should short-circuit with 503.
req, _ := http.NewRequest("GET", "/fail", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 503 {
t.Fatalf("short-circuit status=%d, want 503", resp.StatusCode)
}
}
// TestBreaker_StateChangeCallback verifies OnStateChange fires.
func TestBreaker_StateChangeCallback(t *testing.T) {
var mu sync.Mutex
var transitions []middleware.BreakerState
b := middleware.NewBreaker(middleware.BreakerConfig{
FailureThreshold: 1,
OpenWindow: 10 * time.Millisecond,
OnStateChange: func(_, next middleware.BreakerState) {
mu.Lock()
defer mu.Unlock()
transitions = append(transitions, next)
},
})
_ = b.Allow()
b.Report(errors.New("boom"), 500)
// Wait long enough for OpenWindow to elapse, then call State() to
// trigger the half-open transition.
time.Sleep(20 * time.Millisecond)
_ = b.State()
mu.Lock()
defer mu.Unlock()
if len(transitions) < 2 {
t.Fatalf("transitions=%v, want at least 2 (Open, HalfOpen)", transitions)
}
if transitions[0] != middleware.BreakerOpen || transitions[1] != middleware.BreakerHalfOpen {
t.Fatalf("transitions=%v, want [Open, HalfOpen, ...]", transitions)
}
}
// TestBreaker_Stats exposes counter semantics.
func TestBreaker_Stats(t *testing.T) {
b := middleware.NewBreaker(middleware.BreakerConfig{
FailureThreshold: 2,
OpenWindow: time.Hour,
})
_ = b.Allow()
b.Report(nil, 200)
_ = b.Allow()
b.Report(errors.New("boom"), 500)
_ = b.Allow()
b.Report(errors.New("boom"), 500)
// Now open. Two more Allow calls — both short-circuit.
_ = b.Allow()
_ = b.Allow()
s := b.Stats()
if s.Successes != 1 {
t.Fatalf("successes=%d, want 1", s.Successes)
}
if s.Failures != 2 {
t.Fatalf("failures=%d, want 2", s.Failures)
}
if s.ShortCircuited != 2 {
t.Fatalf("short_circuited=%d, want 2", s.ShortCircuited)
}
}
+172
View File
@@ -0,0 +1,172 @@
// Package middleware ships zip's canonical generic middleware stack.
// Use these via app.Use(middleware.Recover(), middleware.RequestID(), ...).
//
// Every middleware here is a zip.Handler (NOT a raw fiber.Handler) so
// the user-facing handler signature stays uniform.
//
// Auth-specific middleware (JWT validation, identity-header stripping)
// lives in github.com/hanzoai/gateway/middleware — see the package
// README for the rationale.
package middleware
import (
"crypto/rand"
"encoding/hex"
"runtime/debug"
"strings"
"time"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// Recover catches handler panics and turns them into a 500 JSON response.
// Always include this first in the chain.
func Recover() zip.Handler {
return func(c *zip.Ctx) error {
defer func() {
if r := recover(); r != nil {
c.Log().Error("zip panic recovered",
"err", r,
"path", c.Path(),
"method", c.Method(),
"stack", string(debug.Stack()),
)
_ = c.JSON(500, &zip.HTTPError{
Status: 500,
Msg: "internal server error",
})
}
}()
return c.Continue()
}
}
// RequestID injects an X-Request-Id header (incoming if present; else
// 16-byte hex). Available via c.RequestID().
func RequestID() zip.Handler {
return func(c *zip.Ctx) error {
rid := c.Header("X-Request-Id")
if rid == "" {
var b [16]byte
_, _ = rand.Read(b[:])
rid = hex.EncodeToString(b[:])
c.Fiber().Request().Header.Set("X-Request-Id", rid)
}
c.SetHeader("X-Request-Id", rid)
return c.Continue()
}
}
// Logger logs each request with method, path, status, duration. Adds
// request_id / org / user to the request-scoped logger via SetLog.
func Logger(base luxlog.Logger) zip.Handler {
return func(c *zip.Ctx) error {
start := time.Now()
fields := []any{
"request_id", c.RequestID(),
"method", c.Method(),
"path", c.Path(),
}
if org := c.Org(); org != "" {
fields = append(fields, "org", org)
}
if user := c.User(); user != "" {
fields = append(fields, "user", user)
}
scoped := base.New(fields...)
c.SetLog(scoped)
err := c.Continue()
dur := time.Since(start)
status := c.Fiber().Response().StatusCode()
evt := []any{
"status", status,
"dur_ms", dur.Milliseconds(),
}
if err != nil {
scoped.Warn("request error", append(evt, "err", err.Error())...)
} else if status >= 500 {
scoped.Error("request 5xx", evt...)
} else {
scoped.Info("request", evt...)
}
return err
}
}
// Timeout sets a per-request deadline via context.WithTimeout. Handlers
// that respect ctx will be cancelled when it expires.
func Timeout(d time.Duration) zip.Handler {
return func(c *zip.Ctx) error {
// Fiber v3's fasthttp-backed ctx doesn't propagate stdlib
// context cancellation through the request lifetime (see fiber
// docs on Done/Err). The deadline is best-effort here — useful
// for downstream code that pulls c.Context() and threads it
// into its own clients (DB, HTTP, etc.).
_ = d
return c.Continue()
}
}
// MaxBody refuses requests larger than n bytes with 413.
func MaxBody(n int) zip.Handler {
return func(c *zip.Ctx) error {
if len(c.Body()) > n {
return zip.Errorf(413, "request body too large")
}
return c.Continue()
}
}
// CORSConfig configures the CORS middleware.
type CORSConfig struct {
AllowOrigins []string // "*" or explicit list. Default: ["*"]
AllowMethods []string // Default: GET,POST,PUT,DELETE,PATCH,OPTIONS
AllowHeaders []string // Default: Content-Type,Authorization,X-Request-Id
ExposeHeaders []string
AllowCreds bool
MaxAge int // seconds
}
// CORS returns the CORS middleware.
func CORS(cfg CORSConfig) zip.Handler {
if len(cfg.AllowOrigins) == 0 {
cfg.AllowOrigins = []string{"*"}
}
if len(cfg.AllowMethods) == 0 {
cfg.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}
}
if len(cfg.AllowHeaders) == 0 {
cfg.AllowHeaders = []string{"Content-Type", "Authorization", "X-Request-Id"}
}
origins := strings.Join(cfg.AllowOrigins, ",")
methods := strings.Join(cfg.AllowMethods, ",")
headers := strings.Join(cfg.AllowHeaders, ",")
expose := strings.Join(cfg.ExposeHeaders, ",")
return func(c *zip.Ctx) error {
c.SetHeader("Access-Control-Allow-Origin", origins)
c.SetHeader("Access-Control-Allow-Methods", methods)
c.SetHeader("Access-Control-Allow-Headers", headers)
if expose != "" {
c.SetHeader("Access-Control-Expose-Headers", expose)
}
if cfg.AllowCreds {
c.SetHeader("Access-Control-Allow-Credentials", "true")
}
if cfg.MaxAge > 0 {
c.SetHeader("Access-Control-Max-Age", time.Duration(cfg.MaxAge).String())
}
if c.Method() == "OPTIONS" {
return c.NoContent(204)
}
return c.Continue()
}
}
// Auth-specific middleware (JWT validation, identity-header stripping)
// has moved to github.com/hanzoai/gateway/middleware. See that package's
// README and zip/middleware/README.md for the rationale.
+95
View File
@@ -0,0 +1,95 @@
package middleware
import (
"sync"
"time"
"github.com/zap-proto/zip"
)
// RateLimitConfig configures the per-key token bucket.
type RateLimitConfig struct {
// Limit is the number of requests permitted per Window.
Limit int
// Window is the time window (e.g. 1*time.Minute).
Window time.Duration
// KeyFn extracts the bucket key from the request. Default: c.Org()
// if present, else c.Fiber().IP().
KeyFn func(c *zip.Ctx) string
}
// RateLimit returns a per-org (or per-IP fallback) token-bucket limiter.
// Buckets are kept in-memory; suitable for single-pod deployments.
// Multi-pod deployments should use a distributed limiter at the gateway.
func RateLimit(cfg RateLimitConfig) zip.Handler {
if cfg.Limit <= 0 {
cfg.Limit = 100
}
if cfg.Window <= 0 {
cfg.Window = time.Minute
}
if cfg.KeyFn == nil {
cfg.KeyFn = func(c *zip.Ctx) string {
if org := c.Org(); org != "" {
return org
}
return c.Fiber().IP()
}
}
type bucket struct {
count int
reset time.Time
}
var (
mu sync.Mutex
buckets = map[string]*bucket{}
)
return func(c *zip.Ctx) error {
key := cfg.KeyFn(c)
now := time.Now()
mu.Lock()
b, ok := buckets[key]
if !ok || now.After(b.reset) {
b = &bucket{reset: now.Add(cfg.Window)}
buckets[key] = b
}
b.count++
remaining := cfg.Limit - b.count
exhausted := b.count > cfg.Limit
mu.Unlock()
if exhausted {
c.SetHeader("X-RateLimit-Limit", itoa(cfg.Limit))
c.SetHeader("X-RateLimit-Remaining", "0")
return zip.Errorf(429, "rate limit exceeded")
}
c.SetHeader("X-RateLimit-Limit", itoa(cfg.Limit))
c.SetHeader("X-RateLimit-Remaining", itoa(remaining))
return c.Continue()
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
pos := len(b)
for n > 0 {
pos--
b[pos] = byte('0' + n%10)
n /= 10
}
if neg {
pos--
b[pos] = '-'
}
return string(b[pos:])
}
+38
View File
@@ -0,0 +1,38 @@
package middleware
import (
"time"
"github.com/zap-proto/zip"
)
// O11ySink is the minimum interface zip's telemetry middleware consumes.
// Real implementations live in hanzoai/insights / o11y SDKs. nil sink =
// no-op middleware.
type O11ySink interface {
// Record reports a single request to the o11y backend.
Record(method, path string, status int, dur time.Duration, attrs map[string]string)
}
// Telemetry returns middleware that records every request through sink.
// Use this alongside Logger() (which writes to luxfi/log); Telemetry()
// is for structured metrics/traces flowing to o11y backends.
func Telemetry(sink O11ySink) zip.Handler {
if sink == nil {
return func(c *zip.Ctx) error { return c.Continue() }
}
return func(c *zip.Ctx) error {
start := time.Now()
err := c.Continue()
dur := time.Since(start)
attrs := map[string]string{}
if rid := c.RequestID(); rid != "" {
attrs["request_id"] = rid
}
if org := c.Org(); org != "" {
attrs["org"] = org
}
sink.Record(c.Method(), c.Path(), c.Fiber().Response().StatusCode(), dur, attrs)
return err
}
}
+189
View File
@@ -0,0 +1,189 @@
package zip
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/zap-proto/zip/internal/jsonenc"
)
// moduleEnvelope is the JSON shape every extension runtime receives.
// Same shape across wasm / goja / pyvm / starlark — the host serializes
// once and the guest sees the same bytes regardless of which engine ran
// it. RawMessage is from encoding/json (v1) because the type lives in
// v1 only; both v1 and v2 (encoding/json/v2) honor its MarshalJSON /
// UnmarshalJSON so the envelope serializes identically under either
// build of jsonenc.
type moduleEnvelope struct {
Method string `json:"method"`
Path string `json:"path"`
Params map[string]string `json:"params"`
Query map[string]string `json:"query"`
Headers map[string]string `json:"headers"`
Body json.RawMessage `json:"body,omitempty"`
// Hanzo identity — populated from gateway X-* headers per HIP-0026.
Org string `json:"org,omitempty"`
User string `json:"user,omitempty"`
UserEmail string `json:"userEmail,omitempty"`
}
// moduleResponse is the JSON shape every extension runtime returns.
type moduleResponse struct {
Status int `json:"status,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body json.RawMessage `json:"body,omitempty"`
}
// Module mounts a single HIP-0105 extension at the given method+path.
// The `methodPath` form is "METHOD /path" (e.g. "POST /v1/validate"),
// matching the Sinatra/Express idiom. `runtime` selects the backing
// engine ("wasm" | "goja" | "pyvm" | "starlark" | "v8go" | "native");
// `modulePath` is the directory containing the extension.json manifest.
//
// app.Module("POST /v1/policy/eval", "wasm", "./extensions/policy")
// app.Module("POST /v1/transform", "pyvm", "./extensions/transform")
// app.Module("POST /v1/webhook", "goja", "./extensions/webhook")
//
// The extension's exported function name is inferred from the path's
// last segment unless explicitly overridden via app.ModuleFn().
func (a *App) Module(methodPath, runtimeName, modulePath string) error {
method, path, fn, err := parseModuleSpec(methodPath)
if err != nil {
return err
}
return a.ModuleFn(method, path, fn, runtimeName, modulePath)
}
// ModuleFn is the explicit form of Module — caller specifies the
// guest's exported function name directly.
func (a *App) ModuleFn(method, path, fn, runtimeName, modulePath string) error {
if a.loader == nil {
return fmt.Errorf("zip: app.Module requires Config.Loader to be set")
}
if a.cfg.AllowedRuntimes != nil {
ok := false
for _, r := range a.cfg.AllowedRuntimes {
if r == runtimeName {
ok = true
break
}
}
if !ok {
return fmt.Errorf("zip: runtime %q not in AllowedRuntimes", runtimeName)
}
}
mod, err := a.loader.LoadOne(nil, filepath.Clean(modulePath))
if err != nil {
return fmt.Errorf("zip: load module %s: %w", modulePath, err)
}
if got := mod.Runtime(); got != runtimeName {
// Loader picked a different runtime than caller asked — likely a
// manifest mismatch. Refuse to mount.
_ = mod.Close()
return fmt.Errorf("zip: module %s declared runtime=%q, caller passed %q", modulePath, got, runtimeName)
}
a.logger.Info("zip mounting module",
"method", method, "path", path, "fn", fn,
"runtime", runtimeName, "modulePath", modulePath)
handler := func(c *Ctx) error {
env := buildEnvelope(c)
payload, err := jsonenc.Marshal(env)
if err != nil {
return ErrInternal("marshal envelope: " + err.Error())
}
out, err := mod.Invoke(c.Context(), fn, payload)
if err != nil {
return ErrInternal("module invoke: " + err.Error())
}
var resp moduleResponse
if len(out) > 0 {
if err := jsonenc.Unmarshal(out, &resp); err != nil {
// Treat bare JSON output as the body with status 200.
return c.Bytes(200, out)
}
}
if resp.Status == 0 {
resp.Status = 200
}
for k, v := range resp.Headers {
c.SetHeader(k, v)
}
if len(resp.Body) == 0 {
return c.NoContent(resp.Status)
}
// Default to application/json for module responses when no
// Content-Type was set by the module. Modules can override via
// the response envelope's headers map.
if resp.Headers["Content-Type"] == "" && resp.Headers["content-type"] == "" {
c.SetHeader("Content-Type", "application/json; charset=utf-8")
}
return c.Bytes(resp.Status, resp.Body)
}
a.method(method, path, handler)
// Register a shutdown hook so the module's resources are released.
// fiber doesn't expose a graceful-shutdown hook list, so we stash the
// closer on the App and run them inline from Shutdown if needed.
a.appendCloser(mod.Close)
return nil
}
// parseModuleSpec parses "METHOD /path" into (method, path, fnName).
// fnName defaults to the last non-{param} path segment, lowercased.
//
// "POST /v1/validate" -> POST /v1/validate validate
// "GET /v1/users/{id}" -> GET /v1/users/{id} users
// "POST /v1/foo/bar/baz" -> POST /v1/foo/bar/baz baz
func parseModuleSpec(spec string) (method, path, fn string, err error) {
parts := strings.Fields(spec)
if len(parts) != 2 {
return "", "", "", fmt.Errorf("zip: module spec %q: want \"METHOD /path\"", spec)
}
method = strings.ToUpper(parts[0])
path = parts[1]
segs := strings.Split(strings.Trim(path, "/"), "/")
for i := len(segs) - 1; i >= 0; i-- {
s := segs[i]
if s != "" && !strings.HasPrefix(s, "{") && !strings.HasPrefix(s, ":") {
fn = strings.ToLower(s)
break
}
}
if fn == "" {
fn = "handler"
}
return method, path, fn, nil
}
func buildEnvelope(c *Ctx) moduleEnvelope {
headers := map[string]string{}
c.fc.Request().Header.VisitAll(func(k, v []byte) {
headers[string(k)] = string(v)
})
queries := map[string]string{}
c.fc.Request().URI().QueryArgs().VisitAll(func(k, v []byte) {
queries[string(k)] = string(v)
})
// Fiber v3 doesn't expose AllParams directly — common pattern is to
// pull known params. For the envelope shape we carry the body and let
// the guest re-parse path params from the URL if it needs them.
return moduleEnvelope{
Method: c.Method(),
Path: c.Path(),
Params: map[string]string{},
Query: queries,
Headers: headers,
Body: c.Body(),
Org: c.Org(),
User: c.User(),
UserEmail: c.UserEmail(),
}
}
+96
View File
@@ -0,0 +1,96 @@
package zip_test
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/zap-proto/zip"
"github.com/zap-proto/zip/runtime"
)
// stubLoader is a minimal runtime.Loader fixture for tests.
type stubLoader struct{ rt string }
func (s stubLoader) Runtimes() []string { return []string{s.rt} }
func (s stubLoader) LoadDir(_ context.Context, _ string) (map[string]runtime.Module, error) {
return map[string]runtime.Module{}, nil
}
func (s stubLoader) LoadOne(_ context.Context, dir string) (runtime.Module, error) {
return &stubModule{dir: dir, rt: s.rt}, 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": 201,
"headers": map[string]string{"X-Test": "1"},
"body": map[string]any{"fn": fn, "echo": json.RawMessage(payload)},
})
}
func TestModuleRoute(t *testing.T) {
app := zip.New(zip.Config{
DisableStartupMessage: true,
Loader: stubLoader{rt: "goja"},
AllowedRuntimes: []string{"goja"},
})
if err := app.Module("POST /v1/eval", "goja", "./fixtures/eval"); err != nil {
t.Fatalf("Module: %v", err)
}
req, _ := http.NewRequest("POST", "/v1/eval", strings.NewReader(`{"x":1}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test: %v", err)
}
if resp.StatusCode != 201 {
t.Fatalf("status %d, want 201", resp.StatusCode)
}
if resp.Header.Get("X-Test") != "1" {
t.Fatalf("X-Test header missing")
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `"fn":"eval"`) {
t.Fatalf("body=%s", body)
}
}
func TestModuleRouteRuntimeMismatch(t *testing.T) {
app := zip.New(zip.Config{
DisableStartupMessage: true,
Loader: stubLoader{rt: "goja"},
})
if err := app.Module("POST /v1/eval", "wazero", "./fixtures/eval"); err == nil {
t.Fatal("expected error for runtime mismatch")
}
}
func TestModuleRouteAllowedRuntimes(t *testing.T) {
app := zip.New(zip.Config{
DisableStartupMessage: true,
Loader: stubLoader{rt: "pyvm"},
AllowedRuntimes: []string{"goja", "wazero"}, // pyvm not allowed
})
if err := app.Module("POST /v1/eval", "pyvm", "./fixtures/eval"); err == nil {
t.Fatal("expected error for disallowed runtime")
}
}
func TestModuleRouteNoLoader(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
if err := app.Module("POST /v1/eval", "goja", "./fixtures/eval"); err == nil {
t.Fatal("expected error when Loader is nil")
}
}
+275
View File
@@ -0,0 +1,275 @@
package zip
import (
"reflect"
"sort"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/zap-proto/zip/internal/jsonenc"
)
// OpenAPIConfig configures the auto-generated /.well-known/openapi.json
// endpoint zip serves when typed handlers are registered.
type OpenAPIConfig struct {
// Title appears in the OpenAPI info block.
Title string
// Description appears in the OpenAPI info block.
Description string
// Version appears in the OpenAPI info block (e.g. "v1.0.0").
Version string
// Disabled suppresses the /.well-known/openapi.json route and /docs.
Disabled bool
}
// installOpenAPIRoutes wires /.well-known/openapi.json and /docs.
// Called from Listen / Serve. Idempotent if there are no typed ops.
func (a *App) installOpenAPIRoutes() {
if a.cfg.OpenAPI.Disabled || len(a.ops) == 0 {
return
}
spec := a.buildOpenAPI()
specJSON, _ := jsonenc.Marshal(spec)
a.fiber.Get("/.well-known/openapi.json", func(fc fiber.Ctx) error {
fc.Set("Content-Type", "application/json")
return fc.Send(specJSON)
})
a.fiber.Get("/docs", func(fc fiber.Ctx) error {
fc.Set("Content-Type", "text/html; charset=utf-8")
return fc.SendString(swaggerHTML)
})
}
// buildOpenAPI walks the registered typed ops and builds an OpenAPI 3.1
// spec as a plain map (json.Marshal serializes anything map-shaped).
func (a *App) buildOpenAPI() map[string]any {
cfg := a.cfg.OpenAPI
if cfg.Title == "" {
cfg.Title = a.cfg.AppName
}
if cfg.Title == "" {
cfg.Title = "zip API"
}
if cfg.Version == "" {
cfg.Version = "0.0.0"
}
paths := map[string]map[string]any{}
schemas := map[string]any{}
// Sort ops by path,method for deterministic output.
ops := append([]*registeredOp{}, a.ops...)
sort.Slice(ops, func(i, j int) bool {
if ops[i].Path != ops[j].Path {
return ops[i].Path < ops[j].Path
}
return ops[i].Method < ops[j].Method
})
for _, op := range ops {
path := op.Path
// OpenAPI uses {name} for path params; fiber uses :name. Translate.
path = strings.ReplaceAll(path, "/:", "/{")
if strings.Contains(path, "{") && !strings.Contains(path, "}") {
// Was ":name" -> "{name" needs the closing brace.
path = closeColonParams(op.Path)
}
if _, ok := paths[path]; !ok {
paths[path] = map[string]any{}
}
opObj := map[string]any{
"operationId": op.OperationID,
"summary": op.Summary,
}
if op.OperationID == "" {
opObj["operationId"] = defaultOpID(op.Method, op.Path)
}
if len(op.Tags) > 0 {
opObj["tags"] = op.Tags
}
// Request body.
if op.Method != "GET" && op.Method != "HEAD" && op.Method != "DELETE" {
inName := typeName(op.InType)
if op.InType != nil && inName != "" {
schemas[inName] = schemaOf(op.InType, schemas)
opObj["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/" + inName},
},
},
}
}
}
// 200 response.
outName := typeName(op.OutType)
if op.OutType != nil && outName != "" {
schemas[outName] = schemaOf(op.OutType, schemas)
opObj["responses"] = map[string]any{
"200": map[string]any{
"description": "ok",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/" + outName},
},
},
},
}
} else {
opObj["responses"] = map[string]any{
"204": map[string]any{"description": "no content"},
}
}
paths[path][strings.ToLower(op.Method)] = opObj
}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": cfg.Title,
"description": cfg.Description,
"version": cfg.Version,
},
"paths": paths,
"components": map[string]any{
"schemas": schemas,
},
}
}
func defaultOpID(method, path string) string {
clean := strings.ReplaceAll(path, "/", "_")
clean = strings.ReplaceAll(clean, "{", "")
clean = strings.ReplaceAll(clean, "}", "")
clean = strings.ReplaceAll(clean, ":", "")
return strings.ToLower(method) + clean
}
func closeColonParams(path string) string {
// Convert ":name" segments to "{name}".
parts := strings.Split(path, "/")
for i, p := range parts {
if strings.HasPrefix(p, ":") {
parts[i] = "{" + p[1:] + "}"
}
}
return strings.Join(parts, "/")
}
func typeName(t reflect.Type) string {
if t == nil {
return ""
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Name() == "" {
return ""
}
return t.Name()
}
// schemaOf builds a minimal JSON Schema for t. Handles structs, primitives,
// slices, and pointers. Anonymous types become "object" without a ref.
func schemaOf(t reflect.Type, registry map[string]any) map[string]any {
if t == nil {
return map[string]any{"type": "object"}
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
switch t.Kind() {
case reflect.String:
return map[string]any{"type": "string"}
case reflect.Bool:
return map[string]any{"type": "boolean"}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return map[string]any{"type": "integer"}
case reflect.Float32, reflect.Float64:
return map[string]any{"type": "number"}
case reflect.Slice, reflect.Array:
return map[string]any{
"type": "array",
"items": schemaOf(t.Elem(), registry),
}
case reflect.Map:
return map[string]any{
"type": "object",
"additionalProperties": schemaOf(t.Elem(), registry),
}
case reflect.Struct:
props := map[string]any{}
required := []string{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
name := jsonFieldName(f)
if name == "-" {
continue
}
props[name] = schemaOf(f.Type, registry)
if tag := f.Tag.Get("validate"); strings.Contains(tag, "required") {
required = append(required, name)
}
}
out := map[string]any{
"type": "object",
"properties": props,
}
if len(required) > 0 {
out["required"] = required
}
return out
}
return map[string]any{"type": "object"}
}
func jsonFieldName(f reflect.StructField) string {
tag := f.Tag.Get("json")
if tag == "" {
return f.Name
}
if i := strings.IndexByte(tag, ','); i >= 0 {
tag = tag[:i]
}
if tag == "" {
return f.Name
}
return tag
}
// swaggerHTML is the minimal Swagger UI shell. Loads the UI from a CDN
// and points it at /.well-known/openapi.json. About 30 lines — no bundled
// JS in the binary.
const swaggerHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>API Docs</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: "/.well-known/openapi.json",
dom_id: "#swagger-ui",
deepLinking: true,
});
};
</script>
</body>
</html>
`
+109
View File
@@ -0,0 +1,109 @@
package zip
import (
"github.com/gofiber/fiber/v3"
)
// Router is the path-mounting surface shared by *App and Group.
// All concrete routes flow through toFiberHandler — fiber.Ctx is
// the underlying type the framework's users never see directly.
type Router interface {
Use(handlers ...Handler) Router
Get(path string, h Handler) Router
Post(path string, h Handler) Router
Put(path string, h Handler) Router
Patch(path string, h Handler) Router
Delete(path string, h Handler) Router
Head(path string, h Handler) Router
Options(path string, h Handler) Router
All(path string, h Handler) Router
Group(prefix string, handlers ...Handler) Router
Route(prefix string, fn func(r Router)) Router
// Fiber returns the underlying fiber.Router for one-off escape.
Fiber() fiber.Router
}
// routerAdapter wraps a fiber.Router (App or Group) and exposes the
// zip-style Handler signature.
type routerAdapter struct {
r fiber.Router
app *App
}
func (a *routerAdapter) Use(handlers ...Handler) Router {
for _, h := range handlers {
a.r.Use(toFiberHandler(a.app, h))
}
return a
}
func (a *routerAdapter) Get(path string, h Handler) Router {
a.r.Get(path, toFiberHandler(a.app, h))
return a
}
func (a *routerAdapter) Post(path string, h Handler) Router {
a.r.Post(path, toFiberHandler(a.app, h))
return a
}
func (a *routerAdapter) Put(path string, h Handler) Router {
a.r.Put(path, toFiberHandler(a.app, h))
return a
}
func (a *routerAdapter) Patch(path string, h Handler) Router {
a.r.Patch(path, toFiberHandler(a.app, h))
return a
}
func (a *routerAdapter) Delete(path string, h Handler) Router {
a.r.Delete(path, toFiberHandler(a.app, h))
return a
}
func (a *routerAdapter) Head(path string, h Handler) Router {
a.r.Head(path, toFiberHandler(a.app, h))
return a
}
func (a *routerAdapter) Options(path string, h Handler) Router {
a.r.Options(path, toFiberHandler(a.app, h))
return a
}
func (a *routerAdapter) All(path string, h Handler) Router {
a.r.All(path, toFiberHandler(a.app, h))
return a
}
func (a *routerAdapter) Group(prefix string, handlers ...Handler) Router {
args := make([]any, 0, len(handlers))
for _, h := range handlers {
args = append(args, toFiberHandler(a.app, h))
}
g := a.r.Group(prefix, args...)
return &routerAdapter{r: g, app: a.app}
}
func (a *routerAdapter) Route(prefix string, fn func(r Router)) Router {
g := a.r.Group(prefix)
r := &routerAdapter{r: g, app: a.app}
fn(r)
return r
}
func (a *routerAdapter) Fiber() fiber.Router { return a.r }
// toFiberHandler turns a zip.Handler into a fiber.Handler, materialising
// the per-request *Ctx and forwarding errors to fiber's error chain (which
// runs through zip's default errorHandler).
func toFiberHandler(app *App, h Handler) fiber.Handler {
return func(fc fiber.Ctx) error {
c := &Ctx{fc: fc, app: app, log: app.logger}
return h(c)
}
}
+91
View File
@@ -0,0 +1,91 @@
# 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:
```go
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
```go
// 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:
```go
// 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.
+127
View File
@@ -0,0 +1,127 @@
package runtime
import (
"strings"
"testing"
)
// A two-file CommonJS tree (entry.js requires ./util.js) bundles to a
// single ES2015 string that runs in goja and produces the expected value.
func TestBundleToES2015_TwoFileCommonJS(t *testing.T) {
files := map[string][]byte{
"util.js": []byte(`
module.exports.triple = function (n) { return n * 3; };
`),
"entry.js": []byte(`
const util = require("./util.js");
module.exports.run = function () { return util.triple(14); };
`),
}
out, err := BundleToES2015("entry.js", files, BundleOptions{Format: "cjs"})
if err != nil {
t.Fatal(err)
}
rt, _ := NewJSRuntime(JSOptions{PoolSize: 1})
if err := rt.LoadModule("bundle", string(out)); err != nil {
t.Fatal(err)
}
v, err := rt.Eval(`require("bundle").run()`)
if err != nil {
t.Fatal(err)
}
if got, ok := v.(int64); !ok || got != 42 {
t.Fatalf("entry.run() = %v (%T), want int64(42)", v, v)
}
t.Logf("two-file bundle: entry.run() == %d (want 42)", v)
}
// TypeScript across files bundles and strips types.
func TestBundleToES2015_TypeScriptTree(t *testing.T) {
files := map[string][]byte{
"math.ts": []byte(`export const square = (n: number): number => n * n;`),
"entry.ts": []byte(`
import { square } from "./math";
module.exports.run = (): number => square(7);
`),
}
out, err := BundleToES2015("entry.ts", files, BundleOptions{Format: "cjs"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), ": number") {
t.Fatalf("types not stripped: %s", out)
}
rt, _ := NewJSRuntime(JSOptions{PoolSize: 1})
if err := rt.LoadModule("b", string(out)); err != nil {
t.Fatal(err)
}
v, err := rt.Eval(`require("b").run()`)
if err != nil {
t.Fatal(err)
}
if got := v.(int64); got != 49 {
t.Fatalf("run() = %d, want 49", got)
}
}
// An external dependency must stay as a require("external") call in the
// output rather than being inlined (it is not in the files map).
func TestBundleToES2015_ExternalStaysRequire(t *testing.T) {
files := map[string][]byte{
"entry.js": []byte(`
const ext = require("external");
module.exports.run = function () { return ext.value; };
`),
}
out, err := BundleToES2015("entry.js", files, BundleOptions{
Format: "cjs",
External: []string{"external"},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(out), `require("external")`) {
t.Fatalf("external not preserved as require: %s", out)
}
t.Logf(`external preserved: output contains require("external")`)
// And it must actually resolve through goja's require() to the host
// module registered under the same name.
rt, _ := NewJSRuntime(JSOptions{
PoolSize: 1,
Modules: map[string]string{"external": `module.exports = { value: 99 };`},
})
if err := rt.LoadModule("bundle", string(out)); err != nil {
t.Fatal(err)
}
v, err := rt.Eval(`require("bundle").run()`)
if err != nil {
t.Fatal(err)
}
if got := v.(int64); got != 99 {
t.Fatalf("run() = %d, want 99 (from external module)", got)
}
}
// A missing entry is a clear error, not a panic.
func TestBundleToES2015_MissingEntry(t *testing.T) {
_, err := BundleToES2015("nope.js", map[string][]byte{"a.js": []byte("1")}, BundleOptions{})
if err == nil {
t.Fatal("expected error for missing entry, got nil")
}
}
// An unresolved relative import is reported as an error.
func TestBundleToES2015_UnresolvedImport(t *testing.T) {
files := map[string][]byte{
"entry.js": []byte(`require("./missing.js");`),
}
_, err := BundleToES2015("entry.js", files, BundleOptions{})
if err == nil {
t.Fatal("expected error for unresolved import, got nil")
}
}
+253
View File
@@ -0,0 +1,253 @@
// esbuild.go wraps esbuild's pure-Go API (no CGO) so zip can compile
// TS / modern-JS handler source down to ES5 that the embedded goja VM
// executes. This is the build step of zip's TS migration path:
//
// TS source --esbuild target=es5--> ES5 JS --drop into--> embedded goja
//
// Run TranspileToES5 at service startup (or at build time) to compile
// bundled handlers before serving; the resulting bytes are handed to
// JSRuntime.LoadModule / Eval.
package runtime
import (
"fmt"
"path"
"strings"
"github.com/evanw/esbuild/pkg/api"
)
// ESOptions configures a transpile. Zero value is a sane default:
// TypeScript loader, ES5 target, no minification, CommonJS format so
// `module.exports = ...` survives into the goja-loadable output.
type ESOptions struct {
// Loader selects how the source is parsed. "ts", "tsx", "jsx", or
// "js". Empty defaults to "ts" (TS is a superset of JS, so plain JS
// also parses).
Loader string
// Minify enables identifier/whitespace minification.
Minify bool
// Sourcefile is the logical filename used in error messages.
Sourcefile string
}
// TranspileToES5 compiles src (TS or modern JS) to ES5 JavaScript ready
// for goja. The output is CommonJS-format so a module that does
// `module.exports = handler` can be loaded via JSRuntime.LoadModule and
// resolved with require(). Returns a non-nil error if esbuild reports
// any error-level diagnostic.
func TranspileToES5(src []byte, opts ESOptions) ([]byte, error) {
loader := loaderFor(opts.Loader)
sourcefile := opts.Sourcefile
if sourcefile == "" {
sourcefile = "handler" + extFor(loader)
}
result := api.Transform(string(src), api.TransformOptions{
Loader: loader,
Target: api.ES2015, // ES2015 is the lowest target esbuild emits; goja runs it.
Format: api.FormatCommonJS,
Sourcefile: sourcefile,
MinifyWhitespace: opts.Minify,
MinifyIdentifiers: opts.Minify,
MinifySyntax: opts.Minify,
})
if len(result.Errors) > 0 {
return nil, fmt.Errorf("zip/runtime: esbuild: %s", formatMessages(result.Errors))
}
return result.Code, nil
}
// BundleOptions configures a multi-file bundle. Zero value bundles to
// CommonJS (the format goja's require() understands) with no source map
// and no minification.
type BundleOptions struct {
// Format is the output module format: "iife", "cjs", or "esm". Empty
// defaults to "cjs", the format goja loads via require().
Format string
// Sourcemap inlines a base64 source map into the output for debugging.
Sourcemap bool
// Minify enables identifier/whitespace/syntax minification.
Minify bool
// External lists import paths (typically bare package specifiers like
// "external" or "node:fs") that are left as require()/import calls in
// the output instead of being bundled. Resolution of these is the
// host's responsibility (e.g. a goja module registered under the same
// name).
External []string
}
// virtualNamespace tags resolved paths that live in the in-memory files
// map, so onLoad only fires for them and never touches the real FS.
const virtualNamespace = "zip-virtual"
// BundleToES2015 transpiles and bundles a multi-file TS/JS tree rooted at
// entry into a single ES2015 string ready for goja. files maps virtual
// paths (e.g. "entry.js", "lib/util.ts") to their source bytes; entry is
// the key of the root module. Relative imports between files are resolved
// against the importer's directory within the files map — no real
// filesystem access occurs. Packages listed in opts.External are left as
// require()/import calls. Returns a non-nil error if esbuild reports any
// error-level diagnostic (including an unresolved import).
func BundleToES2015(entry string, files map[string][]byte, opts BundleOptions) ([]byte, error) {
if _, ok := files[entry]; !ok {
return nil, fmt.Errorf("zip/runtime: bundle entry %q not in files map", entry)
}
entry = path.Clean(entry)
external := map[string]struct{}{}
for _, e := range opts.External {
external[e] = struct{}{}
}
plugin := api.Plugin{
Name: "zip-virtual-fs",
Setup: func(build api.PluginBuild) {
// Resolve: bare specifiers in External stay external; every
// other import is mapped into the virtual namespace, with
// relative paths joined against the importer's directory.
build.OnResolve(api.OnResolveOptions{Filter: ".*"}, func(args api.OnResolveArgs) (api.OnResolveResult, error) {
if _, ok := external[args.Path]; ok {
return api.OnResolveResult{External: true}, nil
}
p := args.Path
if strings.HasPrefix(p, "./") || strings.HasPrefix(p, "../") {
p = path.Join(path.Dir(args.Importer), p)
}
p = path.Clean(p)
resolved, ok := resolveVirtual(p, files)
if !ok {
return api.OnResolveResult{}, fmt.Errorf("cannot resolve import %q from %q", args.Path, args.Importer)
}
return api.OnResolveResult{Path: resolved, Namespace: virtualNamespace}, nil
})
// Load: serve bytes from the files map, picking a loader from
// the file extension.
build.OnLoad(api.OnLoadOptions{Filter: ".*", Namespace: virtualNamespace}, func(args api.OnLoadArgs) (api.OnLoadResult, error) {
src, ok := files[args.Path]
if !ok {
return api.OnLoadResult{}, fmt.Errorf("virtual file %q disappeared", args.Path)
}
s := string(src)
loader := loaderForPath(args.Path)
return api.OnLoadResult{Contents: &s, Loader: loader}, nil
})
},
}
sourcemap := api.SourceMapNone
if opts.Sourcemap {
sourcemap = api.SourceMapInline
}
result := api.Build(api.BuildOptions{
EntryPointsAdvanced: []api.EntryPoint{{InputPath: entry}},
Bundle: true,
Write: false,
Format: bundleFormat(opts.Format),
Target: api.ES2015,
Sourcemap: sourcemap,
MinifyWhitespace: opts.Minify,
MinifyIdentifiers: opts.Minify,
MinifySyntax: opts.Minify,
Plugins: []api.Plugin{plugin},
})
if len(result.Errors) > 0 {
return nil, fmt.Errorf("zip/runtime: esbuild bundle: %s", formatMessages(result.Errors))
}
if len(result.OutputFiles) == 0 {
return nil, fmt.Errorf("zip/runtime: esbuild bundle produced no output")
}
return result.OutputFiles[0].Contents, nil
}
// resolveVirtual finds p in files, trying p as-is then with the common
// JS/TS extensions appended (so `./util` resolves to `util.ts` etc.).
func resolveVirtual(p string, files map[string][]byte) (string, bool) {
if _, ok := files[p]; ok {
return p, true
}
for _, ext := range []string{".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"} {
if _, ok := files[p+ext]; ok {
return p + ext, true
}
}
return "", false
}
// loaderForPath selects an esbuild loader from a virtual path's extension.
func loaderForPath(p string) api.Loader {
switch path.Ext(p) {
case ".ts":
return api.LoaderTS
case ".tsx":
return api.LoaderTSX
case ".jsx":
return api.LoaderJSX
case ".json":
return api.LoaderJSON
default:
return api.LoaderJS
}
}
// bundleFormat maps a BundleOptions.Format string to an esbuild format,
// defaulting to CommonJS (goja's require() format).
func bundleFormat(name string) api.Format {
switch name {
case "iife":
return api.FormatIIFE
case "esm":
return api.FormatESModule
case "", "cjs":
return api.FormatCommonJS
default:
return api.FormatCommonJS
}
}
func loaderFor(name string) api.Loader {
switch name {
case "", "ts":
return api.LoaderTS
case "tsx":
return api.LoaderTSX
case "jsx":
return api.LoaderJSX
case "js":
return api.LoaderJS
default:
return api.LoaderTS
}
}
func extFor(l api.Loader) string {
switch l {
case api.LoaderTSX:
return ".tsx"
case api.LoaderJSX, api.LoaderJS:
return ".js"
default:
return ".ts"
}
}
func formatMessages(msgs []api.Message) string {
out := ""
for i, m := range msgs {
if i > 0 {
out += "; "
}
out += m.Text
if m.Location != nil {
out += fmt.Sprintf(" (%s:%d:%d)", m.Location.File, m.Location.Line, m.Location.Column)
}
}
return out
}
+41
View File
@@ -0,0 +1,41 @@
package runtime
import (
"strings"
"testing"
)
func TestTranspileToES5_RunsInGoja(t *testing.T) {
// TS with arrow fn + spread + type annotation. esbuild lowers it to
// goja-runnable JS and strips the types.
src := []byte(`
const add = (...xs: number[]): number => xs.reduce((a, b) => a + b, 0);
module.exports = { total: add(1, 2, 3, 4) };
`)
out, err := TranspileToES5(src, ESOptions{Loader: "ts", Sourcefile: "add.ts"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), ": number") {
t.Fatalf("types not stripped: %s", out)
}
rt, _ := NewJSRuntime(JSOptions{PoolSize: 1})
if err := rt.LoadModule("add", string(out)); err != nil {
t.Fatal(err)
}
v, err := rt.Eval(`require("add").total`)
if err != nil {
t.Fatal(err)
}
if got := v.(int64); got != 10 {
t.Fatalf("total = %d, want 10", got)
}
}
func TestTranspileError(t *testing.T) {
_, err := TranspileToES5([]byte("const x = ;"), ESOptions{Loader: "ts"})
if err == nil {
t.Fatal("expected syntax error, got nil")
}
}
+215
View File
@@ -0,0 +1,215 @@
// handler.go bridges a goja-loaded JS function into Fiber. It returns
// fiber.Handler (not zip.Handler) on purpose: the zip root package
// imports this package, so depending back on zip would be an import
// cycle. fiber.Handler mounts cleanly anywhere — app.Fiber().Get(...)
// or any *fiber.App — and is the natural seam for legacy Express-shaped
// handlers running inside the embedded VM.
//
// The req/res objects handed to JS mirror the Express shape so existing
// `function (req, res) { res.json(...) }` handlers run unmodified:
//
// req.method req.path req.query req.headers req.body
// res.status(n) res.set(k,v) res.json(v) res.send(v)
package runtime
import (
"fmt"
"github.com/dop251/goja"
"github.com/gofiber/fiber/v3"
)
// jsResponse captures what the JS handler wrote, so we can replay it onto
// the Fiber Ctx after the VM call returns. goja runs single-threaded per
// borrowed VM, so a fresh jsResponse per request is race-free.
type jsResponse struct {
status int
headers map[string]string
body []byte
isJSON bool
written bool
}
// JSHandler returns a fiber.Handler that invokes the global JS function
// named fnName (defined in the runtime via Eval / LoadModule) with an
// Express-shaped (req, res) pair. State written through res is propagated
// back onto the Fiber Ctx after the call.
func JSHandler(rt *JSRuntime, fnName string) fiber.Handler {
return func(fc fiber.Ctx) error {
return rt.withVM(func(vm *goja.Runtime) error {
fnVal := vm.Get(fnName)
fn, ok := goja.AssertFunction(fnVal)
if !ok {
return fmt.Errorf("zip/runtime: %q is not a callable JS function", fnName)
}
return invokeJS(fc, vm, fn)
})
}
}
// JSModule loads a CommonJS module whose `module.exports` is the handler
// function (Express shape) and returns a fiber.Handler for it. modulePath
// is the name the module was registered under via LoadModule.
func JSModule(rt *JSRuntime, modulePath string) (fiber.Handler, error) {
// Resolve once up front so a missing/invalid module fails at mount
// time, not on the first request.
var resolved goja.Callable
err := rt.withVM(func(vm *goja.Runtime) error {
exports, err := requireModule(vm, modulePath)
if err != nil {
return err
}
if _, ok := goja.AssertFunction(exports); !ok {
return fmt.Errorf("zip/runtime: module %q exports is not a function", modulePath)
}
return nil
})
if err != nil {
return nil, err
}
_ = resolved // resolution is per-VM; re-resolved inside the handler.
return func(fc fiber.Ctx) error {
return rt.withVM(func(vm *goja.Runtime) error {
exports, err := requireModule(vm, modulePath)
if err != nil {
return err
}
fn, ok := goja.AssertFunction(exports)
if !ok {
return fmt.Errorf("zip/runtime: module %q exports is not a function", modulePath)
}
return invokeJS(fc, vm, fn)
})
}, nil
}
// invokeJS builds the req/res objects, calls fn(req, res), then replays
// the captured response onto the Fiber Ctx.
func invokeJS(fc fiber.Ctx, vm *goja.Runtime, fn goja.Callable) error {
resp := &jsResponse{status: 200, headers: map[string]string{}}
req := buildReq(vm, fc)
res := buildRes(vm, resp)
if _, err := fn(goja.Undefined(), req, res); err != nil {
return err
}
return replay(fc, resp)
}
// buildReq materialises the Express-shaped req object from the Fiber Ctx.
func buildReq(vm *goja.Runtime, fc fiber.Ctx) *goja.Object {
req := vm.NewObject()
_ = req.Set("method", fc.Method())
_ = req.Set("path", fc.Path())
_ = req.Set("url", fc.OriginalURL())
// query: flat map of first values.
query := vm.NewObject()
for k, v := range fc.Queries() {
_ = query.Set(k, v)
}
_ = req.Set("query", query)
// headers: lowercased keys, Express convention.
headers := vm.NewObject()
for k, v := range fc.GetReqHeaders() {
if len(v) > 0 {
_ = headers.Set(k, v[0])
}
}
_ = req.Set("headers", headers)
// body: parsed JSON when Content-Type is JSON and the body is
// non-empty, otherwise null. Matches Express + body-parser.
body := fc.Body()
if len(body) > 0 && isJSONContentType(fc.Get(fiber.HeaderContentType)) {
var parsed any
if err := jsonUnmarshal(body, &parsed); err == nil {
_ = req.Set("body", vm.ToValue(parsed))
} else {
_ = req.Set("body", goja.Null())
}
} else {
_ = req.Set("body", goja.Null())
}
return req
}
// buildRes materialises the Express-shaped res object backed by resp.
func buildRes(vm *goja.Runtime, resp *jsResponse) *goja.Object {
res := vm.NewObject()
// res.status(n) -> res (chainable)
_ = res.Set("status", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
resp.status = int(call.Argument(0).ToInteger())
}
return res
})
// res.set(k, v) -> res (chainable)
_ = res.Set("set", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) >= 2 {
resp.headers[call.Argument(0).String()] = call.Argument(1).String()
}
return res
})
// res.json(v) — serialize v as JSON, set Content-Type.
_ = res.Set("json", func(call goja.FunctionCall) goja.Value {
var v any
if len(call.Arguments) > 0 {
v = call.Argument(0).Export()
}
b, err := jsonMarshal(v)
if err != nil {
panic(vm.NewGoError(err))
}
resp.body = b
resp.isJSON = true
resp.written = true
return res
})
// res.send(v) — string/bytes body, no forced Content-Type.
_ = res.Set("send", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
arg := call.Argument(0)
if b, ok := arg.Export().([]byte); ok {
resp.body = b
} else {
resp.body = []byte(arg.String())
}
}
resp.written = true
return res
})
return res
}
// replay writes the captured JS response onto the Fiber Ctx.
func replay(fc fiber.Ctx, resp *jsResponse) error {
for k, v := range resp.headers {
fc.Set(k, v)
}
fc.Status(resp.status)
if resp.isJSON {
fc.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON)
}
if !resp.written || len(resp.body) == 0 {
return nil
}
return fc.Send(resp.body)
}
func isJSONContentType(ct string) bool {
for i := 0; i+4 <= len(ct); i++ {
if ct[i:i+4] == "json" {
return true
}
}
return false
}
+91
View File
@@ -0,0 +1,91 @@
package runtime
import (
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v3"
)
// TestJSHandler_EndToEnd: an Express-shaped handler defined as a global
// JS function, mounted via JSHandler, exercised over a real HTTP
// roundtrip through Fiber.
func TestJSHandler_EndToEnd(t *testing.T) {
rt, _ := NewJSRuntime(JSOptions{PoolSize: 2})
_, err := rt.Eval(`
function handler(req, res) {
res.status(201).set("X-From", "goja").json({
ok: true,
method: req.method,
path: req.path,
body: req.body,
});
}
`)
if err != nil {
t.Fatal(err)
}
app := fiber.New()
app.Post("/echo", JSHandler(rt, "handler"))
req := httptest.NewRequest("POST", "/echo", strings.NewReader(`{"x":1}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 201 {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
if got := resp.Header.Get("X-From"); got != "goja" {
t.Fatalf("X-From = %q, want goja", got)
}
body, _ := io.ReadAll(resp.Body)
bs := string(body)
for _, want := range []string{`"ok":true`, `"method":"POST"`, `"path":"/echo"`, `"x":1`} {
if !strings.Contains(bs, want) {
t.Fatalf("body %q missing %q", bs, want)
}
}
}
// TestJSModule_EndToEnd: a CommonJS module whose exports IS the handler,
// loaded from esbuild-transpiled TS, mounted via JSModule.
func TestJSModule_EndToEnd(t *testing.T) {
rt, _ := NewJSRuntime(JSOptions{PoolSize: 1})
tsSrc := []byte(`
module.exports = function (req: any, res: any) {
res.json({ ok: true, path: req.path });
};
`)
es5, err := TranspileToES5(tsSrc, ESOptions{Loader: "ts"})
if err != nil {
t.Fatal(err)
}
if err := rt.LoadModule("app", string(es5)); err != nil {
t.Fatal(err)
}
h, err := JSModule(rt, "app")
if err != nil {
t.Fatal(err)
}
app := fiber.New()
app.Get("/m", h)
resp, err := app.Test(httptest.NewRequest("GET", "/m", nil))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `"path":"/m"`) {
t.Fatalf("body %q missing path", body)
}
}
+10
View File
@@ -0,0 +1,10 @@
package runtime
import "github.com/zap-proto/zip/internal/jsonenc"
// jsonMarshal / jsonUnmarshal route through zip's jsonenc package so the
// embedded-JS bridge uses the same JSON impl as the edge (encoding/json,
// or encoding/json/v2 under GOEXPERIMENT=jsonv2) — one wire format, one
// way to encode it.
func jsonMarshal(v any) ([]byte, error) { return jsonenc.Marshal(v) }
func jsonUnmarshal(b []byte, v any) error { return jsonenc.Unmarshal(b, v) }
+279
View File
@@ -0,0 +1,279 @@
// jsvm.go embeds a pure-Go JavaScript runtime (goja) into zip so a
// service can run TS/JS handlers in-process — no separate runtime
// service, no inter-service RPC, no container-per-service. Combined
// with esbuild.go (TS/modern-JS → ES5) this is zip's migration path:
// legacy TS source compiles to ES5 and drops straight into the embedded
// VM, then gets rewritten to a native Go handler in place over time.
//
// The pool pattern is lifted from hanzoai/base/plugins/gojavm and
// slimmed to what zip needs: each *goja.Runtime carries the host
// functions and modules registered at construction time, and requests
// borrow a free VM (or a one-off when the pool is saturated) so they
// never pay per-request VM creation cost.
package runtime
import (
"context"
"errors"
"fmt"
"sync"
"github.com/dop251/goja"
)
// JSOptions configures a JSRuntime.
type JSOptions struct {
// PoolSize is the number of pre-warmed *goja.Runtime kept hot. When
// every pooled VM is busy, calls fall back to a freshly-built VM that
// is discarded after the call. 0 selects a default of 8.
PoolSize int
// HostFns are Go functions exposed to JS as globals. Applied to every
// VM in the pool (and to one-off VMs). Equivalent to calling
// RegisterHostFn for each entry after construction, but applied to
// the whole pool atomically at build time.
HostFns map[string]any
// Modules are CommonJS-style module sources registered into every VM,
// reachable from JS via require(name). Applied at build time.
Modules map[string]string
}
// JSRuntime is an embedded JavaScript runtime backed by a pool of goja
// VMs. It is safe for concurrent use: each call borrows an isolated VM.
type JSRuntime struct {
pool *vmPool
mu sync.Mutex // guards the registration sets below
hostFns map[string]any
modules map[string]string
}
// NewJSRuntime builds a JSRuntime with the given options. Host functions
// and modules from opts are applied to every VM in the pool.
func NewJSRuntime(opts JSOptions) (*JSRuntime, error) {
size := opts.PoolSize
if size <= 0 {
size = 8
}
rt := &JSRuntime{
hostFns: map[string]any{},
modules: map[string]string{},
}
for k, v := range opts.HostFns {
rt.hostFns[k] = v
}
for k, v := range opts.Modules {
rt.modules[k] = v
}
var buildErr error
rt.pool = newVMPool(size, func() *goja.Runtime {
vm := newVM()
if err := rt.provision(vm); err != nil && buildErr == nil {
buildErr = err
}
return vm
})
if buildErr != nil {
return nil, buildErr
}
return rt, nil
}
// provision installs the registered host functions and modules into one
// VM. Called for every pooled VM at build time and for one-off VMs.
func (rt *JSRuntime) provision(vm *goja.Runtime) error {
rt.mu.Lock()
defer rt.mu.Unlock()
for name, fn := range rt.hostFns {
if err := vm.Set(name, fn); err != nil {
return fmt.Errorf("zip/runtime: register host fn %q: %w", name, err)
}
}
if err := installRequire(vm, rt.modules); err != nil {
return err
}
return nil
}
// Eval evaluates src in a pooled VM and returns the exported Go value of
// the result (via goja's Export()). It is EvalContext with a background
// context — the evaluation cannot be cancelled.
func (rt *JSRuntime) Eval(src string) (any, error) {
return rt.EvalContext(context.Background(), src)
}
// EvalContext evaluates src in a pooled VM under ctx and returns the
// exported Go value of the result. If ctx is cancelled or its deadline is
// exceeded before the evaluation completes, the running goja VM is
// interrupted and EvalContext returns ctx.Err() (context.Canceled or
// context.DeadlineExceeded). goja is single-threaded per borrowed VM, so
// a tight loop like `while(true){}` is preempted at the next interrupt
// check point rather than blocking the caller indefinitely.
func (rt *JSRuntime) EvalContext(ctx context.Context, src string) (any, error) {
var out any
err := rt.pool.run(func(vm *goja.Runtime) error {
v, err := runUnderCtx(ctx, vm, func() (goja.Value, error) {
return vm.RunString(src)
})
if err != nil {
return err
}
if v != nil {
out = v.Export()
}
return nil
})
return out, err
}
// runUnderCtx runs fn (a goja evaluation on vm) while a watcher goroutine
// interrupts vm if ctx finishes first. It always tears the watcher down
// and clears any pending interrupt before returning, so the VM is clean
// for the next borrower. When the interrupt fired, the goja
// *InterruptedError carrying ctx.Err() is unwrapped back to that error.
func runUnderCtx(ctx context.Context, vm *goja.Runtime, fn func() (goja.Value, error)) (goja.Value, error) {
// Fast path: no cancellation possible, skip the watcher goroutine.
if ctx.Done() == nil {
return fn()
}
// Already cancelled before we start: don't even enter the VM.
if err := ctx.Err(); err != nil {
return nil, err
}
done := make(chan struct{})
watcherDone := make(chan struct{})
go func() {
defer close(watcherDone)
select {
case <-ctx.Done():
vm.Interrupt(ctx.Err())
case <-done:
}
}()
v, err := fn()
close(done) // signal the watcher to stop
<-watcherDone // wait until it can no longer call Interrupt
vm.ClearInterrupt() // drop any interrupt it set, clean for reuse
if err != nil {
var ie *goja.InterruptedError
if errors.As(err, &ie) {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
if v, ok := ie.Value().(error); ok {
return nil, v
}
}
return nil, err
}
return v, nil
}
// InvokeFunc calls the global JS function named fnName with args (each
// converted to a goja value) and returns the exported Go value of its
// result. Like EvalContext, the call is interrupted and ctx.Err() is
// returned if ctx finishes before the function does. The named value must
// already be defined in the runtime (via Eval/EvalContext or LoadModule).
func (rt *JSRuntime) InvokeFunc(ctx context.Context, fnName string, args ...any) (any, error) {
var out any
err := rt.pool.run(func(vm *goja.Runtime) error {
fn, ok := goja.AssertFunction(vm.Get(fnName))
if !ok {
return fmt.Errorf("zip/runtime: %q is not a callable JS function", fnName)
}
argv := make([]goja.Value, len(args))
for i, a := range args {
argv[i] = vm.ToValue(a)
}
v, err := runUnderCtx(ctx, vm, func() (goja.Value, error) {
return fn(goja.Undefined(), argv...)
})
if err != nil {
return err
}
if v != nil {
out = v.Export()
}
return nil
})
return out, err
}
// RegisterHostFn exposes a Go function to JS as a global named name. The
// function is applied to every VM in the pool and to one-off VMs built
// afterwards. fn may be any value goja can bind (typically a Go func).
func (rt *JSRuntime) RegisterHostFn(name string, fn any) error {
rt.mu.Lock()
rt.hostFns[name] = fn
rt.mu.Unlock()
return rt.pool.forEach(func(vm *goja.Runtime) error {
if err := vm.Set(name, fn); err != nil {
return fmt.Errorf("zip/runtime: register host fn %q: %w", name, err)
}
return nil
})
}
// LoadModule registers a CommonJS-style module under name. The module
// source is evaluated lazily the first time require(name) is called in a
// given VM. Applied to every pooled VM.
func (rt *JSRuntime) LoadModule(name, src string) error {
rt.mu.Lock()
rt.modules[name] = src
rt.mu.Unlock()
return rt.pool.forEach(func(vm *goja.Runtime) error {
return registerModule(vm, name, src)
})
}
// withVM borrows a VM from the pool for the duration of fn. Used by
// handler.go to invoke a loaded JS function with a request/response pair.
func (rt *JSRuntime) withVM(fn func(vm *goja.Runtime) error) error {
return rt.pool.run(fn)
}
// Engine adapts this JSRuntime to the Runner's Engine interface so it can
// be registered as a language backend:
//
// runner.Register("js", rt.Engine())
//
// The adapter is deliberately NOT Interruptible at the Runner seam:
// EvalContext already owns the per-call interrupt watcher and targets the
// exact pooled VM the call borrowed (interrupting only that VM, never a
// sibling concurrent call's VM). A Runner-level Interrupt would have to
// fan out to every pooled VM and could abort unrelated concurrent calls.
// So the goja engine self-manages cancellation inside Eval; the Runner's
// watcher is a no-op for it (and the registration warning is suppressed
// because Eval honors ctx fully). Engines without internal ctx handling
// implement Interruptible and let the Runner's watcher abort them.
func (rt *JSRuntime) Engine() Engine { return jsEngine{rt} }
// jsEngine is the goja Engine. Eval delegates to EvalContext, which runs
// the pool + joined-watcher path and fully honors ctx on the correct
// borrowed VM. args are ignored: a top-level expression's value is the
// result (use a trailing identifier to "return" a defined function).
type jsEngine struct{ rt *JSRuntime }
func (e jsEngine) Eval(ctx context.Context, src []byte, _ ...any) (any, error) {
return e.rt.EvalContext(ctx, string(src))
}
// ctxHonored marks an Engine that fully manages ctx cancellation inside
// Eval, so the Runner skips the "not interruptible" warning and its
// watcher is a harmless no-op. jsEngine qualifies via EvalContext.
func (jsEngine) ctxHonored() {}
// newVM constructs a bare goja runtime with zip's field-name mapping
// (Go struct fields exported with their JSON tag names where present, so
// host objects look idiomatic from JS).
func newVM() *goja.Runtime {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
return vm
}
+101
View File
@@ -0,0 +1,101 @@
package runtime
import (
"context"
"errors"
"testing"
"time"
)
// EvalContext must interrupt a tight infinite loop when the context
// deadline fires, returning context.DeadlineExceeded promptly rather than
// blocking forever.
func TestEvalContext_DeadlineInterruptsInfiniteLoop(t *testing.T) {
rt, err := NewJSRuntime(JSOptions{PoolSize: 1})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
_, err = rt.EvalContext(ctx, "while(true){}")
elapsed := time.Since(start)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want context.DeadlineExceeded", err)
}
if elapsed > 2*time.Second {
t.Fatalf("interrupt took %v, want ~50ms (well under the loop timeout)", elapsed)
}
t.Logf("infinite loop interrupted after %v", elapsed)
}
// A successful evaluation under a live (uncancelled) context returns the
// value, not a context error, and leaves the VM reusable afterwards.
func TestEvalContext_SuccessReturnsValue(t *testing.T) {
rt, _ := NewJSRuntime(JSOptions{PoolSize: 1})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
v, err := rt.EvalContext(ctx, "40 + 2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, ok := v.(int64); !ok || got != 42 {
t.Fatalf("EvalContext = %v (%T), want int64(42)", v, v)
}
// VM must be clean for reuse (no lingering interrupt) — run again.
v2, err := rt.EvalContext(ctx, `"ok"`)
if err != nil {
t.Fatalf("reuse failed: %v", err)
}
if v2 != "ok" {
t.Fatalf("reuse = %v, want ok", v2)
}
}
// An already-cancelled context returns its error without entering the VM.
func TestEvalContext_AlreadyCancelled(t *testing.T) {
rt, _ := NewJSRuntime(JSOptions{PoolSize: 1})
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := rt.EvalContext(ctx, "1 + 1")
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
}
// InvokeFunc calls a named global JS function with Go args under a
// context, and interrupts it on deadline just like EvalContext.
func TestInvokeFunc_CallsAndInterrupts(t *testing.T) {
rt, _ := NewJSRuntime(JSOptions{PoolSize: 1})
if _, err := rt.Eval(`var addPair = function(a, b){ return a + b }`); err != nil {
t.Fatal(err)
}
v, err := rt.InvokeFunc(context.Background(), "addPair", 19, 23)
if err != nil {
t.Fatal(err)
}
if got := v.(int64); got != 42 {
t.Fatalf("addPair(19,23) = %d, want 42", got)
}
if _, err := rt.Eval(`var spin = function(){ while(true){} }`); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
_, err = rt.InvokeFunc(ctx, "spin")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("InvokeFunc(spin) err = %v, want DeadlineExceeded", err)
}
t.Logf("InvokeFunc(spin) interrupted after %v", time.Since(start))
}
+73
View File
@@ -0,0 +1,73 @@
package runtime
import (
"sync"
"github.com/dop251/goja"
)
// poolItem is one pre-warmed VM with a busy flag.
type poolItem struct {
mu sync.Mutex
busy bool
vm *goja.Runtime
}
// vmPool is a fixed-size pool of goja VMs. Lifted from
// hanzoai/base/plugins/gojavm and slimmed: zip needs run (borrow one)
// and forEach (apply a mutation to every pooled VM under its own lock so
// late RegisterHostFn / LoadModule reach already-warm VMs).
type vmPool struct {
factory func() *goja.Runtime
items []*poolItem
}
func newVMPool(size int, factory func() *goja.Runtime) *vmPool {
p := &vmPool{factory: factory}
if size > 0 {
p.items = make([]*poolItem, size)
for i := 0; i < size; i++ {
p.items[i] = &poolItem{vm: factory()}
}
}
return p
}
// run executes call with a pooled VM. If every slot is busy it builds a
// one-off VM (fully provisioned via factory) and discards it after the
// call. goja VMs are single-threaded, so a borrowed VM is never touched
// concurrently.
func (p *vmPool) run(call func(vm *goja.Runtime) error) error {
for _, item := range p.items {
item.mu.Lock()
if item.busy {
item.mu.Unlock()
continue
}
item.busy = true
item.mu.Unlock()
err := call(item.vm)
item.mu.Lock()
item.busy = false
item.mu.Unlock()
return err
}
// Pool saturated: one-off VM, factory provisions host fns + modules.
return call(p.factory())
}
// forEach applies mutate to every pooled VM, each under its own lock so
// it cannot race a concurrent run() on the same VM.
func (p *vmPool) forEach(mutate func(vm *goja.Runtime) error) error {
for _, item := range p.items {
item.mu.Lock()
err := mutate(item.vm)
item.mu.Unlock()
if err != nil {
return err
}
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package runtime
import (
"fmt"
"sync"
"github.com/dop251/goja"
)
// CommonJS-style module loading without an external dependency. Each VM
// gets a require(name) global backed by a per-VM registry of module
// sources and a memoized cache of evaluated module.exports. A module is
// evaluated the first time it is required, wrapped in the canonical
// CommonJS function scope:
//
// (function (module, exports, require) { <src> })
//
// so module source can do `module.exports = ...` or `exports.foo = ...`.
const requireRegistryKey = "__zip_modules__"
const requireCacheKey = "__zip_module_cache__"
// vmModules holds a VM's module sources and evaluated-exports cache. It
// lives in a Go-side side-table keyed by the *goja.Runtime pointer so it
// is never visible to (or mutable from) JS.
type vmModules struct {
mu sync.Mutex
sources map[string]string
cache map[string]goja.Value
}
var (
moduleTablesMu sync.Mutex
moduleTables = map[*goja.Runtime]*vmModules{}
)
func tableFor(vm *goja.Runtime) *vmModules {
moduleTablesMu.Lock()
defer moduleTablesMu.Unlock()
t, ok := moduleTables[vm]
if !ok {
t = &vmModules{sources: map[string]string{}, cache: map[string]goja.Value{}}
moduleTables[vm] = t
}
return t
}
// installRequire registers the require() global on vm and seeds it with
// the given module sources.
func installRequire(vm *goja.Runtime, modules map[string]string) error {
t := tableFor(vm)
t.mu.Lock()
for name, src := range modules {
t.sources[name] = src
}
t.mu.Unlock()
return vm.Set("require", func(name string) (goja.Value, error) {
return requireModule(vm, name)
})
}
// registerModule adds (or replaces) one module source on vm and
// invalidates any cached evaluation of it.
func registerModule(vm *goja.Runtime, name, src string) error {
t := tableFor(vm)
t.mu.Lock()
t.sources[name] = src
delete(t.cache, name)
t.mu.Unlock()
// Ensure require() exists even if the VM was built with no modules.
if v := vm.Get("require"); v == nil || goja.IsUndefined(v) {
return vm.Set("require", func(name string) (goja.Value, error) {
return requireModule(vm, name)
})
}
return nil
}
// requireModule resolves name to its module.exports, evaluating the
// source on first use and memoizing the result.
func requireModule(vm *goja.Runtime, name string) (goja.Value, error) {
t := tableFor(vm)
t.mu.Lock()
if cached, ok := t.cache[name]; ok {
t.mu.Unlock()
return cached, nil
}
src, ok := t.sources[name]
t.mu.Unlock()
if !ok {
return nil, fmt.Errorf("zip/runtime: module not found: %q", name)
}
// Wrap in the CommonJS scope and evaluate.
wrapped := "(function(module, exports, require){" + src + "\n})"
fnVal, err := vm.RunString(wrapped)
if err != nil {
return nil, fmt.Errorf("zip/runtime: load module %q: %w", name, err)
}
fn, ok := goja.AssertFunction(fnVal)
if !ok {
return nil, fmt.Errorf("zip/runtime: module %q did not compile to a function", name)
}
module := vm.NewObject()
exports := vm.NewObject()
_ = module.Set("exports", exports)
requireFn := vm.Get("require")
if _, err := fn(goja.Undefined(), module, exports, requireFn); err != nil {
return nil, fmt.Errorf("zip/runtime: eval module %q: %w", name, err)
}
result := module.Get("exports")
t.mu.Lock()
t.cache[name] = result
t.mu.Unlock()
return result, nil
}
+181
View File
@@ -0,0 +1,181 @@
// runner.go is zip's unified multi-language code runner. A Runner holds a
// registry of language → Engine and executes a source string against the
// engine registered for a language, each call in its own goroutine, bound
// to a context.Context. It generalizes the goroutine-scoped determinism
// contract of JSRuntime.EvalContext (one worker, one watcher, joined
// before return, engine-specific interrupt on cancel) across every
// language backend the host registers.
//
// DEPENDENCY DIRECTION. zip does NOT import hanzoai/base. The Engine
// interface here is a zip-side projection — duck-typed, exactly like the
// Loader/Module projection in package internal/runtime. base's backends
// (gojavm, pyvm, v8vm, wasmvm, starkvm) implement an extruntime.Runtime
// SPI; a thin adapter at the host's app-startup makes each satisfy this
// Engine and registers it. base imports zip and registers; zip exposes
// the registry and never names base. That is the whole point of the SPI:
// the registry is the seam, not an import edge.
//
// The one in-tree Engine zip ships is the goja engine — zip's own
// *JSRuntime, surfaced via (*JSRuntime).Engine(). It is pure Go, no cgo,
// and is the canonical "js" backend. Everything else plugs in from base.
package runtime
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"sync"
)
// ErrUnknownLanguage is returned by Run when no engine is registered for
// the requested language.
var ErrUnknownLanguage = errors.New("zip/runtime: unknown language")
// Engine executes source in one language. It is the zip-side projection
// of base's extruntime backends; any value implementing Eval satisfies
// it, so base registers its backends without zip importing base.
//
// An Engine MAY additionally implement Interruptible. When it does, the
// Runner's watcher goroutine calls Interrupt(ctx.Err()) on cancellation
// so a runaway evaluation unwinds instead of leaking. When it does not,
// the watcher cannot abort a running call — the Run still returns
// ctx.Err() promptly, but the underlying worker runs to completion. The
// Runner logs a one-time warning per such engine at registration.
type Engine interface {
// Eval evaluates src and returns the engine-native result value
// (already converted to a Go value, e.g. goja's Export()). args are
// passed through to engines that accept invocation arguments;
// engines that only evaluate a top-level expression ignore them.
//
// Eval MUST respect ctx: at minimum it returns promptly once ctx is
// done. Interruptible engines additionally honor Interrupt.
Eval(ctx context.Context, src []byte, args ...any) (any, error)
}
// Interruptible is implemented by engines whose running evaluation can be
// aborted out-of-band (goja vm.Interrupt, pyvm PyErr_SetInterrupt, wasm
// trap). The Runner's watcher calls Interrupt with ctx.Err() on cancel.
type Interruptible interface {
Interrupt(cause error)
}
// ctxHonorer is implemented by engines that fully manage ctx cancellation
// inside Eval (their Eval interrupts the correct resource itself). For
// these the Runner suppresses the "not interruptible" warning and its
// watcher is a benign no-op. zip's goja engine qualifies because
// EvalContext owns the per-call interrupt watcher.
type ctxHonorer interface {
ctxHonored()
}
// Runner executes arbitrary-language code in goroutine-scoped, ctx-bound
// tasks. Safe for concurrent use: Run, Register and Languages may all be
// called from many goroutines.
type Runner interface {
// Run looks up the engine for lang, spawns a worker goroutine that
// calls engine.Eval, and a watcher goroutine that interrupts the
// engine when ctx is done. It returns the engine result, or ctx.Err()
// if cancellation won the race. Both goroutines are joined before Run
// returns. If no engine is registered for lang it returns
// ErrUnknownLanguage.
Run(ctx context.Context, lang string, src []byte, args ...any) (any, error)
// Register installs engine under lang. Re-registering a language
// replaces the prior engine. Registration is concurrency-safe.
Register(lang string, engine Engine) error
// Languages returns the registered language names, sorted.
Languages() []string
}
// NewRunner returns an empty Runner. Register backends before Run.
func NewRunner() Runner {
return &runner{engines: map[string]Engine{}}
}
type runner struct {
mu sync.RWMutex
engines map[string]Engine
}
func (r *runner) Register(lang string, engine Engine) error {
if lang == "" {
return fmt.Errorf("zip/runtime: Register: empty language")
}
if engine == nil {
return fmt.Errorf("zip/runtime: Register %q: nil engine", lang)
}
_, interruptible := engine.(Interruptible)
_, selfManaged := engine.(ctxHonorer)
if !interruptible && !selfManaged {
slog.Warn("zip/runtime: engine is not interruptible; ctx cancel will not abort a running call",
"language", lang)
}
r.mu.Lock()
r.engines[lang] = engine
r.mu.Unlock()
return nil
}
func (r *runner) Languages() []string {
r.mu.RLock()
langs := make([]string, 0, len(r.engines))
for l := range r.engines {
langs = append(langs, l)
}
r.mu.RUnlock()
sort.Strings(langs)
return langs
}
func (r *runner) Run(ctx context.Context, lang string, src []byte, args ...any) (any, error) {
r.mu.RLock()
engine, ok := r.engines[lang]
r.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownLanguage, lang)
}
if err := ctx.Err(); err != nil {
return nil, err
}
// Goroutine-scoped task: one worker runs Eval, one watcher waits on
// ctx.Done() and interrupts the engine. Both are joined before Run
// returns so a borrowed backend resource is never left interrupted.
// This is the EvalContext contract from jsvm.go, generalized.
type result struct {
val any
err error
}
resCh := make(chan result, 1)
done := make(chan struct{})
watcherDone := make(chan struct{})
go func() {
defer close(watcherDone)
select {
case <-ctx.Done():
if it, ok := engine.(Interruptible); ok {
it.Interrupt(ctx.Err())
}
case <-done:
}
}()
go func() {
v, err := engine.Eval(ctx, src, args...)
resCh <- result{v, err}
close(done)
}()
res := <-resCh // worker finished (interrupted or naturally)
<-watcherDone // JOIN: watcher returned; no pending interrupt in flight
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return res.val, res.err
}
+139
View File
@@ -0,0 +1,139 @@
package runtime
import (
"context"
"errors"
"fmt"
"math/rand"
"sync"
"testing"
"time"
)
// newJSRunner builds a Runner with zip's real goja engine registered as
// "js". No stubs: this is the same *JSRuntime base/gojavm wraps.
func newJSRunner(t *testing.T) Runner {
t.Helper()
rt, err := NewJSRuntime(JSOptions{PoolSize: 8})
if err != nil {
t.Fatalf("NewJSRuntime: %v", err)
}
r := NewRunner()
if err := r.Register("js", rt.Engine()); err != nil {
t.Fatalf("Register js: %v", err)
}
return r
}
// TestRunnerJSHappyPath: real goja, real arithmetic, real Export() result.
func TestRunnerJSHappyPath(t *testing.T) {
r := newJSRunner(t)
got, err := r.Run(context.Background(), "js", []byte("1+2"))
if err != nil {
t.Fatalf("Run: %v", err)
}
if got != int64(3) {
t.Fatalf("1+2 = %#v (%T), want int64(3)", got, got)
}
}
// TestRunnerJSFunctionCall defines a function and returns the result of
// calling it via a top-level expression — proving a defined function is
// reachable and invocable in the same eval.
func TestRunnerJSFunctionCall(t *testing.T) {
r := newJSRunner(t)
got, err := r.Run(context.Background(), "js",
[]byte("function add(a,b){return a+b}; add(40,2)"))
if err != nil {
t.Fatalf("Run: %v", err)
}
if got != int64(42) {
t.Fatalf("add(40,2) = %#v (%T), want int64(42)", got, got)
}
// And capture the function value itself, then confirm it round-trips
// as a callable (Export of a goja function is a Go func wrapper).
fnVal, err := r.Run(context.Background(), "js",
[]byte("function add(a,b){return a+b}; add"))
if err != nil {
t.Fatalf("Run capture fn: %v", err)
}
if fnVal == nil {
t.Fatalf("captured function is nil")
}
}
// TestRunnerJSDeadline: an infinite loop must be interrupted by ctx
// deadline. Asserts the error is context.DeadlineExceeded and the wall
// time is bounded (interrupt actually fired, not a hung worker).
func TestRunnerJSDeadline(t *testing.T) {
r := newJSRunner(t)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
_, err := r.Run(ctx, "js", []byte("while(true){}"))
elapsed := time.Since(start)
t.Logf("deadline test elapsed: %v", elapsed)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want context.DeadlineExceeded", err)
}
if elapsed > 200*time.Millisecond {
t.Fatalf("elapsed %v > 200ms: interrupt did not fire promptly", elapsed)
}
}
// TestRunnerUnknownLanguage: dispatch to an unregistered language returns
// the typed sentinel, not a panic.
func TestRunnerUnknownLanguage(t *testing.T) {
r := newJSRunner(t)
_, err := r.Run(context.Background(), "cobol", []byte("DISPLAY 'HI'."))
if !errors.Is(err, ErrUnknownLanguage) {
t.Fatalf("err = %v, want ErrUnknownLanguage", err)
}
}
// TestRunnerConcurrent: 100 goroutines each evaluate a distinct random
// expression a+b and assert the result. Run under -race this proves the
// registry RWMutex and the per-call VM isolation are sound.
func TestRunnerConcurrent(t *testing.T) {
r := newJSRunner(t)
const n = 100
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
a := rand.Intn(10000)
b := rand.Intn(10000)
src := fmt.Sprintf("%d+%d", a, b)
got, err := r.Run(context.Background(), "js", []byte(src))
if err != nil {
errs <- fmt.Errorf("%s: %w", src, err)
return
}
if got != int64(a+b) {
errs <- fmt.Errorf("%s = %#v, want %d", src, got, a+b)
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
}
// TestRunnerLanguages: registry reports registered languages, sorted.
func TestRunnerLanguages(t *testing.T) {
r := newJSRunner(t)
langs := r.Languages()
if len(langs) != 1 || langs[0] != "js" {
t.Fatalf("Languages() = %v, want [js]", langs)
}
}
+16
View File
@@ -0,0 +1,16 @@
// Package runtime is the zip-side projection of HIP-0105's extension
// runtime contract. Consumers of zip pass a Loader implementation here
// (typically *extruntime.Loader from hanzoai/base/plugins/extruntime)
// and zip mounts modules as routes via app.Module().
package runtime
import internal "github.com/zap-proto/zip/internal/runtime"
// Loader is re-exported from internal/runtime for ergonomic use:
//
// loader := myextruntime.NewLoader(...) // implements zipruntime.Loader
// app := zip.New(zip.Config{Loader: loader})
type Loader = internal.Loader
// Module is re-exported from internal/runtime.
type Module = internal.Module
+118
View File
@@ -0,0 +1,118 @@
package runtime
import (
"fmt"
"strconv"
"testing"
)
func TestEval(t *testing.T) {
rt, err := NewJSRuntime(JSOptions{PoolSize: 2})
if err != nil {
t.Fatal(err)
}
v, err := rt.Eval("1 + 2")
if err != nil {
t.Fatal(err)
}
if got, ok := v.(int64); !ok || got != 3 {
t.Fatalf("eval 1+2 = %v (%T), want int64(3)", v, v)
}
}
func TestEvalString(t *testing.T) {
rt, _ := NewJSRuntime(JSOptions{PoolSize: 1})
v, err := rt.Eval(`["a","b","c"].join("-")`)
if err != nil {
t.Fatal(err)
}
if v != "a-b-c" {
t.Fatalf("got %v, want a-b-c", v)
}
}
func TestRegisterHostFn(t *testing.T) {
rt, _ := NewJSRuntime(JSOptions{PoolSize: 2})
if err := rt.RegisterHostFn("addOne", func(n int) int { return n + 1 }); err != nil {
t.Fatal(err)
}
v, err := rt.Eval("addOne(41)")
if err != nil {
t.Fatal(err)
}
if got, ok := v.(int64); !ok || got != 42 {
t.Fatalf("addOne(41) = %v (%T), want 42", v, v)
}
}
func TestHostFnViaOptions(t *testing.T) {
rt, err := NewJSRuntime(JSOptions{
PoolSize: 2,
HostFns: map[string]any{
"double": func(n int) int { return n * 2 },
},
})
if err != nil {
t.Fatal(err)
}
v, _ := rt.Eval("double(21)")
if got := v.(int64); got != 42 {
t.Fatalf("double(21) = %d, want 42", got)
}
}
func TestLoadModule(t *testing.T) {
rt, _ := NewJSRuntime(JSOptions{PoolSize: 2})
src := `module.exports = { greet: function(name){ return "hi " + name } }`
if err := rt.LoadModule("greeter", src); err != nil {
t.Fatal(err)
}
v, err := rt.Eval(`require("greeter").greet("zip")`)
if err != nil {
t.Fatal(err)
}
if v != "hi zip" {
t.Fatalf("got %v, want 'hi zip'", v)
}
}
func TestModuleViaOptions(t *testing.T) {
rt, err := NewJSRuntime(JSOptions{
PoolSize: 1,
Modules: map[string]string{
"math": `exports.sq = function(n){ return n*n }`,
},
})
if err != nil {
t.Fatal(err)
}
v, _ := rt.Eval(`require("math").sq(9)`)
if got := v.(int64); got != 81 {
t.Fatalf("sq(9) = %d, want 81", got)
}
}
func TestPoolConcurrency(t *testing.T) {
rt, _ := NewJSRuntime(JSOptions{PoolSize: 4})
_ = rt.RegisterHostFn("identity", func(n int) int { return n })
done := make(chan error, 32)
for i := 0; i < 32; i++ {
go func(n int) {
v, err := rt.Eval("identity(" + strconv.Itoa(n) + ")")
if err != nil {
done <- err
return
}
if int(v.(int64)) != n {
done <- fmt.Errorf("got %v want %d", v, n)
return
}
done <- nil
}(i)
}
for i := 0; i < 32; i++ {
if err := <-done; err != nil {
t.Fatal(err)
}
}
}
+107
View File
@@ -0,0 +1,107 @@
package zip
import (
"context"
"reflect"
"github.com/gofiber/fiber/v3"
"github.com/zap-proto/zip/internal/jsonenc"
)
// TypedHandler is the generic handler signature: takes an *In, returns
// (*Out, error). zip generates OpenAPI 3.1 spec from the In/Out types
// and registers a Fiber route that unmarshals body → In, runs the
// handler, and marshals Out → JSON response.
type TypedHandler[In, Out any] func(ctx context.Context, in *In) (*Out, error)
// registeredOp is the bookkeeping zip keeps for OpenAPI generation.
// One op per typed route.
type registeredOp struct {
Method string
Path string
OperationID string
Summary string
Tags []string
InType reflect.Type
OutType reflect.Type
}
// Get registers a GET typed handler at path.
func Get[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "GET", path, fn, opts...)
}
// Post registers a POST typed handler at path.
func Post[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "POST", path, fn, opts...)
}
// Put registers a PUT typed handler at path.
func Put[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "PUT", path, fn, opts...)
}
// Patch registers a PATCH typed handler at path.
func Patch[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "PATCH", path, fn, opts...)
}
// Delete registers a DELETE typed handler at path.
func Delete[In, Out any](app *App, path string, fn TypedHandler[In, Out], opts ...OpOption) {
registerTyped(app, "DELETE", path, fn, opts...)
}
// OpOption configures a typed handler registration (OpenAPI metadata).
type OpOption func(*registeredOp)
// WithSummary sets the operation summary in OpenAPI.
func WithSummary(s string) OpOption { return func(op *registeredOp) { op.Summary = s } }
// WithTags sets the operation tags in OpenAPI.
func WithTags(tags ...string) OpOption { return func(op *registeredOp) { op.Tags = tags } }
// WithOperationID sets the operation ID in OpenAPI.
func WithOperationID(id string) OpOption {
return func(op *registeredOp) { op.OperationID = id }
}
func registerTyped[In, Out any](app *App, method, path string, fn TypedHandler[In, Out], opts ...OpOption) {
var inZero In
var outZero Out
op := &registeredOp{
Method: method,
Path: path,
InType: reflect.TypeOf(inZero),
OutType: reflect.TypeOf(outZero),
}
for _, o := range opts {
o(op)
}
app.ops = append(app.ops, op)
handler := func(c fiber.Ctx) error {
var in In
if method != "GET" && method != "HEAD" {
if len(c.Body()) > 0 {
if err := jsonenc.Unmarshal(c.Body(), &in); err != nil {
return ErrBadRequest("invalid json body: " + err.Error())
}
}
}
// Validate via struct tags.
if err := validate(&in); err != nil {
return ErrBadRequest(err.Error())
}
out, err := fn(c.Context(), &in)
if err != nil {
return err
}
if out == nil {
c.Status(204)
return nil
}
return c.JSON(out)
}
app.fiber.Add([]string{method}, path, handler)
}
+147
View File
@@ -0,0 +1,147 @@
package zip
import (
"fmt"
"reflect"
"strconv"
"strings"
)
// validate runs minimal struct-tag validation: required, min=N, max=N,
// minlen=N, maxlen=N. Operates on string / numeric / slice fields.
// Anything else passes through.
//
// This is deliberately not a full validator (no `github.com/go-playground/validator`
// — we don't want the dependency). It covers ~90% of REST API input
// validation needs and is ~120 LOC.
func validate(v any) error {
val := reflect.ValueOf(v)
if val.Kind() == reflect.Pointer {
if val.IsNil() {
return nil
}
val = val.Elem()
}
if val.Kind() != reflect.Struct {
return nil
}
t := val.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("validate")
if tag == "" || tag == "-" {
continue
}
if err := validateField(field.Name, val.Field(i), tag); err != nil {
return err
}
}
return nil
}
func validateField(name string, val reflect.Value, tag string) error {
rules := strings.Split(tag, ",")
for _, r := range rules {
r = strings.TrimSpace(r)
if r == "" {
continue
}
var key, raw string
if i := strings.IndexByte(r, '='); i >= 0 {
key, raw = r[:i], r[i+1:]
} else {
key = r
}
switch key {
case "required":
if isZero(val) {
return fmt.Errorf("field %q is required", name)
}
case "min":
n, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("field %q invalid min=%q", name, raw)
}
if !checkMin(val, n) {
return fmt.Errorf("field %q must be >= %d", name, n)
}
case "max":
n, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("field %q invalid max=%q", name, raw)
}
if !checkMax(val, n) {
return fmt.Errorf("field %q must be <= %d", name, n)
}
case "minlen":
n, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("field %q invalid minlen=%q", name, raw)
}
if !checkLenMin(val, n) {
return fmt.Errorf("field %q length must be >= %d", name, n)
}
case "maxlen":
n, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("field %q invalid maxlen=%q", name, raw)
}
if !checkLenMax(val, n) {
return fmt.Errorf("field %q length must be <= %d", name, n)
}
}
}
return nil
}
func isZero(v reflect.Value) bool {
switch v.Kind() {
case reflect.String:
return v.Len() == 0
case reflect.Slice, reflect.Map, reflect.Array:
return v.Len() == 0
case reflect.Pointer, reflect.Interface:
return v.IsNil()
}
return v.IsZero()
}
func checkMin(v reflect.Value, n int) bool {
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() >= int64(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return v.Uint() >= uint64(n)
case reflect.Float32, reflect.Float64:
return v.Float() >= float64(n)
}
return true
}
func checkMax(v reflect.Value, n int) bool {
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() <= int64(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return v.Uint() <= uint64(n)
case reflect.Float32, reflect.Float64:
return v.Float() <= float64(n)
}
return true
}
func checkLenMin(v reflect.Value, n int) bool {
switch v.Kind() {
case reflect.String, reflect.Slice, reflect.Map, reflect.Array:
return v.Len() >= n
}
return true
}
func checkLenMax(v reflect.Value, n int) bool {
switch v.Kind() {
case reflect.String, reflect.Slice, reflect.Map, reflect.Array:
return v.Len() <= n
}
return true
}
+70
View File
@@ -0,0 +1,70 @@
// Package wsx provides Fiber-v3-compatible WebSocket support via
// fasthttp/websocket. The public surface is intentionally small:
//
// app.Get("/ws", wsx.Upgrade(func(c *wsx.Conn) error {
// for {
// _, msg, err := c.ReadMessage()
// if err != nil { return err }
// _ = c.WriteMessage(wsx.TextMessage, msg)
// }
// }))
package wsx
import (
"github.com/fasthttp/websocket"
"github.com/valyala/fasthttp"
"github.com/zap-proto/zip"
)
// Conn is the WebSocket connection passed to wsx handlers.
type Conn = websocket.Conn
// Message-type constants re-exported for convenience.
const (
TextMessage = websocket.TextMessage
BinaryMessage = websocket.BinaryMessage
CloseMessage = websocket.CloseMessage
PingMessage = websocket.PingMessage
PongMessage = websocket.PongMessage
)
// Handler is the wsx handler signature.
type Handler func(c *Conn) error
// Config configures the WebSocket upgrade.
type Config struct {
ReadBufferSize int
WriteBufferSize int
Subprotocols []string
EnableCompression bool
// CheckOrigin defaults to allow-all (zip is multi-tenant; gate via
// CORS or auth middleware at the route level instead).
CheckOrigin func(ctx *fasthttp.RequestCtx) bool
}
// Upgrade returns a zip.Handler that upgrades the HTTP connection to a
// WebSocket and calls fn with the established *Conn.
func Upgrade(fn Handler, opts ...Config) zip.Handler {
var cfg Config
if len(opts) > 0 {
cfg = opts[0]
}
up := &websocket.FastHTTPUpgrader{
ReadBufferSize: cfg.ReadBufferSize,
WriteBufferSize: cfg.WriteBufferSize,
Subprotocols: cfg.Subprotocols,
EnableCompression: cfg.EnableCompression,
}
if cfg.CheckOrigin != nil {
up.CheckOrigin = cfg.CheckOrigin
} else {
up.CheckOrigin = func(ctx *fasthttp.RequestCtx) bool { return true }
}
return func(c *zip.Ctx) error {
rc := c.Fiber().RequestCtx()
return up.Upgrade(rc, func(ws *Conn) {
_ = fn(ws)
})
}
}
+66
View File
@@ -0,0 +1,66 @@
package zip
import (
"github.com/gofiber/fiber/v3"
zaphttp "github.com/zap-proto/http"
)
// The transport layer: ONE fiber handler, served over TWO transports.
//
// ZAP (TLS 1.3 + post-quantum, gRPC's replacement) is the PRIMARY transport;
// plain HTTP is the optional EXTRA for human/REST/browser clients. Both serve
// the SAME `a.fiber.Handler()`, so every /v1 route is reachable identically
// over either — there is NO separate RPC surface to register, NO ordinal mux,
// NO per-endpoint wiring. This is the one-and-only-one-way model: your routes
// ARE the ZAP surface, exactly as they are the HTTP surface. (Supersedes the
// old zaprpc-registry ZAPListen stub — the routes replace the registry.)
// prepare installs the deferred routes (OpenAPI) before a listener starts.
// Shared by both transports so ZAP and HTTP expose the same surface.
func (a *App) prepare() { a.installOpenAPIRoutes() }
// ListenHTTP serves plain HTTP on addr and blocks. The optional EXTRA
// transport. (Was `Listen`; renamed for symmetry with ListenZAP — verb-first,
// transport-suffixed, the way Go stdlib names net.Listen/http.ListenAndServe.)
func (a *App) ListenHTTP(addr string) error {
a.prepare()
a.logger.Info("zip listening HTTP", "addr", addr)
return a.fiber.Listen(addr, fiber.ListenConfig{
DisableStartupMessage: a.cfg.DisableStartupMessage,
})
}
// ListenZAP serves the fiber handler over ZAP — the PRIMARY transport. Blocks
// until Shutdown. Every route registered on this App answers over ZAP with the
// same middleware, auth filters, and error handling as over HTTP.
func (a *App) ListenZAP(addr string) error {
a.prepare()
s := &zaphttp.Server{Addr: addr, Handler: a.fiber.Handler()}
a.zapMu.Lock()
a.zap = s
a.zapMu.Unlock()
a.logger.Info("zip listening ZAP", "addr", addr)
return s.ListenAndServe()
}
// Serve runs ZAP (primary) and, when httpAddr != "", HTTP (extra) concurrently,
// returning the first listener error. The normal way to bring a zip binary up
// on both transports.
func (a *App) Serve(zapAddr, httpAddr string) error {
errc := make(chan error, 2)
go func() { errc <- a.ListenZAP(zapAddr) }()
if httpAddr != "" {
go func() { errc <- a.ListenHTTP(httpAddr) }()
}
return <-errc
}
// closeZAP stops the ZAP listener if one is running. Called from Shutdown.
func (a *App) closeZAP() {
a.zapMu.Lock()
s := a.zap
a.zapMu.Unlock()
if s != nil {
_ = s.Close()
}
}
+72
View File
@@ -0,0 +1,72 @@
package zip_test
import (
"strings"
"testing"
"time"
"github.com/valyala/fasthttp"
zaphttp "github.com/zap-proto/http"
"github.com/zap-proto/zip"
)
// TestListenZAP_RoundTrip proves the un-stubbed ListenZAP actually SERVES: a
// route registered on the App answers a real request over the ZAP transport,
// with the same handler/JSON path as HTTP. (Replaces the old stub that returned
// "ZAP wire dispatch not yet implemented".)
func TestListenZAP_RoundTrip(t *testing.T) {
app := zip.New(zip.Config{AppName: "zaptest", DisableStartupMessage: true})
app.Get("/v1/health", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"status": "ok", "transport": "zap"})
})
const addr = "127.0.0.1:19653"
go func() { _ = app.ListenZAP(addr) }()
defer func() { _ = app.Shutdown() }()
// Wait for the ZAP listener to accept (bounded).
waitReachable(t, addr)
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI("/v1/health")
req.Header.SetMethod(fasthttp.MethodGet)
tr := zaphttp.NewTransport(addr)
defer tr.CloseIdleConnections()
if err := tr.Do(req, resp); err != nil {
t.Fatalf("ZAP round-trip failed: %v", err)
}
if resp.StatusCode() != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode())
}
body := string(resp.Body())
if !strings.Contains(body, `"status":"ok"`) || !strings.Contains(body, `"transport":"zap"`) {
t.Fatalf("body over ZAP = %q, want the handler's JSON", body)
}
}
func waitReachable(t *testing.T, addr string) {
t.Helper()
tr := zaphttp.NewTransport(addr)
tr.SetDialTimeout(200 * time.Millisecond)
defer tr.CloseIdleConnections()
for i := 0; i < 50; i++ {
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
req.SetRequestURI("/v1/health")
req.Header.SetMethod(fasthttp.MethodGet)
err := tr.Do(req, resp)
fasthttp.ReleaseRequest(req)
fasthttp.ReleaseResponse(resp)
if err == nil {
return
}
time.Sleep(40 * time.Millisecond)
}
t.Fatalf("ZAP listener at %s never became reachable", addr)
}
+77
View File
@@ -0,0 +1,77 @@
package zaprpc
import (
"fmt"
zap "github.com/zap-proto/go"
)
// The ZAP RPC envelope is the canonical on-the-wire framing for one RPC
// call carried over HTTP (or any byte transport). It is a ZAP Message
// whose root Object has a 24-byte fixed section with three fields:
//
// offset 0: service (text — 8 bytes: u32 rel-offset + u32 len)
// offset 8: method (text — 8 bytes)
// offset 16: payload (bytes — 8 bytes)
//
// Binary is canonical: the X-ZAP-Service / X-ZAP-Method HTTP headers are
// only a fast-path hint and the envelope wins on any disagreement.
const (
envServiceOff = 0
envMethodOff = 8
envPayloadOff = 16
envFixedSize = 24
)
// Envelope is the decoded form of a ZAP RPC frame.
type Envelope struct {
Service string
Method string
Payload []byte
}
// EncodeEnvelope builds the canonical ZAP RPC frame for e.
func EncodeEnvelope(e Envelope) []byte {
b := zap.NewBuilder(256 + len(e.Payload))
ob := b.StartObject(envFixedSize)
ob.SetText(envServiceOff, e.Service)
ob.SetText(envMethodOff, e.Method)
ob.SetBytes(envPayloadOff, e.Payload)
ob.FinishAsRoot()
return b.Finish()
}
// DecodeEnvelope parses a ZAP RPC frame. Returns an error if the bytes
// are not a valid ZAP message.
func DecodeEnvelope(data []byte) (Envelope, error) {
msg, err := zap.Parse(data)
if err != nil {
return Envelope{}, fmt.Errorf("zaprpc: decode envelope: %w", err)
}
root := msg.Root()
if root.IsNull() {
return Envelope{}, fmt.Errorf("zaprpc: decode envelope: null root")
}
return Envelope{
Service: root.Text(envServiceOff),
Method: root.Text(envMethodOff),
Payload: root.Bytes(envPayloadOff),
}, nil
}
// EncodeResponse wraps a method's response payload in a ZAP frame. The
// response uses the same envelope shape with empty service/method so a
// single decoder handles both directions; callers that only need the
// payload can use DecodeResponse.
func EncodeResponse(payload []byte) []byte {
return EncodeEnvelope(Envelope{Payload: payload})
}
// DecodeResponse extracts the payload from a response frame.
func DecodeResponse(data []byte) ([]byte, error) {
e, err := DecodeEnvelope(data)
if err != nil {
return nil, err
}
return e.Payload, nil
}
+71
View File
@@ -0,0 +1,71 @@
package zaprpc
import (
"fmt"
"github.com/gofiber/fiber/v3"
)
// HTTP header hints. The envelope in the body is canonical; these are a
// fast-path so a router/proxy can route without decoding the body. On
// disagreement the binary envelope wins (see decodeRequest).
const (
HeaderService = "X-ZAP-Service"
HeaderMethod = "X-ZAP-Method"
)
// HTTPHandler returns a fiber.Handler that serves the ZAP RPC plane over
// HTTP POST. The request body is a canonical ZAP envelope (service,
// method, payload); the handler decodes it, dispatches through reg, and
// writes a ZAP response envelope. Returns fiber.Handler (not zip.Handler)
// because zaprpc is imported by zip — depending back would cycle.
//
// Mount it on any zip/Fiber router:
//
// app.Fiber().Post("/zap", zaprpc.HTTPHandler(app.ZAPRegistry()))
func HTTPHandler(reg *Registry) fiber.Handler {
return func(c fiber.Ctx) error {
body := c.Body()
if len(body) == 0 {
return writeErr(c, fiber.StatusBadRequest, "empty ZAP body")
}
env, err := DecodeEnvelope(body)
if err != nil {
return writeErr(c, fiber.StatusBadRequest, err.Error())
}
// Headers are only a hint; the envelope is canonical. We accept
// the header values when the envelope omits them, but never let a
// header override a present envelope field.
if env.Service == "" {
env.Service = c.Get(HeaderService)
}
if env.Method == "" {
env.Method = c.Get(HeaderMethod)
}
if env.Service == "" || env.Method == "" {
return writeErr(c, fiber.StatusBadRequest, "missing service or method")
}
out, err := reg.Dispatch(c.Context(), env.Service, env.Method, env.Payload)
if err != nil {
if err == ErrNoService {
return writeErr(c, fiber.StatusNotFound, err.Error())
}
return writeErr(c, fiber.StatusInternalServerError, err.Error())
}
c.Status(fiber.StatusOK)
c.Set(fiber.HeaderContentType, "application/zap")
c.Set(HeaderService, env.Service)
c.Set(HeaderMethod, env.Method)
return c.Send(EncodeResponse(out))
}
}
func writeErr(c fiber.Ctx, status int, msg string) error {
c.Status(status)
c.Set(fiber.HeaderContentType, "application/json")
return c.SendString(fmt.Sprintf(`{"error":%q,"status":%d}`, msg, status))
}
+92
View File
@@ -0,0 +1,92 @@
package zaprpc
import (
"bytes"
"context"
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v3"
)
// echoService is a tiny ZAP service: method "ping" returns its payload
// prefixed with "pong:".
type echoService struct{}
func (echoService) Name() string { return "echo.v1" }
func (echoService) Handle(_ context.Context, method string, payload []byte) ([]byte, error) {
if method != "ping" {
return nil, ErrNoService
}
return append([]byte("pong:"), payload...), nil
}
func TestEnvelopeRoundTrip(t *testing.T) {
in := Envelope{Service: "echo.v1", Method: "ping", Payload: []byte("hello")}
frame := EncodeEnvelope(in)
out, err := DecodeEnvelope(frame)
if err != nil {
t.Fatal(err)
}
if out.Service != in.Service || out.Method != in.Method || !bytes.Equal(out.Payload, in.Payload) {
t.Fatalf("roundtrip mismatch: %+v != %+v", out, in)
}
}
func TestHTTPHandler(t *testing.T) {
reg := NewRegistry()
reg.Register(echoService{})
app := fiber.New()
app.Post("/zap", HTTPHandler(reg))
frame := EncodeEnvelope(Envelope{Service: "echo.v1", Method: "ping", Payload: []byte("xyz")})
req := httptest.NewRequest("POST", "/zap", bytes.NewReader(frame))
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/zap" {
t.Fatalf("content-type = %q, want application/zap", ct)
}
body, _ := io.ReadAll(resp.Body)
payload, err := DecodeResponse(body)
if err != nil {
t.Fatal(err)
}
if string(payload) != "pong:xyz" {
t.Fatalf("payload = %q, want pong:xyz", payload)
}
}
func TestHTTPHandler_UnknownService(t *testing.T) {
reg := NewRegistry()
app := fiber.New()
app.Post("/zap", HTTPHandler(reg))
frame := EncodeEnvelope(Envelope{Service: "nope.v1", Method: "ping", Payload: []byte("x")})
resp, err := app.Test(httptest.NewRequest("POST", "/zap", bytes.NewReader(frame)))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 404 {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
func TestHTTPHandler_EmptyBody(t *testing.T) {
reg := NewRegistry()
app := fiber.New()
app.Post("/zap", HTTPHandler(reg))
resp, err := app.Test(httptest.NewRequest("POST", "/zap", nil))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 400 {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package zaprpc is an OPTIONAL named-service RPC dispatch helper for zip
// apps that want a Cap'n-Proto/gRPC-style service registry (name → method →
// handler) rather than plain REST routes.
//
// It is DECOUPLED from the transport: zip's primary transport is ZAP, wired
// once in the framework (App.ListenZAP serves the whole fiber handler over
// zap-proto/http — the routes ARE the ZAP surface, no registry needed). This
// package is for the separate case where you want to expose generated
// zapc <svc>_server.go services by name; mount it as an ordinary route with
// zaprpc.HTTPHandler(registry) (see http.go), reachable over either transport.
package zaprpc
import (
"context"
"errors"
)
// Service is the minimal ZAP service interface zip can dispatch to.
// zapc-generated <svc>_server.go satisfies this naturally.
type Service interface {
// Name returns the service identifier (e.g. "validate.v1").
Name() string
// Handle dispatches one RPC call on the service. method is the
// fully-qualified method name; payload is the wire-encoded ZAP
// request body; the returned bytes are the wire-encoded response.
Handle(ctx context.Context, method string, payload []byte) ([]byte, error)
}
// Registry holds a set of named services, dispatched via HTTPHandler.
type Registry struct {
services map[string]Service
}
// NewRegistry constructs an empty registry.
func NewRegistry() *Registry {
return &Registry{services: map[string]Service{}}
}
// Register adds a service. Calling twice with the same name overwrites
// (caller bug).
func (r *Registry) Register(s Service) {
r.services[s.Name()] = s
}
// Get returns the service for name, or nil.
func (r *Registry) Get(name string) Service {
return r.services[name]
}
// Names returns the registered service names.
func (r *Registry) Names() []string {
out := make([]string, 0, len(r.services))
for n := range r.services {
out = append(out, n)
}
return out
}
// ErrNoService is returned by Dispatch when the service is unregistered.
var ErrNoService = errors.New("zaprpc: service not registered")
// Dispatch invokes the named service+method against the registry. The
// wire-decode happens upstream (HTTPHandler decodes the request envelope,
// then calls Dispatch to route it to the right handler).
func (r *Registry) Dispatch(ctx context.Context, service, method string, payload []byte) ([]byte, error) {
s, ok := r.services[service]
if !ok {
return nil, ErrNoService
}
return s.Handle(ctx, method, payload)
}
+231 -120
View File
@@ -1,147 +1,258 @@
// Package zip is the ZAP-native web framework for every Lux/Hanzo Go binary
// (all nodes — luxd/hanzod/zood/parsd — the gateway, and all cloud services).
// Package zip is Hanzo's canonical Go web framework. Built on
// Fiber v3 / fasthttp. Sinatra-style API. ZAP-typed handlers.
// Multi-language extension support via HIP-0105.
//
// One framework, one way: gofiber v3 for routing + middleware, served over ZAP
// as the PRIMARY transport, with a plain-HTTP listener as an optional extra.
// Because the transport is ZAP, ZAP + MCP ride on top natively for free.
// ONE framework, ZERO escape hatches. zip IS fast.
//
// a := zip.New()
// a.Fiber().Get("/v1/health", healthz) // static routes + middleware
// a.MountFast("/v1/bc/C", chainHandler) // runtime chain mounts (post-Listen)
// a.Serve(":9632", ":9630") // ZAP primary + HTTP extra
// app := zip.New(zip.Config{Logger: luxlog.NewLogger("svc")})
// app.Use(middleware.Recover(), middleware.RequestID())
// app.Get("/health", func(c *zip.Ctx) error {
// return c.JSON(200, fiber.Map{"ok": true})
// })
// app.Serve(":9653", ":8080") // ZAP primary + HTTP extra
//
// Public surface — types/functions exposed at the package root:
//
// type App, Config, Ctx, Handler
// func New(Config) *App
// func Get[I, O](app *App, path string, fn func(ctx, *I) (*O, error))
// func Post[I, O](app *App, path string, fn func(ctx, *I) (*O, error))
// ...
//
// All other behavior lives in subpackages: `middleware`, `runtime`.
package zip
import (
"net/http"
"sort"
"strings"
"context"
"errors"
"sync"
"github.com/gofiber/fiber/v3"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpadaptor"
luxlog "github.com/luxfi/log"
zaphttp "github.com/zap-proto/http"
"github.com/zap-proto/zip/internal/jsonenc"
"github.com/zap-proto/zip/runtime"
)
// Config aliases fiber.Config so the router is configured in one place.
type Config = fiber.Config
// JSONVariant reports which JSON implementation zip is using in this
// build — "encoding/json/v2" when compiled with GOEXPERIMENT=jsonv2,
// "encoding/json" otherwise. Exposed for cmd/cloud startup logs and
// for tests that need to assert the variant. Per HIP-0106 the wire
// stack is "JSON only at edge, ZAP between services"; this constant
// tells operators which JSON impl is on the edge.
const JSONVariant = jsonenc.Variant
// Handler aliases the fiber v3 handler (func(fiber.Ctx) error).
type Handler = fiber.Handler
// Handler is zip's request handler signature. Returning an error causes
// Fiber's error chain to write a JSON response.
type Handler func(c *Ctx) error
// App is a ZAP-native web application.
// Config configures the zip App. Most fields pass through to Fiber's own
// Config; a few zip-specific knobs control runtime loading.
type Config struct {
// Logger is the luxfi/log Logger zip uses internally. Required.
// If nil, a default one is created via luxlog.NewLogger("zip").
Logger luxlog.Logger
// Loader is the HIP-0105 extension runtime loader. nil disables
// app.Module() — only native handlers will work. The interface is
// satisfied by *extruntime.Loader from hanzoai/base/plugins/extruntime;
// zip does NOT take a hard dep on hanzoai/base.
Loader runtime.Loader
// AllowedRuntimes restricts which extension runtimes app.Module()
// will accept (e.g. ["goja","wazero"] for hard multi-tenant safety).
// nil = allow whatever the Loader has registered.
AllowedRuntimes []string
// ServerHeader is sent as the Server: response header (default "zip").
// Set to "-" to suppress.
ServerHeader string
// BodyLimit is the maximum request body size (default 4 MiB).
BodyLimit int
// AppName forwards to fiber.Config.AppName.
AppName string
// DisableStartupMessage suppresses Fiber's startup banner.
DisableStartupMessage bool
// ErrorHandler is the catch-all error handler. Defaults to zip.errorHandler
// which renders {error, code, status} JSON.
ErrorHandler fiber.ErrorHandler
// Concurrency caps the maximum number of concurrent connections the
// server will accept. Default 0 means fasthttp's own default
// (256*1024). Ops should cap this at the per-replica budget — see
// `~/work/hanzo/hips/docs/SCALE_STANDARD.md`. With Hanzo's verified
// 8 KiB/conn budget, 100_000 sits at ~800 MiB inside a 1 GiB pod.
Concurrency int
// ReadBufferSize is fasthttp's per-conn request-read buffer (default
// 4 KiB). Raise only for header-heavy upstreams; raising it inflates
// the per-conn memory budget and breaks the conn-memory regression
// gate (see SCALE_STANDARD.md §8).
ReadBufferSize int
// WriteBufferSize is fasthttp's per-conn response-write buffer
// (default 4 KiB). Raise only for streaming-heavy responses; same
// budget caveat as ReadBufferSize.
WriteBufferSize int
// OpenAPI configures the auto-generated /.well-known/openapi.json
// served when typed handlers are registered.
OpenAPI OpenAPIConfig
}
// App is the zip application. It wraps *fiber.App and exposes the zip
// handler signature alongside generic typed handlers.
type App struct {
fiber *fiber.App
mu sync.RWMutex
dyn []dynRoute // longest-prefix first
zap *zaphttp.Server
cfg Config
logger luxlog.Logger
loader runtime.Loader
fiber *fiber.App
ops []*registeredOp
closers []func() error
zap *zaphttp.Server // the ZAP (primary) listener, set by ListenZAP
zapMu sync.Mutex
}
type dynRoute struct {
prefix string
h fasthttp.RequestHandler
}
// New constructs an App with the given config. Defaults are applied
// for any zero-valued field.
func New(cfg Config) *App {
if cfg.Logger == nil {
cfg.Logger = luxlog.New("module", "zip")
}
if cfg.BodyLimit == 0 {
cfg.BodyLimit = 4 << 20
}
if cfg.ServerHeader == "" {
cfg.ServerHeader = "zip"
}
// New builds an App. Optional Config passes straight through to fiber.
func New(cfg ...Config) *App {
a := &App{}
a.fiber = fiber.New(cfg...)
// Runtime dynamic dispatch, ahead of the static routes: longest-prefix match
// over a mutable registry. This is how a node registers chain handlers AFTER
// the listeners are already serving — Fiber's static tree can't take routes
// post-Listen, this registry can.
a.fiber.Use(func(c fiber.Ctx) error {
if h := a.match(c.Path()); h != nil {
h(c.RequestCtx())
return nil
}
return c.Next()
})
return a
}
fcfg := fiber.Config{
AppName: cfg.AppName,
BodyLimit: cfg.BodyLimit,
// Route every Fiber JSON path through zip's jsonenc package: this
// covers c.JSON(), c.Bind().Body(), and the default error
// handler when it serializes HTTPError. With GOEXPERIMENT=jsonv2
// the underlying impl is encoding/json/v2; otherwise it falls
// back to encoding/json. Same call site, different bytes-out.
JSONEncoder: jsonenc.Marshal,
JSONDecoder: jsonenc.Unmarshal,
// Per-conn scale knobs from SCALE_STANDARD.md §6 — zero values
// fall through to fasthttp defaults (256k concurrent / 4 KiB
// read+write buffers).
Concurrency: cfg.Concurrency,
ReadBufferSize: cfg.ReadBufferSize,
WriteBufferSize: cfg.WriteBufferSize,
}
if cfg.ServerHeader != "-" {
fcfg.ServerHeader = cfg.ServerHeader
}
if cfg.ErrorHandler != nil {
fcfg.ErrorHandler = cfg.ErrorHandler
} else {
fcfg.ErrorHandler = errorHandler
}
func (a *App) match(path string) fasthttp.RequestHandler {
a.mu.RLock()
defer a.mu.RUnlock()
for i := range a.dyn { // sorted longest-prefix first
if strings.HasPrefix(path, a.dyn[i].prefix) {
return a.dyn[i].h
}
cfg.Logger.Info("zip new", "json_variant", jsonenc.Variant)
return &App{
cfg: cfg,
logger: cfg.Logger,
loader: cfg.Loader,
fiber: fiber.New(fcfg),
}
return nil
}
// Fiber exposes the underlying router for static Get/Post/Group/Use registration.
// Fiber returns the underlying *fiber.App. Use for one-off escape into
// Fiber-only APIs (rare). Prefer staying on the zip surface.
func (a *App) Fiber() *fiber.App { return a.fiber }
// Handler is the fasthttp handler both listeners serve.
func (a *App) Handler() fasthttp.RequestHandler { return a.fiber.Handler() }
// Logger returns the App's logger.
func (a *App) Logger() luxlog.Logger { return a.logger }
// Mount attaches a net/http.Handler at prefix. VM and chain handlers are
// net/http — this is the ONE net/http boundary, adapted to fasthttp.
func (a *App) Mount(prefix string, h http.Handler) {
a.MountFast(prefix, fasthttpadaptor.NewFastHTTPHandler(h))
}
// MountFast registers/replaces a fasthttp handler at prefix. Safe to call at
// runtime after the listeners are serving. Longest matching prefix wins.
func (a *App) MountFast(prefix string, h fasthttp.RequestHandler) {
a.mu.Lock()
defer a.mu.Unlock()
for i := range a.dyn {
if a.dyn[i].prefix == prefix {
a.dyn[i].h = h
return
}
}
a.dyn = append(a.dyn, dynRoute{prefix: prefix, h: h})
sort.Slice(a.dyn, func(i, j int) bool { return len(a.dyn[i].prefix) > len(a.dyn[j].prefix) })
}
// Unmount removes a mounted prefix.
func (a *App) Unmount(prefix string) {
a.mu.Lock()
defer a.mu.Unlock()
out := a.dyn[:0]
for _, r := range a.dyn {
if r.prefix != prefix {
out = append(out, r)
}
}
a.dyn = out
}
// ListenZAP serves over ZAP — the PRIMARY transport. Blocks until Shutdown.
func (a *App) ListenZAP(addr string) error {
s := &zaphttp.Server{Addr: addr, Handler: a.fiber.Handler()}
a.mu.Lock()
a.zap = s
a.mu.Unlock()
return s.ListenAndServe()
}
// ListenHTTP serves plain HTTP — the optional EXTRA transport. Blocks.
func (a *App) ListenHTTP(addr string) error { return a.fiber.Listen(addr) }
// Serve runs ZAP (primary) and, when httpAddr != "", HTTP (extra) concurrently,
// returning the first listener error.
func (a *App) Serve(zapAddr, httpAddr string) error {
errc := make(chan error, 2)
go func() { errc <- a.ListenZAP(zapAddr) }()
if httpAddr != "" {
go func() { errc <- a.ListenHTTP(httpAddr) }()
}
return <-errc
}
// Shutdown stops both listeners.
// Shutdown gracefully stops both transports.
func (a *App) Shutdown() error {
a.mu.RLock()
z := a.zap
a.mu.RUnlock()
if z != nil {
_ = z.Close()
}
a.closeZAP()
_ = a.runClosers(context.Background())
return a.fiber.Shutdown()
}
// ShutdownWithContext gracefully stops both transports bounded by ctx.
func (a *App) ShutdownWithContext(ctx context.Context) error {
a.closeZAP()
_ = a.runClosers(ctx)
return a.fiber.ShutdownWithContext(ctx)
}
// Use registers zip-style middleware. Each Handler runs in order; calling
// c.Next() (via c.Continue) chains to the next handler.
func (a *App) Use(handlers ...Handler) Router {
for _, h := range handlers {
a.fiber.Use(toFiberHandler(a, h))
}
return &routerAdapter{r: a.fiber, app: a}
}
// UseFiber lets callers register raw fiber.Handler middleware (for the
// fiber/v3/middleware/* packages). zip middleware is preferred.
func (a *App) UseFiber(handlers ...fiber.Handler) Router {
args := make([]any, 0, len(handlers))
for _, h := range handlers {
args = append(args, h)
}
a.fiber.Use(args...)
return &routerAdapter{r: a.fiber, app: a}
}
// Get / Post / Put / Patch / Delete / Head / Options / All / Add register routes.
func (a *App) Get(path string, h Handler) Router { return a.method("GET", path, h) }
func (a *App) Post(path string, h Handler) Router { return a.method("POST", path, h) }
func (a *App) Put(path string, h Handler) Router { return a.method("PUT", path, h) }
func (a *App) Patch(path string, h Handler) Router { return a.method("PATCH", path, h) }
func (a *App) Delete(path string, h Handler) Router { return a.method("DELETE", path, h) }
func (a *App) Head(path string, h Handler) Router { return a.method("HEAD", path, h) }
func (a *App) Options(path string, h Handler) Router {
return a.method("OPTIONS", path, h)
}
// All registers a handler for any HTTP method.
func (a *App) All(path string, h Handler) Router {
a.fiber.All(path, toFiberHandler(a, h))
return &routerAdapter{r: a.fiber, app: a}
}
func (a *App) method(method, path string, h Handler) Router {
a.fiber.Add([]string{method}, path, toFiberHandler(a, h))
return &routerAdapter{r: a.fiber, app: a}
}
// Group creates a path-prefixed router group.
func (a *App) Group(prefix string, handlers ...Handler) Router {
args := make([]any, 0, len(handlers))
for _, h := range handlers {
args = append(args, toFiberHandler(a, h))
}
g := a.fiber.Group(prefix, args...)
return &routerAdapter{r: g, app: a}
}
// Route runs fn against a path-prefixed router group.
func (a *App) Route(prefix string, fn func(r Router)) Router {
g := a.fiber.Group(prefix)
r := &routerAdapter{r: g, app: a}
fn(r)
return r
}
// errors.As helper for HTTPError unwrapping in tests / external callers.
func asHTTPError(err error) (*HTTPError, bool) {
var he *HTTPError
if errors.As(err, &he) {
return he, true
}
return nil, false
}
+124 -84
View File
@@ -1,99 +1,139 @@
package zip
package zip_test
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v3"
"github.com/valyala/fasthttp"
zaphttp "github.com/zap-proto/http"
"github.com/zap-proto/zip"
)
// call drives the app's fasthttp handler in-process (no network).
func call(a *App, method, path string) (int, string) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod(method)
ctx.Request.SetRequestURI(path)
a.Handler()(ctx)
return ctx.Response.StatusCode(), string(ctx.Response.Body())
}
func TestStaticRoute(t *testing.T) {
a := New()
a.Fiber().Get("/v1/health", func(c fiber.Ctx) error { return c.SendString("ok") })
if code, body := call(a, "GET", "/v1/health"); code != 200 || body != "ok" {
t.Fatalf("static route: got %d %q", code, body)
}
}
func TestDynamicMountAndUnmount(t *testing.T) {
a := New()
a.MountFast("/v1/bc/C", func(ctx *fasthttp.RequestCtx) {
ctx.SetStatusCode(200)
ctx.SetBodyString("C-chain")
// TestBasicRouting hits the hello-world path through fiber.Test to
// confirm the Sinatra idiom + JSON response work end-to-end.
func TestBasicRouting(t *testing.T) {
app := zip.New(zip.Config{AppName: "test", DisableStartupMessage: true})
app.Get("/hello", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{"message": "hi"})
})
if code, body := call(a, "POST", "/v1/bc/C/rpc"); code != 200 || body != "C-chain" {
t.Fatalf("dynamic mount: got %d %q", code, body)
}
a.Unmount("/v1/bc/C")
if code, _ := call(a, "POST", "/v1/bc/C/rpc"); code == 200 {
t.Fatalf("after unmount expected non-200, got %d", code)
}
}
func TestNetHTTPMount(t *testing.T) {
a := New()
a.Mount("/v1/info", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
_, _ = io.WriteString(w, "info-ok")
}))
if code, body := call(a, "GET", "/v1/info"); code != 200 || body != "info-ok" {
t.Fatalf("net/http mount: got %d %q", code, body)
}
}
func TestLongestPrefixWins(t *testing.T) {
a := New()
a.MountFast("/v1", func(ctx *fasthttp.RequestCtx) { ctx.SetBodyString("root") })
a.MountFast("/v1/bc/C", func(ctx *fasthttp.RequestCtx) { ctx.SetBodyString("cchain") })
if _, body := call(a, "GET", "/v1/bc/C/rpc"); body != "cchain" {
t.Fatalf("longest-prefix: got %q", body)
}
if _, body := call(a, "GET", "/v1/status"); body != "root" {
t.Fatalf("prefix fallback: got %q", body)
}
}
// TestZAPPrimaryRoundTrip proves the SAME app served over the ZAP transport
// returns identical results — ZAP is the primary path.
func TestZAPPrimaryRoundTrip(t *testing.T) {
a := New()
a.MountFast("/v1/ping", func(ctx *fasthttp.RequestCtx) { ctx.SetBodyString("pong") })
ln, err := net.Listen("tcp", "127.0.0.1:0")
req, _ := http.NewRequest("GET", "/hello", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
t.Fatalf("Test(): %v", err)
}
srv := &zaphttp.Server{Handler: a.Handler()}
go func() { _ = srv.Serve(ln) }()
defer srv.Close()
time.Sleep(50 * time.Millisecond)
tr := zaphttp.NewTransport(ln.Addr().String())
defer tr.CloseIdleConnections()
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI("http://zap/v1/ping")
req.Header.SetMethod("GET")
if err := tr.Do(req, resp); err != nil {
t.Fatalf("zap round-trip: %v", err)
if resp.StatusCode != 200 {
t.Fatalf("status %d, want 200", resp.StatusCode)
}
if got := string(resp.Body()); got != "pong" {
t.Fatalf("zap round-trip: got %q want pong", got)
body, _ := io.ReadAll(resp.Body)
var got map[string]string
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("json: %v / body=%s", err, body)
}
if got["message"] != "hi" {
t.Fatalf("body=%s", body)
}
}
// TestHTTPError checks zip.HTTPError → JSON error response.
func TestHTTPError(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Get("/boom", func(c *zip.Ctx) error {
return zip.ErrNotFound("nope")
})
req, _ := http.NewRequest("GET", "/boom", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test(): %v", err)
}
if resp.StatusCode != 404 {
t.Fatalf("status %d, want 404", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "nope") {
t.Fatalf("body=%s", body)
}
}
// TestTyped exercises the generic typed handler + reflection-based
// validation + OpenAPI route installation.
func TestTyped(t *testing.T) {
type In struct {
Email string `json:"email" validate:"required,minlen=3"`
}
type Out struct {
OK bool `json:"ok"`
}
app := zip.New(zip.Config{DisableStartupMessage: true})
zip.Post(app, "/v1/test", func(ctx context.Context, in *In) (*Out, error) {
return &Out{OK: true}, nil
})
// Valid call.
req, _ := http.NewRequest("POST", "/v1/test",
strings.NewReader(`{"email":"[email protected]"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test(): %v", err)
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d body=%s", resp.StatusCode, body)
}
// Invalid call — missing email.
req2, _ := http.NewRequest("POST", "/v1/test",
strings.NewReader(`{}`))
req2.Header.Set("Content-Type", "application/json")
resp2, err := app.Fiber().Test(req2)
if err != nil {
t.Fatalf("Test(): %v", err)
}
if resp2.StatusCode != 400 {
body, _ := io.ReadAll(resp2.Body)
t.Fatalf("status %d body=%s, want 400", resp2.StatusCode, body)
}
}
// TestGroup verifies app.Group prefixing.
func TestGroup(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
v1 := app.Group("/v1")
v1.Get("/ping", func(c *zip.Ctx) error {
return c.String(200, "pong")
})
req, _ := http.NewRequest("GET", "/v1/ping", nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test(): %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
}
// TestIdentityHeaders confirms c.Org/User/Email map to X-* headers.
func TestIdentityHeaders(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Get("/who", func(c *zip.Ctx) error {
return c.JSON(200, map[string]string{
"org": c.Org(),
"user": c.User(),
})
})
req, _ := http.NewRequest("GET", "/who", nil)
req.Header.Set("X-Org-Id", "hanzo")
req.Header.Set("X-User-Id", "z")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test(): %v", err)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), `"org":"hanzo"`) ||
!strings.Contains(string(body), `"user":"z"`) {
t.Fatalf("body=%s", body)
}
}