mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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).
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 := ®isteredOp{
|
||||
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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
cfg Config
|
||||
logger luxlog.Logger
|
||||
loader runtime.Loader
|
||||
fiber *fiber.App
|
||||
|
||||
mu sync.RWMutex
|
||||
dyn []dynRoute // longest-prefix first
|
||||
zap *zaphttp.Server
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
cfg.Logger.Info("zip new", "json_variant", jsonenc.Variant)
|
||||
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
logger: cfg.Logger,
|
||||
loader: cfg.Loader,
|
||||
fiber: fiber.New(fcfg),
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
return a
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user