Add the one teardown primitive zip was missing:
func (a *App) OnShutdown(fn func(context.Context) error)
Subsystems register their own cleanup at mount time; reverse-mount
teardown falls out for free. This is what consumers (hanzoai/cloud)
need to flatten a MountSpec{Shutdown} registry into plain
pkg.Mount(app, cfg) calls.
Semantics:
- Order: hooks run LAST — after listeners stop accepting and after
in-flight requests drain (fiber) — in LIFO order. Draining first
means a subsystem's teardown never races the requests still using
it; LIFO gives reverse-mount teardown of dependencies.
- Errors: every hook runs even if an earlier one fails; all hook
errors (plus the drain error) are aggregated via errors.Join.
- Context: ShutdownWithContext passes its ctx (values + deadline) to
hooks; plain Shutdown uses context.Background() (process ending).
- Idempotent: a shared once-guard makes the whole Shutdown a no-op on
the second call; hooks run at most once, across both entry points.
- Post-shutdown registration is a no-op: the hook is dropped and a
warning logged (no panic). Keeps OnShutdown's meaning constant —
register-for-later — instead of silently changing to run-now.
- Registration is mutex-guarded; a nil hook is ignored.
Decomplect: fold the internal `closers` stopgap onto OnShutdown.
module.go's own comment described appendCloser as a poor-man's
graceful-shutdown hook list "because fiber doesn't expose one" — which
is exactly this primitive. Module() now registers mod.Close via
OnShutdown, so there is ONE teardown mechanism. A module's Close now
runs AFTER in-flight requests drain (it previously ran before), so a
module stays live for any request still executing it — a correctness
improvement. closers.go is deleted.
Tests (shutdown_test.go): LIFO order, errors.Join aggregation,
idempotent second Shutdown, ShutdownWithContext ctx propagation,
post-shutdown drop, nil-hook, and concurrent registration under -race.
Co-authored-by: Hanzo Dev <[email protected]>
zip
Docs: zip · part of the ZAP Protocol
The ZAP-native Go web framework. Built on Fiber v3 / fasthttp, with a Sinatra-style API, typed handlers that project to OpenAPI and MCP, and ZAP as the primary transport — HTTP is a secondary view of the same routes.
zap-proto.io · Docs · fiber · Spec
ONE framework. ONE Listen verb. Routes defined once, served over every transport.
package main
import (
"github.com/zap-proto/zip"
"github.com/zap-proto/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(), // gateway-minted X-Org-Id
"user": c.User(), // gateway-minted X-User-Id
})
})
_ = app.Listen(":9653", "http://:8080") // ZAP primary + HTTP extra, one verb
}
Install
go get github.com/zap-proto/zip
Module path github.com/zap-proto/zip. Requires Go 1.26+.
Features
-
Sinatra/Express idiom —
app.Get(path, fn)is the primary API; handlers arefunc(c *zip.Ctx) error. -
Transport is a value, not a method — one verb,
app.Listen(addrs...), and the address scheme selects the transport (mirrorsnet.Listen):app.Listen(":9653") // ZAP (bare addr = the primary) app.Listen(":9653", "http://:8080") // ZAP + HTTP in one call app.Listen("http://:8080") // HTTP only app.Listen("quic://:443") // any RegisterTransport'd protocolZAP (TLS 1.3 + post-quantum) is the default; HTTP is built in;
zip.RegisterTransport(scheme, fn)adds any future protocol with zero change toListen. -
One registry, three projections —
zip.Get[In, Out](app, path, fn)registers one operation that becomes a REST route, an OpenAPI 3.1 doc (/.well-known/openapi.json, Swagger UI at/docs), and a Model Context Protocol tool at/mcp(JSON-RPC 2.0). Same schema, same handler. Because/mcpis an ordinary route, ZAP-native MCP is automatic. On by default;Config.MCP.Disabledto suppress. -
Precedence is a property of the pattern — routing comes from the
zap-proto/fiberfork: the most specific pattern wins regardless of registration order (static ≻ :param ≻ *), and ambiguous equal-specificity overlaps panic at startup instead of silently shadowing. -
Identity built-in —
c.Org() / c.User() / c.UserEmail() / c.IsAdmin()read JWT-validatedX-*headers set by the gateway; handlers never parse tokens. -
Middleware —
Recover,RequestID,Logger,Timeout,MaxBody,CORS,RateLimit,Telemetry,Breakerinzip/middleware. -
WebSocket & streaming —
wsx.Upgrade(fn)over fasthttp/websocket;c.SendStreamWriterfor SSE / chunked responses. -
Extension routes —
app.Module("POST /v1/eval", "wasm", "./policy")mounts a sandboxed extension (wasm / goja / pyvm / starlark / v8go / native) as a route. -
Embedded JS/TS runtime — run an Express-shaped JS/TS handler in-process via goja (pure Go, no CGO); esbuild transpiles TS ahead of it, for incremental migration to native Go.
-
Drop-in migration —
app.Mount("/legacy", h)andzip.AdaptNetHTTP(...)accept anyhttp.Handler(chi, gin, beego,net/http). -
Composition by Mount — a service is a set of subsystem packages exposing
func Mount(app *zip.App, deps Deps) error; a standalone binary and a fused multi-subsystem binary are the same code with a different selection. -
Stdlib JSON only — every JSON path goes through one internal helper backed by
encoding/json/v2when built withGOEXPERIMENT=jsonv2(Go 1.25+), elseencoding/json. No third-party JSON library.
Documentation
The full guide — Ctx reference, the route-precedence contract, middleware, extension-runtime mounts, and versioning — is at zap-proto.dev/docs/zip. Runnable examples live in examples/.
License
MIT — see LICENSE.