mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
plugin: a repo declares its routes, and the host discovers them
HIP-0106's plugin contract, in the framework rather than in 116 repositories. Declaration projects the LIVE ROUTER — every method+pattern, complete by construction, so a route a plugin serves and does not publish is still declared. That is the analytics-ingestion outage class: the published paths were listed, the four ingestion doors were not, and every product beacon in the fleet answered 405. Declaration/Route/(*App).Declaration what a host discovers /.well-known/zip/plugin.json the same value off a running child (*App).Described `<bin> openapi <f>` / `<bin> declare <f>` Tenant the tenancy read, with the real rule Delegate a continuation for another org Ask dial a peer by name, invoke one op (*App).Peer peer ops on their own app, own socket (*App).Ops /healthz + /readyz + /metrics Config.Eager the one fact a router cannot show App.claim a second claim of a prefix is an ERROR zip/runtime -> zip/js a language runtime is an adaptor Four things the reference plugin falsified while being built: - Describe was already taken. zip.Describe(methodPath, Doc) is the doc registry generated code calls. The projection writer is (*App).Described, one call, because argv is read in one place and a main cannot forget to forward it. - zip's control plane is NOT all under /.well-known/. /docs and the MCP path are not, so a prefix filter let a child declare them — paths outside /v1/ that every plugin serves and whose first claimant takes the HOST's document and agent door. App.control now registers and records in one call, so leaking one is impossible rather than unlikely. - OPS_PORT appeared in no Go file in the fleet. HIP-0119 named the paths and nobody implemented them, so conformance meant 116 repos hand-rolling three routes and a listener. It is one sibling app now, brought up by Listen. - There is deliberately NO default ops port. A default made every second app in a process die on "address already in use" — a plugin has two apps in it and a test process has many. The deployment states OPS_PORT; a host states none on a child, so a child correctly owns none. Load's duplicate claim used to return nil while the first registration kept answering: the losing plugin's whole surface served another plugin's 404 and nothing said so. It is now an error naming both claimants, all-or-nothing across a plugin's prefixes, taken before anything is spawned. a.ops was the op registry and is now a.registry — the ONE registry the OpenAPI document, the MCP tool, the CLI command and the plane op are all projected from. a.ops is the ops sibling. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+1
-1
@@ -65,7 +65,7 @@ Fiber's own `middleware/adaptor` package which is part of the v3 core.
|
||||
X-User-IsAdmin`.
|
||||
- HIP-0105 extension contract — duck-typed via `zip.Loader` at the root, so the
|
||||
framework stays decoupled from `hanzoai/base` AND from a JS toolchain: the
|
||||
goja/esbuild implementation is the separate opt-in import `zip/runtime`.
|
||||
goja/esbuild implementation is the separate opt-in import `zip/js`.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// conns is the process's one Conn per peer name. A [Conn] is safe for
|
||||
// concurrent use and holds a POOLED transport, so the right lifetime for one is
|
||||
// the process, not the call: dialing per call throws the pool away and turns
|
||||
// every plane hop into a fresh connect, which on a hot path is the whole cost.
|
||||
//
|
||||
// Keyed by name rather than by resolved path so that a redeployed peer — same
|
||||
// name, same socket, new process — is reached by the same handle: the socket
|
||||
// path is the identity (see [SocketPath]), and reconnecting is the transport's
|
||||
// job, not the caller's.
|
||||
var conns sync.Map // name -> *Conn
|
||||
|
||||
// Ask invokes one op on a named peer and returns its reply. It is the whole of
|
||||
// what a caller needs to reach another service:
|
||||
//
|
||||
// out, err := zip.Ask[plane.AuthorizeIn, plane.AuthorizeOut](
|
||||
// ctx, "billing", plane.FinanceAuthorize, &in)
|
||||
//
|
||||
// The peer is reached over its canonical socket ([SocketPath]) — no registry, no
|
||||
// discovery, no address in the caller's configuration. The op token is the
|
||||
// operation's one identity: the same string is its operationId, its MCP tool
|
||||
// name and its CLI command.
|
||||
//
|
||||
// Identity is not invented here. Whatever the edge asserted about the caller
|
||||
// rides along when ctx carries a request ([Ctx.Forward]); a background job
|
||||
// states one explicitly with [WithCaller]. There is no org argument, because an
|
||||
// org in the argument is an org the caller chose.
|
||||
//
|
||||
// The Conn is kept for the life of the process. Nothing needs closing, and a
|
||||
// caller that closes one only drops its idle connections — the next Ask
|
||||
// redials.
|
||||
func Ask[In, Out any](ctx context.Context, name, op string, in *In) (*Out, error) {
|
||||
c, err := peer(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Call[In, Out](ctx, c, op, in)
|
||||
}
|
||||
|
||||
// peer returns the process's Conn to name, dialing on first use. Two callers
|
||||
// racing the first Ask both dial; one handle wins and the loser's is dropped
|
||||
// unused, which costs nothing because dialing is lazy.
|
||||
func peer(name string) (*Conn, error) {
|
||||
if c, ok := conns.Load(name); ok {
|
||||
return c.(*Conn), nil
|
||||
}
|
||||
c, err := DialApp(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if got, loaded := conns.LoadOrStore(name, c); loaded {
|
||||
return got.(*Conn), nil
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
type askIn struct {
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
type askOut struct {
|
||||
Org string `json:"org"`
|
||||
}
|
||||
|
||||
// Ask is the whole of what a caller needs to reach a peer: a name, an op token
|
||||
// and a value. No address, no registry, no discovery — and the tenant rides the
|
||||
// CALLER, so the callee reads an org the caller could not choose.
|
||||
func TestAskReachesAPeerByNameAndCarriesTheCaller(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(zip.RuntimeDirEnv, dir)
|
||||
|
||||
peer := zip.New(zip.Config{AppName: "ledger", DisableStartupMessage: true})
|
||||
zip.Post(peer, "/v1/ledger/whoami", func(ctx context.Context, in *askIn) (*askOut, error) {
|
||||
org, ok := zip.Tenant(ctx)
|
||||
if !ok {
|
||||
return nil, zip.Errorf(403, "no tenant")
|
||||
}
|
||||
return &askOut{Org: org}, nil
|
||||
}, zip.WithOperationID("ledger_whoami"))
|
||||
|
||||
go func() { _ = peer.Listen(zip.SocketPath("ledger")) }()
|
||||
t.Cleanup(func() { _ = peer.Shutdown() })
|
||||
waitSock(t, zip.SocketPath("ledger"))
|
||||
|
||||
// A background job STATES its caller once, explicitly. It cannot launder one:
|
||||
// an inbound request always wins over a stated caller.
|
||||
ctx := zip.WithCaller(context.Background(), zip.Caller{User: "u_job", Org: "acme"})
|
||||
out, err := zip.Ask[askIn, askOut](ctx, "ledger", "ledger_whoami", &askIn{Ref: "r1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if out.Org != "acme" {
|
||||
t.Fatalf("org = %q, want acme", out.Org)
|
||||
}
|
||||
|
||||
// A caller with no validated principal is refused by the CALLEE, not by
|
||||
// anything the caller could skip.
|
||||
_, err = zip.Ask[askIn, askOut](context.Background(), "ledger", "ledger_whoami", &askIn{})
|
||||
var he *zip.HTTPError
|
||||
if !errors.As(err, &he) || he.Status != 403 {
|
||||
t.Fatalf("unauthenticated Ask = %v, want a 403 from the callee", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The Conn is kept for the life of the process: a plane hop must not pay for a
|
||||
// fresh connect, which is what "dial, call, close" per call costs.
|
||||
func TestAskReusesOneConnPerPeer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(zip.RuntimeDirEnv, dir)
|
||||
|
||||
peer := zip.New(zip.Config{AppName: "reuse", DisableStartupMessage: true})
|
||||
zip.Post(peer, "/v1/reuse/ping", func(ctx context.Context, in *askIn) (*askOut, error) {
|
||||
return &askOut{Org: "ok"}, nil
|
||||
}, zip.WithOperationID("reuse_ping"))
|
||||
go func() { _ = peer.Listen(zip.SocketPath("reuse")) }()
|
||||
t.Cleanup(func() { _ = peer.Shutdown() })
|
||||
waitSock(t, zip.SocketPath("reuse"))
|
||||
|
||||
ctx := zip.WithCaller(context.Background(), zip.Caller{User: "u", Org: "o"})
|
||||
for i := 0; i < 20; i++ {
|
||||
if _, err := zip.Ask[askIn, askOut](ctx, "reuse", "reuse_ping", &askIn{}); err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// One socket, N calls: nothing to assert about the pool from outside, so the
|
||||
// property under test is that 20 sequential calls succeed on one handle
|
||||
// without the caller ever holding a Conn.
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// The op-call plane — the projection that lets services compose WITHOUT
|
||||
// linking. The same typed-op registry (a.ops) that produces the REST routes,
|
||||
// linking. The same typed-op registry (a.registry) that produces the REST routes,
|
||||
// the OpenAPI document and the MCP tool list also answers a call addressed by
|
||||
// the op's NAME, carrying the op's In as the body and its Out as the reply.
|
||||
// One value (the op), five projections (REST · OpenAPI · MCP · CLI · call).
|
||||
@@ -140,10 +140,10 @@ func socketIn(dir, name string) string { return filepath.Join(dir, name+".sock")
|
||||
// installCallPlane mounts the by-name op plane when there are ops to call.
|
||||
// Called from prepare() alongside installOpenAPIRoutes and installMCP.
|
||||
func (a *App) installCallPlane() {
|
||||
if len(a.ops) == 0 {
|
||||
if len(a.registry) == 0 {
|
||||
return
|
||||
}
|
||||
a.fiber.Post(CallPath+":op", func(fc fiber.Ctx) error {
|
||||
a.control(fiber.MethodPost, CallPath+":op", func(fc fiber.Ctx) error {
|
||||
// opByName is the ONE by-name lookup and op.invoke the ONE dispatcher —
|
||||
// the same pair tools/call runs, without MCP's agent-facing envelope.
|
||||
op := a.opByName(fc.Params("op"))
|
||||
@@ -172,7 +172,7 @@ func (a *App) installCallPlane() {
|
||||
fc.Set(fiber.HeaderContentType, CallContentType)
|
||||
return fc.Send(body)
|
||||
})
|
||||
a.logger.Info("zip call plane", "path", CallPath, "ops", len(a.ops))
|
||||
a.logger.Info("zip call plane", "path", CallPath, "ops", len(a.registry))
|
||||
}
|
||||
|
||||
// Conn is a handle to another zip app, dialed once and used concurrently. It
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The defect this closes: a SECOND claim of a prefix used to return nil and the
|
||||
// first registration kept answering. The losing plugin's whole surface then
|
||||
// served another plugin's 404, and nothing in the composition said so — which is
|
||||
// why a host's conflict gate has to run at build time.
|
||||
func TestASecondClaimOfAPrefixIsRefused(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "a", Addr: "/run/zip/a.sock"}, "/v1/x")); err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
err := app.Add(zip.Load(zip.Plugin{Name: "b", Addr: "/run/zip/b.sock"}, "/v1/x"))
|
||||
if err == nil {
|
||||
t.Fatal("the second claim of /v1/x was accepted — the losing plugin serves a's 404")
|
||||
}
|
||||
for _, want := range []string{"/v1/x", `"a"`, `"b"`} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q does not name %s", err, want)
|
||||
}
|
||||
}
|
||||
// "/v1/x" and "/v1/x/" are ONE claim: the router normalises, so the gate must.
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "c", Addr: "/run/zip/c.sock"}, "/v1/x/")); err == nil {
|
||||
t.Fatal("a trailing slash bought a second claim on the same subtree")
|
||||
}
|
||||
}
|
||||
|
||||
// All-or-nothing: a plugin owning three prefixes and losing the third must not
|
||||
// leave the first two held, or the plugins that really own them cannot load.
|
||||
func TestAPartialClaimIsRolledBack(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "held", Addr: "/run/zip/h.sock"}, "/v1/c")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "wide", Addr: "/run/zip/w.sock"},
|
||||
"/v1/a", "/v1/b", "/v1/c")); err == nil {
|
||||
t.Fatal("wide took /v1/c from held")
|
||||
}
|
||||
// /v1/a and /v1/b must be free for whoever really owns them.
|
||||
for _, p := range []string{"/v1/a", "/v1/b"} {
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "real" + p, Addr: "/run/zip/r.sock"}, p)); err != nil {
|
||||
t.Fatalf("%s stayed held by a plugin that failed to load: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A bare Mount is the same claim through a different door, so it is refused the
|
||||
// same way — otherwise the gate is one that any caller can walk around.
|
||||
func TestMountAndLoadShareOneClaimRegister(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Mount("/v1/y", "/run/zip/y.sock"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "z", Addr: "/run/zip/z.sock"}, "/v1/y")); err == nil {
|
||||
t.Fatal("Load walked around a claim Mount already held")
|
||||
}
|
||||
if err := app.Mount("/v1/y", "/run/zip/other.sock"); err == nil {
|
||||
t.Fatal("Mount walked around its own claim")
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// CLI — the FOURTH projection. The same typed-op registry (a.ops) that produces
|
||||
// CLI — the FOURTH projection. The same typed-op registry (a.registry) that produces
|
||||
// the REST routes, the OpenAPI document and the MCP tools produces a command
|
||||
// line: an op becomes `<service> <operation>`, its In fields become flags, its
|
||||
// doc comment becomes the help, and its Example becomes the example invocation.
|
||||
@@ -109,8 +109,8 @@ type Flag struct {
|
||||
// Commands projects every registered typed op into a command. This is the whole
|
||||
// derivation: no registration, no list of commands, no per-endpoint code.
|
||||
func (a *App) Commands() []Command {
|
||||
cmds := make([]Command, 0, len(a.ops))
|
||||
for _, op := range a.ops {
|
||||
cmds := make([]Command, 0, len(a.registry))
|
||||
for _, op := range a.registry {
|
||||
doc, has := docFor(op.Method, op.Path)
|
||||
c := newCommand(op.Method, op.Path, opName(op), op.Summary, doc, has)
|
||||
c.op = op
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
)
|
||||
|
||||
// A plugin DECLARES the paths it answers; a host DISCOVERS them.
|
||||
//
|
||||
// A host that hand-maintains a routing row for a repository it does not build
|
||||
// will get that row wrong, and a wrong row is an outage: cloud's `analytics`
|
||||
// row listed the read endpoints and omitted the four ingestion doors the app
|
||||
// actually served, so every product beacon in the fleet answered 405 eighteen
|
||||
// seconds after the row became load-bearing.
|
||||
//
|
||||
// The declaration is projected from the LIVE ROUTER. Not from the AST — that
|
||||
// cannot cross a repository boundary. Not from the OpenAPI document — a route
|
||||
// with no typed op is in no document and still needs routing, which is exactly
|
||||
// the ingestion case. Not from a committed golden — a golden is what gets
|
||||
// consulted instead of the router.
|
||||
|
||||
// PluginPath is where a running plugin serves its own declaration, so a host
|
||||
// that already started a child can compare what it MOUNTED against what the
|
||||
// child SERVES. It is one of zip's control-plane routes, and a Declaration
|
||||
// never includes one (see [App.control]).
|
||||
const PluginPath = "/.well-known/zip/plugin.json"
|
||||
|
||||
// control registers one of zip's OWN routes — a projection of the app rather
|
||||
// than a door the app's owner wrote — and records it so [App.Declaration]
|
||||
// excludes it.
|
||||
//
|
||||
// Recording is not a filter on a path prefix, because zip's control plane is
|
||||
// not all under one: /.well-known/openapi.json and /.well-known/zip/op/ are,
|
||||
// but /docs and the MCP path (default /mcp, configurable) are not. A prefix
|
||||
// filter therefore let a child DECLARE /docs and /mcp — paths that are not
|
||||
// under /v1/ at all, that every other plugin also serves, and whose first
|
||||
// claimant would take the HOST's own document and agent door for the whole
|
||||
// composition. Registering through here makes leaking one impossible rather
|
||||
// than unlikely.
|
||||
func (a *App) control(method, path string, h fiber.Handler) {
|
||||
if a.controls == nil {
|
||||
a.controls = map[string]bool{}
|
||||
}
|
||||
a.controls[method+" "+path] = true
|
||||
a.fiber.Add([]string{method}, path, h)
|
||||
}
|
||||
|
||||
// Declaration is what a plugin tells a host: who it is, whether it must be
|
||||
// running before the first request arrives, every route pattern its router
|
||||
// holds, and every op name it answers on the call plane.
|
||||
//
|
||||
// It is the whole of what a host needs in order to route to a repository it
|
||||
// does not build. There is no Prefixes field and no Remainder flag: a plugin
|
||||
// that owns a version remainder declares the catch-all route it actually
|
||||
// registered ("/v1/*"), so the fact lives in one place — the router.
|
||||
type Declaration struct {
|
||||
Name string `json:"name"`
|
||||
Eager bool `json:"eager,omitempty"`
|
||||
Routes []Route `json:"routes"`
|
||||
Ops []string `json:"ops,omitempty"`
|
||||
}
|
||||
|
||||
// Route is one pattern in the ROUTER's own spelling — ":id", not "{id}". The
|
||||
// host mounts what the router matches, so the two must be the same string.
|
||||
type Route struct {
|
||||
Method string `json:"method"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
// Declaration projects the live router: every method+pattern the app will
|
||||
// answer, sorted by (pattern, method) and deduplicated, plus every registered
|
||||
// op name. It is complete by construction — a route the plugin serves and does
|
||||
// not publish is still declared.
|
||||
//
|
||||
// zip's own control plane is excluded — see [App.control]: those are
|
||||
// per-process routes a host serves for itself, and a child claiming them would
|
||||
// take the host's own document, agent door and op plane.
|
||||
func (a *App) Declaration() Declaration {
|
||||
a.prepare()
|
||||
d := Declaration{Name: a.cfg.AppName, Eager: a.cfg.Eager, Routes: []Route{}}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, r := range a.fiber.GetRoutes(true) {
|
||||
if r.Method == "" || r.Path == "" {
|
||||
continue
|
||||
}
|
||||
if a.controls[r.Method+" "+r.Path] {
|
||||
continue
|
||||
}
|
||||
// fiber registers a HEAD alongside every GET and an OPTIONS shadow for
|
||||
// CORS; neither is a door a host routes on its own.
|
||||
if r.Method == fiber.MethodHead || r.Method == fiber.MethodOptions {
|
||||
continue
|
||||
}
|
||||
k := r.Method + " " + r.Path
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
d.Routes = append(d.Routes, Route{Method: r.Method, Pattern: r.Path})
|
||||
}
|
||||
sort.Slice(d.Routes, func(i, j int) bool {
|
||||
if d.Routes[i].Pattern != d.Routes[j].Pattern {
|
||||
return d.Routes[i].Pattern < d.Routes[j].Pattern
|
||||
}
|
||||
return d.Routes[i].Method < d.Routes[j].Method
|
||||
})
|
||||
|
||||
ops := map[string]bool{}
|
||||
for _, op := range a.registry {
|
||||
if n := opName(op); n != "" && !ops[n] {
|
||||
ops[n] = true
|
||||
d.Ops = append(d.Ops, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(d.Ops)
|
||||
return d
|
||||
}
|
||||
|
||||
// installPluginRoute serves this app's own declaration, so a host can read the
|
||||
// SERVED routes off a started child and compare them to the ones it mounted.
|
||||
// Called from prepare() alongside installOpenAPIRoutes and installMCP.
|
||||
func (a *App) installPluginRoute() {
|
||||
a.control(fiber.MethodGet, PluginPath, func(fc fiber.Ctx) error {
|
||||
fc.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSON)
|
||||
return fc.JSON(a.Declaration())
|
||||
})
|
||||
}
|
||||
|
||||
// Projection names one document an app can emit from its own op registry. The
|
||||
// verb on the command line IS the name here, and both write a FILE.
|
||||
type Projection string
|
||||
|
||||
const (
|
||||
// OpenAPI is the app's own OpenAPI subset, composed upward into the fleet
|
||||
// document. Never carved out of the fleet document by prefix: that makes
|
||||
// the fleet the source and the plugin a derivative, which is how a
|
||||
// catch-all silently swallows a neighbour's routes.
|
||||
OpenAPI Projection = "openapi"
|
||||
|
||||
// Declare is the routing declaration a host discovers (see [Declaration]).
|
||||
Declare Projection = "declare"
|
||||
)
|
||||
|
||||
// Described writes the projection this process was asked for on the command
|
||||
// line and reports whether it did:
|
||||
//
|
||||
// <binary> openapi <file> the app's OpenAPI subset
|
||||
// <binary> declare <file> the app's routing declaration
|
||||
//
|
||||
// A main calls it once, after registering every op and BEFORE opening a store
|
||||
// or dialing a peer — a projection is a function of the code, so a describe run
|
||||
// must not need a database:
|
||||
//
|
||||
// if done, err := app.Described(); done {
|
||||
// return err
|
||||
// }
|
||||
// return app.Listen(zip.Addr(":9653"))
|
||||
//
|
||||
// It writes a FILE and never stdout. A plugin's own dependencies write to
|
||||
// stdout at construction — zip.New logs a line, GORM logs queries, sqlite-vec
|
||||
// prints a warning — and `> file` splices those into the front of the
|
||||
// document.
|
||||
//
|
||||
// One call rather than a Described()/Describe() pair, because argv is read in
|
||||
// exactly one place and a main cannot forget to forward it.
|
||||
func (a *App) Described() (bool, error) {
|
||||
args := os.Args[1:]
|
||||
if len(args) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
var mode Projection
|
||||
switch Projection(args[0]) {
|
||||
case OpenAPI:
|
||||
mode = OpenAPI
|
||||
case Declare:
|
||||
mode = Declare
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
if len(args) < 2 || args[1] == "" {
|
||||
return true, fmt.Errorf("zip: %s needs a destination file", mode)
|
||||
}
|
||||
return true, a.project(mode, args[1])
|
||||
}
|
||||
|
||||
// project renders one projection to dest, atomically: a reader that finds the
|
||||
// file finds a whole document, and a failed render leaves the previous one.
|
||||
func (a *App) project(mode Projection, dest string) error {
|
||||
var v any
|
||||
switch mode {
|
||||
case OpenAPI:
|
||||
v = a.OpenAPISpec()
|
||||
case Declare:
|
||||
v = a.Declaration()
|
||||
default:
|
||||
return fmt.Errorf("zip: unknown projection %q", mode)
|
||||
}
|
||||
body, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("zip: %s: %w", mode, err)
|
||||
}
|
||||
body = append(body, '\n')
|
||||
|
||||
if dir := filepath.Dir(dest); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("zip: %s: %w", mode, err)
|
||||
}
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
||||
return fmt.Errorf("zip: %s: %w", mode, err)
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("zip: %s: %w", mode, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
type declIn struct {
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
type declOut struct {
|
||||
Total string `json:"total"`
|
||||
}
|
||||
|
||||
func declApp(t *testing.T) *zip.App {
|
||||
t.Helper()
|
||||
app := zip.New(zip.Config{AppName: "demo", DisableStartupMessage: true})
|
||||
zip.Post(app, "/v1/demo/quote", func(ctx context.Context, in *declIn) (*declOut, error) {
|
||||
return &declOut{Total: "1.00"}, nil
|
||||
}, zip.WithOperationID("demo_quote"))
|
||||
zip.Get(app, "/v1/demo/quotes/:id", func(ctx context.Context, in *declIn) (*declOut, error) {
|
||||
return &declOut{Total: "2.00"}, nil
|
||||
}, zip.WithOperationID("demo_quote_read"))
|
||||
// An UNTYPED route: in no document, no MCP tool, no op — and still declared,
|
||||
// because a route a host does not mount is a 405 on live traffic.
|
||||
app.Post("/v1/demo/ingest", func(c *zip.Ctx) error { return c.JSON(204, nil) })
|
||||
return app
|
||||
}
|
||||
|
||||
// The declaration comes from the ROUTER, so it holds the untyped ingestion door
|
||||
// too. That is the whole analytics-outage class: the published paths were
|
||||
// listed, the four ingestion doors were not, and every beacon answered 405.
|
||||
func TestDeclarationIsTheRouter(t *testing.T) {
|
||||
d := declApp(t).Declaration()
|
||||
if d.Name != "demo" {
|
||||
t.Fatalf("name = %q, want demo", d.Name)
|
||||
}
|
||||
want := []zip.Route{
|
||||
{Method: "POST", Pattern: "/v1/demo/ingest"},
|
||||
{Method: "POST", Pattern: "/v1/demo/quote"},
|
||||
{Method: "GET", Pattern: "/v1/demo/quotes/:id"},
|
||||
}
|
||||
if len(d.Routes) != len(want) {
|
||||
t.Fatalf("routes = %+v, want %+v", d.Routes, want)
|
||||
}
|
||||
for i := range want {
|
||||
if d.Routes[i] != want[i] {
|
||||
t.Fatalf("route %d = %+v, want %+v", i, d.Routes[i], want[i])
|
||||
}
|
||||
}
|
||||
// The op token is the operation's ONE identity, so only typed ops appear.
|
||||
if len(d.Ops) != 2 || d.Ops[0] != "demo_quote" || d.Ops[1] != "demo_quote_read" {
|
||||
t.Fatalf("ops = %v, want [demo_quote demo_quote_read]", d.Ops)
|
||||
}
|
||||
// zip's own control plane is the host's, per process, never a child's claim.
|
||||
for _, r := range d.Routes {
|
||||
if r.Pattern == zip.PluginPath || r.Pattern == "/.well-known/openapi.json" {
|
||||
t.Fatalf("declaration leaked zip's control plane: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eager is the one fact the router cannot show, so it travels in the
|
||||
// declaration and comes from the app's own Config.
|
||||
func TestDeclarationCarriesEager(t *testing.T) {
|
||||
if declApp(t).Declaration().Eager {
|
||||
t.Fatal("a request-driven app declared itself eager")
|
||||
}
|
||||
app := zip.New(zip.Config{AppName: "pubsub", Eager: true, DisableStartupMessage: true})
|
||||
app.Get("/v1/pubsub/topics", func(c *zip.Ctx) error { return nil })
|
||||
if !app.Declaration().Eager {
|
||||
t.Fatal("an app that owns a consumer declared itself lazy")
|
||||
}
|
||||
}
|
||||
|
||||
// Described writes a FILE, never stdout: a plugin's own dependencies log at
|
||||
// construction and `> file` splices those into the front of the document.
|
||||
func TestDescribedWritesBothProjections(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, tc := range []struct {
|
||||
verb string
|
||||
want string
|
||||
}{{"declare", "demo_quote"}, {"openapi", "/v1/demo/quote"}} {
|
||||
dest := filepath.Join(dir, "sub", tc.verb+".json")
|
||||
old := os.Args
|
||||
os.Args = []string{"demo", tc.verb, dest}
|
||||
done, err := declApp(t).Described()
|
||||
os.Args = old
|
||||
if !done || err != nil {
|
||||
t.Fatalf("%s: done=%v err=%v", tc.verb, done, err)
|
||||
}
|
||||
body, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.verb, err)
|
||||
}
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
t.Fatalf("%s: not JSON: %v", tc.verb, err)
|
||||
}
|
||||
if !containsStr(string(body), tc.want) {
|
||||
t.Fatalf("%s: %s missing from %s", tc.verb, tc.want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A main with no projection verb must fall through to Listen, and an unknown
|
||||
// argv must not be swallowed as one.
|
||||
func TestDescribedIgnoresEverythingElse(t *testing.T) {
|
||||
for _, args := range [][]string{{"demo"}, {"demo", "serve"}, {"demo", "--help"}} {
|
||||
old := os.Args
|
||||
os.Args = args
|
||||
done, err := declApp(t).Described()
|
||||
os.Args = old
|
||||
if done || err != nil {
|
||||
t.Fatalf("%v: done=%v err=%v — want a fall-through to Listen", args, done, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A projection verb with no destination is a mistake worth failing on, not a
|
||||
// reason to write to stdout.
|
||||
func TestDescribedRefusesAMissingDestination(t *testing.T) {
|
||||
old := os.Args
|
||||
os.Args = []string{"demo", "declare"}
|
||||
done, err := declApp(t).Described()
|
||||
os.Args = old
|
||||
if !done || err == nil {
|
||||
t.Fatalf("done=%v err=%v — want a refusal", done, err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(hay, needle string) bool {
|
||||
return len(hay) >= len(needle) && (func() bool {
|
||||
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||
if hay[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
@@ -88,7 +88,7 @@ type Loader interface {
|
||||
|
||||
Because the contract lives at the root and no implementation is imported
|
||||
with it, a service that never evaluates JavaScript does not compile one:
|
||||
goja and esbuild live in `zip/runtime`, a separate import that only the
|
||||
goja and esbuild live in `zip/js`, a separate import that only the
|
||||
binaries wanting JS pay for.
|
||||
|
||||
A real service binary constructs `*extruntime.Loader` from
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
"github.com/zap-proto/zip/middleware"
|
||||
"github.com/zap-proto/zip/runtime"
|
||||
"github.com/zap-proto/zip/js"
|
||||
)
|
||||
|
||||
//go:embed app.ts
|
||||
@@ -39,7 +39,7 @@ var appTS []byte
|
||||
// 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{
|
||||
src, err := js.TranspileToES5(appTS, js.ESOptions{
|
||||
Loader: "ts",
|
||||
Sourcefile: "app.ts",
|
||||
})
|
||||
@@ -48,16 +48,16 @@ func setup() (*zip.App, error) {
|
||||
}
|
||||
|
||||
// 2. Embedded JS runtime, register the module (module.exports = fn).
|
||||
rt, err := runtime.NewJSRuntime(runtime.JSOptions{PoolSize: 8})
|
||||
rt, err := js.NewJSRuntime(js.JSOptions{PoolSize: 8})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rt.LoadModule("app", string(js)); err != nil {
|
||||
if err := rt.LoadModule("app", string(src)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Fiber handler from the module's exported function.
|
||||
h, err := runtime.JSModule(rt, "app")
|
||||
h, err := js.JSModule(rt, "app")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func setup() (*zip.App, error) {
|
||||
// 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()
|
||||
runner := js.NewRunner()
|
||||
if err := runner.Register("js", rt.Engine()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,11 +115,11 @@ type RunOut struct {
|
||||
// runSource dispatches the source to the engine registered for :lang. An
|
||||
// unregistered language is a 404; an evaluation error is a 200 carrying the
|
||||
// error string, so the caller sees the engine's own message.
|
||||
func runSource(runner runtime.Runner) func(context.Context, *RunIn) (*RunOut, error) {
|
||||
func runSource(runner js.Runner) func(context.Context, *RunIn) (*RunOut, error) {
|
||||
return func(ctx context.Context, in *RunIn) (*RunOut, error) {
|
||||
res, err := runner.Run(ctx, in.Lang, []byte(in.Source))
|
||||
if err != nil {
|
||||
if errors.Is(err, runtime.ErrUnknownLanguage) {
|
||||
if errors.Is(err, js.ErrUnknownLanguage) {
|
||||
return nil, zip.ErrNotFound("unknown language")
|
||||
}
|
||||
return &RunOut{Error: err.Error()}, nil
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# zip/runtime — embedded multi-language code runner
|
||||
# zip/js — 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
|
||||
@@ -1,4 +1,4 @@
|
||||
package runtime
|
||||
package js
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -7,7 +7,7 @@
|
||||
// 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
|
||||
package js
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -55,7 +55,7 @@ func TranspileToES5(src []byte, opts ESOptions) ([]byte, error) {
|
||||
MinifySyntax: opts.Minify,
|
||||
})
|
||||
if len(result.Errors) > 0 {
|
||||
return nil, fmt.Errorf("zip/runtime: esbuild: %s", formatMessages(result.Errors))
|
||||
return nil, fmt.Errorf("zip/js: esbuild: %s", formatMessages(result.Errors))
|
||||
}
|
||||
return result.Code, nil
|
||||
}
|
||||
@@ -96,7 +96,7 @@ const virtualNamespace = "zip-virtual"
|
||||
// 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)
|
||||
return nil, fmt.Errorf("zip/js: bundle entry %q not in files map", entry)
|
||||
}
|
||||
entry = path.Clean(entry)
|
||||
|
||||
@@ -159,10 +159,10 @@ func BundleToES2015(entry string, files map[string][]byte, opts BundleOptions) (
|
||||
Plugins: []api.Plugin{plugin},
|
||||
})
|
||||
if len(result.Errors) > 0 {
|
||||
return nil, fmt.Errorf("zip/runtime: esbuild bundle: %s", formatMessages(result.Errors))
|
||||
return nil, fmt.Errorf("zip/js: esbuild bundle: %s", formatMessages(result.Errors))
|
||||
}
|
||||
if len(result.OutputFiles) == 0 {
|
||||
return nil, fmt.Errorf("zip/runtime: esbuild bundle produced no output")
|
||||
return nil, fmt.Errorf("zip/js: esbuild bundle produced no output")
|
||||
}
|
||||
return result.OutputFiles[0].Contents, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package runtime
|
||||
package js
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -10,7 +10,7 @@
|
||||
//
|
||||
// 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
|
||||
package js
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -40,7 +40,7 @@ func JSHandler(rt *JSRuntime, fnName string) fiber.Handler {
|
||||
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 fmt.Errorf("zip/js: %q is not a callable JS function", fnName)
|
||||
}
|
||||
return invokeJS(fc, vm, fn)
|
||||
})
|
||||
@@ -60,7 +60,7 @@ func JSModule(rt *JSRuntime, modulePath string) (fiber.Handler, error) {
|
||||
return err
|
||||
}
|
||||
if _, ok := goja.AssertFunction(exports); !ok {
|
||||
return fmt.Errorf("zip/runtime: module %q exports is not a function", modulePath)
|
||||
return fmt.Errorf("zip/js: module %q exports is not a function", modulePath)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -77,7 +77,7 @@ func JSModule(rt *JSRuntime, modulePath string) (fiber.Handler, error) {
|
||||
}
|
||||
fn, ok := goja.AssertFunction(exports)
|
||||
if !ok {
|
||||
return fmt.Errorf("zip/runtime: module %q exports is not a function", modulePath)
|
||||
return fmt.Errorf("zip/js: module %q exports is not a function", modulePath)
|
||||
}
|
||||
return invokeJS(fc, vm, fn)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
package runtime
|
||||
package js
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -1,4 +1,4 @@
|
||||
package runtime
|
||||
package js
|
||||
|
||||
import "github.com/zap-proto/zip/internal/jsonenc"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// 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
|
||||
package js
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -88,7 +88,7 @@ func (rt *JSRuntime) provision(vm *goja.Runtime) error {
|
||||
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)
|
||||
return fmt.Errorf("zip/js: register host fn %q: %w", name, err)
|
||||
}
|
||||
}
|
||||
if err := installRequire(vm, rt.modules); err != nil {
|
||||
@@ -185,7 +185,7 @@ func (rt *JSRuntime) InvokeFunc(ctx context.Context, fnName string, args ...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)
|
||||
return fmt.Errorf("zip/js: %q is not a callable JS function", fnName)
|
||||
}
|
||||
argv := make([]goja.Value, len(args))
|
||||
for i, a := range args {
|
||||
@@ -214,7 +214,7 @@ func (rt *JSRuntime) RegisterHostFn(name string, fn any) error {
|
||||
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 fmt.Errorf("zip/js: register host fn %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
package runtime
|
||||
package js
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package runtime
|
||||
package js
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -1,4 +1,4 @@
|
||||
package runtime
|
||||
package js
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -89,18 +89,18 @@ func requireModule(vm *goja.Runtime, name string) (goja.Value, error) {
|
||||
src, ok := t.sources[name]
|
||||
t.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("zip/runtime: module not found: %q", name)
|
||||
return nil, fmt.Errorf("zip/js: 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)
|
||||
return nil, fmt.Errorf("zip/js: 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)
|
||||
return nil, fmt.Errorf("zip/js: module %q did not compile to a function", name)
|
||||
}
|
||||
|
||||
module := vm.NewObject()
|
||||
@@ -109,7 +109,7 @@ func requireModule(vm *goja.Runtime, name string) (goja.Value, error) {
|
||||
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)
|
||||
return nil, fmt.Errorf("zip/js: eval module %q: %w", name, err)
|
||||
}
|
||||
|
||||
result := module.Get("exports")
|
||||
@@ -19,7 +19,7 @@
|
||||
// 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
|
||||
package js
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
|
||||
// ErrUnknownLanguage is returned by Run when no engine is registered for
|
||||
// the requested language.
|
||||
var ErrUnknownLanguage = errors.New("zip/runtime: unknown language")
|
||||
var ErrUnknownLanguage = errors.New("zip/js: unknown language")
|
||||
|
||||
// Engine executes source in one language. It is the zip-side projection
|
||||
// of base's extruntime backends; any value implementing Eval satisfies
|
||||
@@ -103,15 +103,15 @@ type runner struct {
|
||||
|
||||
func (r *runner) Register(lang string, engine Engine) error {
|
||||
if lang == "" {
|
||||
return fmt.Errorf("zip/runtime: Register: empty language")
|
||||
return fmt.Errorf("zip/js: Register: empty language")
|
||||
}
|
||||
if engine == nil {
|
||||
return fmt.Errorf("zip/runtime: Register %q: nil engine", lang)
|
||||
return fmt.Errorf("zip/js: 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",
|
||||
slog.Warn("zip/js: engine is not interruptible; ctx cancel will not abort a running call",
|
||||
"language", lang)
|
||||
}
|
||||
r.mu.Lock()
|
||||
@@ -1,4 +1,4 @@
|
||||
package runtime
|
||||
package js
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -12,4 +12,4 @@
|
||||
// ([github.com/zap-proto/zip.Loader], [github.com/zap-proto/zip.Module]) —
|
||||
// it is duck-typed, so an implementation registers itself by being passed
|
||||
// in, never by zip importing it.
|
||||
package runtime
|
||||
package js
|
||||
@@ -1,4 +1,4 @@
|
||||
package runtime
|
||||
package js
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -296,9 +296,23 @@ func (a *App) load(prefixes []string, spec Plugin) error {
|
||||
return fmt.Errorf("zip: Load(%s) needs at least one prefix", spec.Name)
|
||||
}
|
||||
prefix := prefixes[0]
|
||||
|
||||
// Claimed BEFORE anything is mounted or spawned: a composition that cannot
|
||||
// route this plugin must not first pay for its process, and a duplicate must
|
||||
// fail at compose time rather than become a live prefix serving another
|
||||
// plugin's 404. All-or-nothing — a plugin owning three prefixes and losing
|
||||
// the third leaves the first two free for whoever really owns them.
|
||||
for i, pre := range prefixes {
|
||||
if err := a.claim(pre, spec.Name); err != nil {
|
||||
a.release(prefixes[:i])
|
||||
return fmt.Errorf("zip: Load(%s): %w", spec.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if spec.Addr != "" {
|
||||
for _, pre := range prefixes {
|
||||
if err := a.Mount(pre, spec.Addr); err != nil {
|
||||
if err := a.mount(pre, spec.Addr); err != nil {
|
||||
a.release(prefixes)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -314,9 +328,11 @@ func (a *App) load(prefixes []string, spec Plugin) error {
|
||||
return nil
|
||||
}
|
||||
if len(spec.Bin) == 0 && spec.Path == "" && spec.URL == "" {
|
||||
a.release(prefixes)
|
||||
return fmt.Errorf("zip: Load(%s): plugin %q has no Addr, Bin, Path, or URL", prefix, spec.Name)
|
||||
}
|
||||
if spec.URL != "" && spec.Sum == "" {
|
||||
a.release(prefixes)
|
||||
return fmt.Errorf("zip: Load(%s): plugin %q has URL but no Sum — refusing to run an unverified download", prefix, spec.Name)
|
||||
}
|
||||
|
||||
@@ -325,6 +341,7 @@ func (a *App) load(prefixes []string, spec Plugin) error {
|
||||
if !spec.Lazy {
|
||||
var err error
|
||||
if in, err = start(spec); err != nil {
|
||||
a.release(prefixes)
|
||||
return fmt.Errorf("zip: Load(%s): %w", spec.Name, err)
|
||||
}
|
||||
p.cur.Store(in)
|
||||
@@ -336,6 +353,7 @@ func (a *App) load(prefixes []string, spec Plugin) error {
|
||||
}
|
||||
if _, dup := a.plugins[spec.Name]; dup {
|
||||
a.plugMu.Unlock()
|
||||
a.release(prefixes)
|
||||
stop(in, 0) // nil for a lazy plugin — stop handles that
|
||||
return fmt.Errorf("zip: Load(%s): plugin %q already loaded", prefix, spec.Name)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/zap-proto/zip/internal/jsonenc"
|
||||
)
|
||||
|
||||
// MCP — the THIRD projection. The same typed-op registry (a.ops) that produces
|
||||
// MCP — the THIRD projection. The same typed-op registry (a.registry) that produces
|
||||
// the REST routes and the OpenAPI doc also produces a Model Context Protocol
|
||||
// tool surface, for FREE: every typed handler (Get/Post[In,Out]) becomes an MCP
|
||||
// tool whose inputSchema is schemaOf(In) and whose call runs the exact same fn
|
||||
@@ -52,11 +52,11 @@ func (a *App) mcpName() string {
|
||||
// installMCP mounts the JSON-RPC 2.0 MCP endpoint when there are typed ops to
|
||||
// expose. Called from prepare() alongside installOpenAPIRoutes.
|
||||
func (a *App) installMCP() {
|
||||
if a.cfg.MCP.Disabled || len(a.ops) == 0 {
|
||||
if a.cfg.MCP.Disabled || len(a.registry) == 0 {
|
||||
return
|
||||
}
|
||||
a.fiber.Post(a.mcpPath(), a.handleMCP)
|
||||
a.logger.Info("zip mcp", "path", a.mcpPath(), "tools", len(a.ops))
|
||||
a.control(fiber.MethodPost, a.mcpPath(), a.handleMCP)
|
||||
a.logger.Info("zip mcp", "path", a.mcpPath(), "tools", len(a.registry))
|
||||
}
|
||||
|
||||
type mcpRequest struct {
|
||||
@@ -115,8 +115,8 @@ func (a *App) MCPTools() []map[string]any { return a.mcpTools() }
|
||||
// description. WithSummary remains the fallback for a package the generator has
|
||||
// not run over, so an undocumented op still names itself.
|
||||
func (a *App) mcpTools() []map[string]any {
|
||||
tools := make([]map[string]any, 0, len(a.ops))
|
||||
for _, op := range a.ops {
|
||||
tools := make([]map[string]any, 0, len(a.registry))
|
||||
for _, op := range a.registry {
|
||||
doc, hasDoc := docFor(op.Method, op.Path)
|
||||
desc := op.Summary
|
||||
if hasDoc && doc.Description != "" {
|
||||
@@ -168,7 +168,7 @@ func (a *App) mcpCall(fc fiber.Ctx, req mcpRequest) error {
|
||||
}
|
||||
|
||||
func (a *App) opByName(name string) *registeredOp {
|
||||
for _, op := range a.ops {
|
||||
for _, op := range a.registry {
|
||||
if opName(op) == name {
|
||||
return op
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ type Module interface {
|
||||
// identical result types, and an equivalent method set is not enough.
|
||||
//
|
||||
// The in-tree JavaScript implementation is package
|
||||
// [github.com/zap-proto/zip/runtime] — goja to evaluate and esbuild to
|
||||
// [github.com/zap-proto/zip/js] — goja to evaluate and esbuild to
|
||||
// bundle. It is a SEPARATE import on purpose: that cost belongs to the
|
||||
// binaries that ask for JS, not to every consumer of zip.
|
||||
type Loader interface {
|
||||
|
||||
+13
-4
@@ -16,6 +16,15 @@ import (
|
||||
"github.com/zap-proto/zip/internal/jsontag"
|
||||
)
|
||||
|
||||
// SpecPath and DocsPath are where an app serves its own OpenAPI document and
|
||||
// the interactive page over it. Both are zip's control plane — a projection of
|
||||
// the app, not a door its owner wrote — so neither appears in a [Declaration]
|
||||
// and a host serves its own.
|
||||
const (
|
||||
SpecPath = "/.well-known/openapi.json"
|
||||
DocsPath = "/docs"
|
||||
)
|
||||
|
||||
// OpenAPIConfig configures the auto-generated /.well-known/openapi.json
|
||||
// endpoint zip serves when typed handlers are registered.
|
||||
type OpenAPIConfig struct {
|
||||
@@ -32,17 +41,17 @@ type OpenAPIConfig struct {
|
||||
// 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 {
|
||||
if a.cfg.OpenAPI.Disabled || len(a.registry) == 0 {
|
||||
return
|
||||
}
|
||||
spec := a.buildOpenAPI()
|
||||
specJSON, _ := jsonenc.Marshal(spec)
|
||||
|
||||
a.fiber.Get("/.well-known/openapi.json", func(fc fiber.Ctx) error {
|
||||
a.control(fiber.MethodGet, SpecPath, func(fc fiber.Ctx) error {
|
||||
fc.Set("Content-Type", "application/json")
|
||||
return fc.Send(specJSON)
|
||||
})
|
||||
a.fiber.Get("/docs", func(fc fiber.Ctx) error {
|
||||
a.control(fiber.MethodGet, DocsPath, func(fc fiber.Ctx) error {
|
||||
fc.Set("Content-Type", "text/html; charset=utf-8")
|
||||
return fc.SendString(swaggerHTML)
|
||||
})
|
||||
@@ -68,7 +77,7 @@ func (a *App) buildOpenAPI() map[string]any {
|
||||
reg := newSchemaRegistry(specDefs)
|
||||
|
||||
// Sort ops by path,method for deterministic output.
|
||||
ops := append([]*registeredOp{}, a.ops...)
|
||||
ops := append([]*registeredOp{}, a.registry...)
|
||||
sort.Slice(ops, func(i, j int) bool {
|
||||
if ops[i].Path != ops[j].Path {
|
||||
return ops[i].Path < ops[j].Path
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
fiber "github.com/zap-proto/fiber/v3"
|
||||
)
|
||||
|
||||
// The ops surface — HIP-0119 §1 and §3, in one place.
|
||||
//
|
||||
// Every Hanzo service answers /healthz, /readyz and /metrics on a SECOND
|
||||
// listener, never on the public one: a liveness probe must not queue behind
|
||||
// public traffic, and a metrics endpoint on a public port is an information
|
||||
// leak. Before this, the paths were named by HIP-0119 and implemented by
|
||||
// nobody — OPS_PORT appeared in no Go file in the fleet — so conformance meant
|
||||
// 116 repositories about to hand-roll three routes and a listener each.
|
||||
//
|
||||
// It is not a separate verb. [App.Listen] brings the ops listener up when the
|
||||
// environment names one, which means:
|
||||
//
|
||||
// standalone the deployment sets OPS_PORT → the ops listener runs
|
||||
// under a host the host sets no OPS_PORT → it does not
|
||||
//
|
||||
// and that is exactly the split HIP-0106 §1.3(f) states: a child does not own
|
||||
// the ops listener, because the ops port belongs to the deployment and a
|
||||
// child's private socket is not one of the deployment's listeners.
|
||||
|
||||
// The ops paths, per HIP-0119 §3. They are zip's control plane on the ops app,
|
||||
// so they never appear in a [Declaration].
|
||||
const (
|
||||
HealthPath = "/healthz"
|
||||
ReadyPath = "/readyz"
|
||||
MetricsPath = "/metrics"
|
||||
)
|
||||
|
||||
// OpsPortEnv names the ops listener's port. HIP-0119 §5 spells it OPS_PORT;
|
||||
// OpsAddr also accepts a full address in ZIP_OPS_ADDR for a deployment that
|
||||
// needs to bind one interface.
|
||||
const (
|
||||
OpsPortEnv = "OPS_PORT"
|
||||
OpsAddrEnv = "ZIP_OPS_ADDR"
|
||||
)
|
||||
|
||||
// DefaultOpsPort is the port HIP-0119 §1 names, stated once so a deployment's
|
||||
// manifest and a local run agree. It is NOT a fallback: see [OpsAddr].
|
||||
const DefaultOpsPort = "9090"
|
||||
|
||||
// OpsAddr is the address this process's ops listener binds, or "" when this
|
||||
// process does not own one:
|
||||
//
|
||||
// $ZIP_OPS_ADDR a full address, always wins
|
||||
// $OPS_PORT a port, per HIP-0119 §5
|
||||
// otherwise "" — this process owns no ops listener
|
||||
//
|
||||
// There is deliberately NO fallback. A default would mean every process that
|
||||
// calls Listen tries to bind one port, and a plugin has more than one app in it
|
||||
// (its edge app and its peer app) while a test process has many — so the
|
||||
// SECOND listener in any of them dies on "address already in use", which is
|
||||
// exactly the failure HIP-0106 §2 names. The ops port is deployment
|
||||
// configuration (HIP-0119 §5) and a deployment states it; a host does not set
|
||||
// it on a child, so a child correctly owns none.
|
||||
func OpsAddr() string {
|
||||
if a := strings.TrimSpace(os.Getenv(OpsAddrEnv)); a != "" {
|
||||
return a
|
||||
}
|
||||
if p := strings.TrimSpace(os.Getenv(OpsPortEnv)); p != "" {
|
||||
if _, err := strconv.Atoi(p); err == nil {
|
||||
return ":" + p
|
||||
}
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Ops is this app's ops sibling: a second [App] serving /healthz, /readyz and
|
||||
// /metrics and nothing else, so the public listener carries no ops surface and
|
||||
// the ops listener carries no public routes.
|
||||
//
|
||||
// Memoized — one sibling per app, whoever asks — because two ops apps would
|
||||
// report two readiness answers for one process.
|
||||
func (a *App) Ops() *App {
|
||||
a.opsOnce.Do(func() {
|
||||
ops := New(Config{
|
||||
AppName: a.cfg.AppName + "-ops",
|
||||
Logger: a.logger,
|
||||
DisableStartupMessage: true,
|
||||
// The ops app has no typed ops, so it projects no document and needs
|
||||
// no MCP door; disabling them says so rather than relying on the
|
||||
// emptiness of the registry.
|
||||
OpenAPI: OpenAPIConfig{Disabled: true},
|
||||
MCP: MCPConfig{Disabled: true},
|
||||
})
|
||||
// Liveness: the process is running and answering. It deliberately does
|
||||
// NOT consult a dependency — a liveness probe that fails when a database
|
||||
// blinks restarts a healthy process and turns one outage into two.
|
||||
ops.control(fiber.MethodGet, HealthPath, func(fc fiber.Ctx) error {
|
||||
return fc.SendString("ok")
|
||||
})
|
||||
// Readiness: this process's own listeners are bound. That is the whole of
|
||||
// what zip knows and can prove; a readiness answer a plugin needs to
|
||||
// widen (a store opened, a cache warmed) it widens in its own handler.
|
||||
ops.control(fiber.MethodGet, ReadyPath, func(fc fiber.Ctx) error {
|
||||
if a.listening.Load() == 0 {
|
||||
return Errorf(503, "not listening yet")
|
||||
}
|
||||
return fc.SendString("ready")
|
||||
})
|
||||
ops.control(fiber.MethodGet, MetricsPath, func(fc fiber.Ctx) error {
|
||||
fc.Set(fiber.HeaderContentType, "text/plain; version=0.0.4; charset=utf-8")
|
||||
return fc.SendString(a.metrics())
|
||||
})
|
||||
ops.sibling = true // a sibling has no siblings of its own
|
||||
a.ops = ops
|
||||
})
|
||||
return a.ops
|
||||
}
|
||||
|
||||
// metrics is what zip actually knows about this process, in Prometheus text.
|
||||
// Every value is read from a live structure — the op registry, the router, the
|
||||
// plugin table, the process's own start — so none of it can be stale and none of
|
||||
// it is a placeholder. A plugin's own metrics are its own to add.
|
||||
func (a *App) metrics() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# HELP zip_up 1 when the app's listeners are bound.\n# TYPE zip_up gauge\nzip_up ")
|
||||
if a.listening.Load() > 0 {
|
||||
b.WriteString("1\n")
|
||||
} else {
|
||||
b.WriteString("0\n")
|
||||
}
|
||||
b.WriteString("# HELP zip_uptime_seconds Seconds since the app was constructed.\n")
|
||||
b.WriteString("# TYPE zip_uptime_seconds gauge\nzip_uptime_seconds ")
|
||||
b.WriteString(strconv.FormatFloat(time.Since(a.born).Seconds(), 'f', 3, 64))
|
||||
b.WriteString("\n# HELP zip_ops Registered typed operations.\n# TYPE zip_ops gauge\nzip_ops ")
|
||||
b.WriteString(strconv.Itoa(len(a.registry)))
|
||||
b.WriteString("\n# HELP zip_routes Registered routes.\n# TYPE zip_routes gauge\nzip_routes ")
|
||||
b.WriteString(strconv.Itoa(len(a.fiber.GetRoutes(false))))
|
||||
b.WriteString("\n")
|
||||
if st := a.Plugins(); len(st) > 0 {
|
||||
b.WriteString("# HELP zip_plugin_running 1 when a composed plugin has a live instance.\n")
|
||||
b.WriteString("# TYPE zip_plugin_running gauge\n")
|
||||
for _, s := range st {
|
||||
b.WriteString(`zip_plugin_running{name="`)
|
||||
b.WriteString(s.Name)
|
||||
b.WriteString(`"} `)
|
||||
if s.Running {
|
||||
b.WriteString("1\n")
|
||||
} else {
|
||||
b.WriteString("0\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// serveOps brings the ops listener up alongside the public one when this
|
||||
// process owns an ops port. Called from Listen, so a plugin main ends with one
|
||||
// verb and the environment decides whether there is a second listener.
|
||||
//
|
||||
// Only the PRIMARY app does this. An app's siblings (its ops app, its peer app)
|
||||
// are the same process and report through the primary's surface.
|
||||
func (a *App) serveOps() {
|
||||
if a.sibling || a.ops != nil {
|
||||
return // a sibling serves no siblings; and Listen may be called twice
|
||||
}
|
||||
addr := OpsAddr()
|
||||
if addr == "" {
|
||||
return
|
||||
}
|
||||
ops := a.Ops()
|
||||
go func() {
|
||||
if err := ops.Listen(addr); err != nil {
|
||||
a.logger.Error("zip ops listener stopped", "addr", addr, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The ops surface is a SECOND listener. HIP-0119 §1 wants /metrics off the
|
||||
// public port, and a probe that queues behind public traffic is not a probe.
|
||||
func TestOpsSurfaceIsASecondListener(t *testing.T) {
|
||||
port := freePort(t)
|
||||
t.Setenv(zip.OpsAddrEnv, "http://127.0.0.1:"+port)
|
||||
t.Setenv(zip.RuntimeDirEnv, t.TempDir())
|
||||
|
||||
app := zip.New(zip.Config{AppName: "opsy", DisableStartupMessage: true})
|
||||
zip.Get(app, "/v1/opsy/thing", func(ctx context.Context, in *askIn) (*askOut, error) {
|
||||
return &askOut{Org: "x"}, nil
|
||||
}, zip.WithOperationID("opsy_thing"))
|
||||
|
||||
pub := freePort(t)
|
||||
go func() { _ = app.Listen("http://127.0.0.1:" + pub) }()
|
||||
t.Cleanup(func() { _ = app.Shutdown(); _ = app.Ops().Shutdown() })
|
||||
waitHTTP(t, "http://127.0.0.1:"+port+zip.HealthPath)
|
||||
|
||||
for _, p := range []string{zip.HealthPath, zip.ReadyPath, zip.MetricsPath} {
|
||||
if code, _ := get(t, "http://127.0.0.1:"+port+p); code != 200 {
|
||||
t.Fatalf("ops %s = %d, want 200", p, code)
|
||||
}
|
||||
// The same path on the PUBLIC listener must not answer.
|
||||
if code, _ := get(t, "http://127.0.0.1:"+pub+p); code == 200 {
|
||||
t.Fatalf("public listener served %s — /metrics is not a public door", p)
|
||||
}
|
||||
}
|
||||
// And the ops listener serves none of the app's own routes.
|
||||
if code, _ := get(t, "http://127.0.0.1:"+port+"/v1/opsy/thing"); code == 200 {
|
||||
t.Fatal("the ops listener served a product route")
|
||||
}
|
||||
|
||||
// The metrics are read off live structures, so they carry this app's real
|
||||
// registry and route counts rather than a placeholder.
|
||||
_, body := get(t, "http://127.0.0.1:"+port+zip.MetricsPath)
|
||||
for _, want := range []string{"zip_up 1", "zip_ops 1", "zip_uptime_seconds", "zip_routes"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("metrics missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// There is NO default ops port, and that is the point. A default meant every
|
||||
// process calling Listen tried to bind one port — and a plugin has two apps in
|
||||
// it while a test process has many, so the second died on "address already in
|
||||
// use". A host sets no OPS_PORT on a child, so a child owns none.
|
||||
func TestThereIsNoDefaultOpsPort(t *testing.T) {
|
||||
t.Setenv(zip.OpsAddrEnv, "")
|
||||
t.Setenv(zip.OpsPortEnv, "")
|
||||
if got := zip.OpsAddr(); got != "" {
|
||||
t.Fatalf("OpsAddr = %q with nothing configured — a surprise bind", got)
|
||||
}
|
||||
t.Setenv(zip.OpsPortEnv, zip.DefaultOpsPort)
|
||||
if got := zip.OpsAddr(); got != ":9090" {
|
||||
t.Fatalf("OPS_PORT ignored: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Two apps in one process — the edge app and its peer sibling — must not race
|
||||
// for one ops port. Only the primary brings the surface up.
|
||||
func TestOnlyThePrimaryAppOwnsTheOpsListener(t *testing.T) {
|
||||
t.Setenv(zip.RuntimeDirEnv, t.TempDir())
|
||||
t.Setenv(zip.OpsPortEnv, freePort(t))
|
||||
|
||||
app := zip.New(zip.Config{AppName: "twoapps", DisableStartupMessage: true})
|
||||
zip.Post(app.Peer(), "/v1/twoapps/peer", func(ctx context.Context, in *askIn) (*askOut, error) {
|
||||
return &askOut{Org: "peer"}, nil
|
||||
}, zip.WithOperationID("twoapps_peer"))
|
||||
zip.Get(app, "/v1/twoapps/edge", func(ctx context.Context, in *askIn) (*askOut, error) {
|
||||
return &askOut{Org: "edge"}, nil
|
||||
}, zip.WithOperationID("twoapps_edge"))
|
||||
|
||||
pub := freePort(t)
|
||||
go func() { _ = app.Listen("http://127.0.0.1:" + pub) }()
|
||||
t.Cleanup(func() { _ = app.Shutdown(); _ = app.Ops().Shutdown(); _ = app.Peer().Shutdown() })
|
||||
waitHTTP(t, "http://127.0.0.1:"+pub+"/v1/twoapps/edge?ref=x")
|
||||
|
||||
// One verb brought up three listeners: edge, peer socket, ops port.
|
||||
waitSock(t, zip.SocketPath("twoapps"))
|
||||
ctx := zip.WithCaller(context.Background(), zip.Caller{User: "u", Org: "o"})
|
||||
out, err := zip.Ask[askIn, askOut](ctx, "twoapps", "twoapps_peer", &askIn{})
|
||||
if err != nil || out.Org != "peer" {
|
||||
t.Fatalf("Ask over the peer socket: %v %+v", err, out)
|
||||
}
|
||||
// The peer op is NOT on the edge: a typed op rides every transport its app
|
||||
// listens on, which is why the separation has to be two apps.
|
||||
if code, _ := get(t, "http://127.0.0.1:"+pub+"/v1/twoapps/peer"); code == 200 {
|
||||
t.Fatal("an internal op answered on the public listener")
|
||||
}
|
||||
if _, err := zip.Ask[askIn, askOut](ctx, "twoapps", "twoapps_edge", &askIn{}); err == nil {
|
||||
t.Fatal("an edge op answered on the peer socket")
|
||||
}
|
||||
}
|
||||
|
||||
// The ops paths are control plane, so they never appear in a declaration — a
|
||||
// plugin declaring /metrics would claim it for the whole composition.
|
||||
func TestOpsPathsAreNotDeclared(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "d", DisableStartupMessage: true})
|
||||
app.Get("/v1/d/x", func(c *zip.Ctx) error { return nil })
|
||||
for _, r := range app.Ops().Declaration().Routes {
|
||||
if r.Pattern == zip.MetricsPath || r.Pattern == zip.HealthPath || r.Pattern == zip.ReadyPath {
|
||||
t.Fatalf("the ops app declared its own control plane: %v", r)
|
||||
}
|
||||
}
|
||||
for _, r := range app.Declaration().Routes {
|
||||
if r.Pattern == zip.MetricsPath {
|
||||
t.Fatalf("a product app declared /metrics: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
_, port, _ := net.SplitHostPort(l.Addr().String())
|
||||
return port
|
||||
}
|
||||
|
||||
func get(t *testing.T, url string) (int, string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return 0, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package zip
|
||||
|
||||
// The peer surface — one app's ops as another app may invoke them.
|
||||
//
|
||||
// A typed op rides EVERY transport its app listens on. So an internal op
|
||||
// registered on the edge-facing app is an internal op served on :8000, and the
|
||||
// separation between "what the world may call" and "what a peer may call" has to
|
||||
// be structural rather than a check somebody remembers to write.
|
||||
//
|
||||
// It is a second [App], listening only on [SocketPath], and it is reached with
|
||||
// [Ask]. That is the whole of the internal call plane: no registry, no discovery,
|
||||
// no address in a caller's configuration — the name IS the address.
|
||||
|
||||
// Peer is this app's peer sibling: the ops a peer may invoke, served ONLY on
|
||||
// this app's canonical socket and never on the edge.
|
||||
//
|
||||
// svc.ops(app) // the world's surface
|
||||
// svc.plane(app.Peer()) // the fleet's surface
|
||||
// return app.Listen(zip.Addr(":9653"))
|
||||
//
|
||||
// [App.Listen] brings it up, so there is one serve verb and no second Listen to
|
||||
// forget. It exists only when something registered on it: an app with no peer
|
||||
// ops binds no peer socket, and one that has them binds exactly the path every
|
||||
// caller's [Ask] resolves.
|
||||
//
|
||||
// Memoized, because two peer apps would be two answers to one op name.
|
||||
func (a *App) Peer() *App {
|
||||
a.peerOnce.Do(func() {
|
||||
p := New(Config{
|
||||
AppName: a.cfg.AppName,
|
||||
Logger: a.logger,
|
||||
DisableStartupMessage: true,
|
||||
// A peer surface publishes no document and serves no agent door: it is
|
||||
// reached by name and op token from inside the fleet, and its schema is
|
||||
// the Go types both ends import.
|
||||
OpenAPI: OpenAPIConfig{Disabled: true},
|
||||
MCP: MCPConfig{Disabled: true},
|
||||
})
|
||||
p.sibling = true
|
||||
a.peer = p
|
||||
})
|
||||
return a.peer
|
||||
}
|
||||
|
||||
// servePeer binds the peer sibling to this app's canonical socket. Called from
|
||||
// Listen on the PRIMARY app only, and only when peer ops were registered — a
|
||||
// plugin that answers no peer op should not create a socket other processes can
|
||||
// dial and get nothing from.
|
||||
func (a *App) servePeer() {
|
||||
if a.sibling || a.peer == nil || len(a.peer.registry) == 0 {
|
||||
return
|
||||
}
|
||||
if a.cfg.AppName == "" {
|
||||
a.logger.Error("zip: peer ops registered on an app with no AppName — " +
|
||||
"the name IS the socket, so there is nowhere to serve them")
|
||||
return
|
||||
}
|
||||
sock := SocketPath(a.cfg.AppName)
|
||||
peer := a.peer
|
||||
go func() {
|
||||
if err := peer.Listen(sock); err != nil {
|
||||
a.logger.Error("zip peer listener stopped", "addr", sock, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The tenancy read, once, for every transport.
|
||||
//
|
||||
// "CallerOf(ctx).Org != empty" is NOT the check, and the reason is the whole of
|
||||
// why this lives in zip rather than in each callee. X-Org-Id on its own is a
|
||||
// client-settable header: a caller reaching a service directly — in-cluster, or
|
||||
// on a public host whose edge has not yet been locked down — can present an org
|
||||
// with NO validated user beside it. "The org is non-empty" is then a statement
|
||||
// the caller made about itself, and the value is retained past the request as a
|
||||
// store key and a ledger key.
|
||||
//
|
||||
// Where the principal COMES FROM is IAM's (HIP-0134): it is established once,
|
||||
// nothing re-derives it, and a call carries it as delegation rather than
|
||||
// assertion. Checking that one ARRIVED and is bounded before using it as a key
|
||||
// is the callee's, and it is not a second implementation of IAM.
|
||||
|
||||
// MaxOrgLen bounds the org key. The org is the validated IAM owner claim — a
|
||||
// short DNS-ish label — so anything longer is malformed or hostile and is
|
||||
// refused before it can become a storage key or a namespace.
|
||||
const MaxOrgLen = 128
|
||||
|
||||
// Tenant is the tenant a call may act for, or ("", false).
|
||||
//
|
||||
// It returns false — not an empty string a caller might use anyway — on each of
|
||||
// three conditions, all of which are the same defect at different depths:
|
||||
//
|
||||
// no validated User claim the org that rode along is the caller's own assertion
|
||||
// an empty Org there is no tenant to act for
|
||||
// an Org over MaxOrgLen malformed or hostile, and it is about to be a key
|
||||
//
|
||||
// The org is returned VERBATIM apart from surrounding space: never lower-cased,
|
||||
// never truncated. Folding collapses distinct owners ("acme", "ACME", a 32-char
|
||||
// prefix) into one bucket, which is itself a cross-tenant break.
|
||||
//
|
||||
// A handler that touches per-tenant state calls this and refuses !ok. A handler
|
||||
// that re-derives the rule is the sixth copy this function exists to delete.
|
||||
func Tenant(ctx context.Context) (string, bool) {
|
||||
c := CallerOf(ctx)
|
||||
if strings.TrimSpace(c.User) == "" {
|
||||
return "", false
|
||||
}
|
||||
org := strings.TrimSpace(c.Org)
|
||||
if org == "" || len(org) > MaxOrgLen {
|
||||
return "", false
|
||||
}
|
||||
return strings.Clone(org), true
|
||||
}
|
||||
|
||||
// Delegate is the context a background continuation runs under: the in-flight
|
||||
// request's caller, with the org replaced.
|
||||
//
|
||||
// It is for the one case a request handler legitimately acts for another
|
||||
// tenant — a platform operator's fan-out, a cross-org reconciliation — and it
|
||||
// keeps the ACTING user, so the audit trail still names a person. Passing an
|
||||
// org through an argument instead is the thing §6 forbids: an org in the
|
||||
// argument is an org the caller chose.
|
||||
//
|
||||
// The returned context carries no request, so it outlives the handler: both
|
||||
// transports reuse the underlying request for the next call on the connection,
|
||||
// and a goroutine holding one reads another caller's headers.
|
||||
func Delegate(c *Ctx, org string) context.Context {
|
||||
as := CallerOf(c.Forward())
|
||||
as.Org = strings.TrimSpace(org)
|
||||
return WithCaller(context.Background(), as)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The naive rule — "the org is non-empty" — admits exactly the forge this
|
||||
// function exists to refuse: an org with no validated user beside it is a
|
||||
// statement the caller made about itself, and it is about to become a store key.
|
||||
func TestTenantRefusesTheThreeWaysAnOrgIsNotOne(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
c zip.Caller
|
||||
}{
|
||||
{"no caller at all", zip.Caller{}},
|
||||
{"an org with no validated user", zip.Caller{Org: "victim"}},
|
||||
{"a user with no org", zip.Caller{User: "u_1"}},
|
||||
{"a blank org", zip.Caller{User: "u_1", Org: " "}},
|
||||
{"an org past the bound", zip.Caller{User: "u_1", Org: strings.Repeat("a", zip.MaxOrgLen+1)}},
|
||||
} {
|
||||
ctx := zip.WithCaller(context.Background(), tc.c)
|
||||
if org, ok := zip.Tenant(ctx); ok {
|
||||
t.Fatalf("%s: admitted %q", tc.name, org)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verbatim: trimmed, never folded. "acme", "ACME" and a truncated prefix are
|
||||
// three distinct owners, and collapsing them is itself a cross-tenant break.
|
||||
func TestTenantIsVerbatim(t *testing.T) {
|
||||
ctx := zip.WithCaller(context.Background(), zip.Caller{User: "u_1", Org: " ACME-Prod "})
|
||||
org, ok := zip.Tenant(ctx)
|
||||
if !ok || org != "ACME-Prod" {
|
||||
t.Fatalf("Tenant = %q, %v — want ACME-Prod, true", org, ok)
|
||||
}
|
||||
at := zip.Caller{User: "u_1", Org: strings.Repeat("a", zip.MaxOrgLen)}
|
||||
if org, ok := zip.Tenant(zip.WithCaller(context.Background(), at)); !ok || len(org) != zip.MaxOrgLen {
|
||||
t.Fatalf("the bound is inclusive: got %d, %v", len(org), ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate keeps the ACTING user, so the audit trail still names a person, and
|
||||
// detaches from the request, so the continuation can outlive the handler.
|
||||
func TestDelegateKeepsTheActorAndSurvivesTheRequest(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
|
||||
c := app.TestCtx("GET", "/x")
|
||||
c.Fiber().Request().Header.Set(zip.HeaderUser, "u_admin")
|
||||
c.Fiber().Request().Header.Set(zip.HeaderOrg, "admin")
|
||||
|
||||
ctx := zip.Delegate(c, "acme")
|
||||
org, ok := zip.Tenant(ctx)
|
||||
if !ok || org != "acme" {
|
||||
t.Fatalf("Tenant = %q, %v — want acme, true", org, ok)
|
||||
}
|
||||
if got := zip.CallerOf(ctx).User; got != "u_admin" {
|
||||
t.Fatalf("acting user = %q, want u_admin", got)
|
||||
}
|
||||
// Reset the request as a reused connection would: the delegated context
|
||||
// must not read whatever landed next.
|
||||
c.Fiber().Request().Header.Reset()
|
||||
if org, ok := zip.Tenant(ctx); !ok || org != "acme" {
|
||||
t.Fatalf("after the request was reused: %q, %v — the context borrowed it", org, ok)
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,7 @@ func (a *App) prepare() {
|
||||
a.installOpenAPIRoutes()
|
||||
a.installMCP()
|
||||
a.installCallPlane()
|
||||
a.installPluginRoute()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,6 +196,18 @@ func (a *App) Listen(addrs ...string) error {
|
||||
a.srvMu.Lock()
|
||||
a.servers = servers
|
||||
a.srvMu.Unlock()
|
||||
a.listening.Add(int32(len(servers)))
|
||||
|
||||
// The two listeners a plugin main does not have to write. Each appears only
|
||||
// when the app actually has the thing it serves, so one verb — Listen —
|
||||
// covers a plugin run standalone and the same plugin run as a child.
|
||||
//
|
||||
// HIP-0119 §1: the ops surface is a SECOND listener, up only when the
|
||||
// deployment named an ops port (a host names none on a child).
|
||||
a.serveOps()
|
||||
// HIP-0106 §2(3): peer ops ride a SECOND app served only on the canonical
|
||||
// socket, so an internal op is never reachable on the edge.
|
||||
a.servePeer()
|
||||
|
||||
// Serve every transport concurrently; return the first error (Shutdown
|
||||
// closes the rest via closeServers).
|
||||
@@ -225,6 +238,18 @@ func (a *App) Listen(addrs ...string) error {
|
||||
// without relinking it. Moving a service between Mount and a linked-in route
|
||||
// tree is a deployment decision, not a code change.
|
||||
func (a *App) Mount(prefix, addr string) error {
|
||||
// A bare Mount is its own claimant, and the address is the only name it has.
|
||||
if err := a.claim(prefix, addr); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.mount(prefix, addr)
|
||||
}
|
||||
|
||||
// mount registers prefix onto a dialled address. The prefix is already CLAIMED
|
||||
// by the caller — [Mount] claims its one, [App.load] claims a plugin's whole set
|
||||
// all-or-nothing before spawning anything — so this does not claim again and
|
||||
// there is exactly one claim per prefix however the composition came in.
|
||||
func (a *App) mount(prefix, addr string) error {
|
||||
scheme, hostport, t, err := transportFor(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -243,6 +268,10 @@ func (a *App) Mount(prefix, addr string) error {
|
||||
// returns, so the router is never touched twice. Re-registering routes on
|
||||
// every reload would grow the route table without bound — the leak that makes
|
||||
// naive hot-reload untenable.
|
||||
//
|
||||
// Registering the route is a separate concern from CLAIMING the prefix (see
|
||||
// [App.claim]): a reload re-resolves the target without re-registering, and an
|
||||
// ownership assertion happens once, at the door the composition came in.
|
||||
func (a *App) mountVia(prefix string, to func() (Client, string)) {
|
||||
h := func(c *Ctx) error {
|
||||
client, host := to()
|
||||
@@ -262,6 +291,40 @@ func (a *App) mountVia(prefix string, to func() (Client, string)) {
|
||||
a.All(prefix+"/*", h)
|
||||
}
|
||||
|
||||
// claim records owner as the holder of prefix, or refuses because someone else
|
||||
// already holds it. The comparison is on the NORMALISED prefix, so "/v1/x" and
|
||||
// "/v1/x/" are one claim and not two.
|
||||
//
|
||||
// This catches an exact duplicate at compose time, with a message naming both
|
||||
// claimants. It does NOT catch SHADOWING — a parameter sibling swallowing
|
||||
// another plugin's deeper static path, which is sometimes legitimate — which is
|
||||
// why a host also runs the router oracle over the composed union (HIP-0106
|
||||
// §3.5). Two checks, two distinct failures, not two ways to do one thing.
|
||||
func (a *App) claim(prefix, owner string) error {
|
||||
key := strings.TrimSuffix(normPath(prefix), "/")
|
||||
a.plugMu.Lock()
|
||||
defer a.plugMu.Unlock()
|
||||
if held, dup := a.claims[key]; dup {
|
||||
return fmt.Errorf("zip: %q is already claimed by %q — %q cannot have it too", key, held, owner)
|
||||
}
|
||||
if a.claims == nil {
|
||||
a.claims = map[string]string{}
|
||||
}
|
||||
a.claims[key] = owner
|
||||
return nil
|
||||
}
|
||||
|
||||
// release drops a claim, so Unload frees the prefix its plugin held and a
|
||||
// replacement can take it. Without this an Unload+Load cycle refuses its own
|
||||
// second half.
|
||||
func (a *App) release(prefixes []string) {
|
||||
a.plugMu.Lock()
|
||||
defer a.plugMu.Unlock()
|
||||
for _, p := range prefixes {
|
||||
delete(a.claims, strings.TrimSuffix(normPath(p), "/"))
|
||||
}
|
||||
}
|
||||
|
||||
// makeSocketDir creates the directory a unix socket will be bound in, so
|
||||
// serving at the canonical zip.SocketPath("name") works on a host where the
|
||||
// runtime dir does not exist yet — which is every host, the first time.
|
||||
|
||||
@@ -265,7 +265,7 @@ func registerTyped[In, Out any](on OpTarget, method, path string, fn TypedHandle
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
app.ops = append(app.ops, op)
|
||||
app.registry = append(app.registry, op)
|
||||
|
||||
handler := func(c fiber.Ctx) error {
|
||||
// hasBody is THE rule about what a method carries, read here as well as
|
||||
|
||||
@@ -21,13 +21,15 @@
|
||||
// func Call[I, O](ctx, *Conn, op string, in *I) (*O, error)
|
||||
// ...
|
||||
//
|
||||
// All other behavior lives in subpackages: `middleware`, `runtime`.
|
||||
// All other behavior lives in subpackages: `middleware`, `js`.
|
||||
package zip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
"github.com/zap-proto/fiber/v3"
|
||||
@@ -61,7 +63,7 @@ type Config struct {
|
||||
// must return map[string][Module] exactly, so a backend with its own
|
||||
// Module type (e.g. hanzoai/base/plugins/extruntime) needs a thin adapter,
|
||||
// not a direct assignment. For JavaScript, import
|
||||
// [github.com/zap-proto/zip/runtime] — a separate import so its goja +
|
||||
// [github.com/zap-proto/zip/js] — a separate import so its goja +
|
||||
// esbuild cost lands only on binaries that evaluate JS.
|
||||
Loader Loader
|
||||
|
||||
@@ -77,9 +79,23 @@ type Config struct {
|
||||
// BodyLimit is the maximum request body size (default 4 MiB).
|
||||
BodyLimit int
|
||||
|
||||
// AppName forwards to fiber.Config.AppName.
|
||||
// AppName forwards to fiber.Config.AppName. It is also the plugin's ONE
|
||||
// name: the binary's name, its socket's stem ([SocketPath]), its
|
||||
// [Declaration].Name and its <org>-<app> IAM segment. No mapping table.
|
||||
AppName string
|
||||
|
||||
// Eager says this app's work is NOT request-driven: it owns a listener, a
|
||||
// consumer or a background loop, so a host MUST start it rather than defer
|
||||
// it to the first request that reaches one of its routes.
|
||||
//
|
||||
// It is the ONE fact about an app that its router cannot show, which is why
|
||||
// it is stated here and travels in the [Declaration]. Everything else a host
|
||||
// needs to route is projected from the router itself.
|
||||
//
|
||||
// A host reads it as the ceiling on its own deferral choice
|
||||
// ([Plugin].Lazy): deferring an eager app is the mismatch §3.4 rejects.
|
||||
Eager bool
|
||||
|
||||
// DisableStartupMessage suppresses Fiber's startup banner.
|
||||
DisableStartupMessage bool
|
||||
|
||||
@@ -119,12 +135,15 @@ type Config struct {
|
||||
// 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 Loader
|
||||
fiber *fiber.App
|
||||
ops []*registeredOp
|
||||
servers []Server // the running transport listeners, set by Listen
|
||||
cfg Config
|
||||
logger luxlog.Logger
|
||||
loader Loader
|
||||
fiber *fiber.App
|
||||
// registry is THE op registry — the one entry a typed op makes, from which
|
||||
// the OpenAPI document, the MCP tool, the CLI command and the plane op are
|
||||
// all projected. Four documents, one registration.
|
||||
registry []*registeredOp
|
||||
servers []Server // the running transport listeners, set by Listen
|
||||
|
||||
// authorizer, when set via Authorize, runs at every typed op's invoke seam
|
||||
// on the decoded In — the one place REST and MCP both funnel the value the
|
||||
@@ -143,7 +162,30 @@ type App struct {
|
||||
// Running plugins, by name, so Reload can find one after Load composed it.
|
||||
// Guarded by plugMu.
|
||||
plugins map[string]*plugin
|
||||
plugMu sync.Mutex
|
||||
|
||||
// claims maps a mounted prefix to the name that claimed it, so a SECOND
|
||||
// claim of the same prefix is an error instead of a silent no-op. Guarded by
|
||||
// plugMu.
|
||||
claims map[string]string
|
||||
plugMu sync.Mutex
|
||||
|
||||
// The ops sibling (/healthz, /readyz, /metrics) and the listener count its
|
||||
// readiness reports. born is the process's own clock for zip_uptime_seconds.
|
||||
ops *App // the /healthz + /readyz + /metrics sibling; see App.Ops
|
||||
opsOnce sync.Once
|
||||
peer *App // the peer-op sibling served on the canonical socket; see App.Peer
|
||||
peerOnce sync.Once
|
||||
// sibling marks an App that IS one of the two above, so it does not grow
|
||||
// siblings of its own and does not try to bind the process's ops port.
|
||||
sibling bool
|
||||
listening atomic.Int32
|
||||
born time.Time
|
||||
|
||||
// controls is zip's OWN routes, keyed "METHOD path" — the openapi document,
|
||||
// the docs page, the MCP door, the op plane, this app's declaration. Written
|
||||
// only by App.control, read only by Declaration, so a control route cannot
|
||||
// be added without being excluded from every plugin's declaration.
|
||||
controls map[string]bool
|
||||
|
||||
prepareOnce sync.Once // installs deferred routes (OpenAPI, MCP) exactly once
|
||||
}
|
||||
@@ -194,6 +236,7 @@ func New(cfg Config) *App {
|
||||
logger: cfg.Logger,
|
||||
loader: cfg.Loader,
|
||||
fiber: fiber.New(fcfg),
|
||||
born: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user