mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
main
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5f71d2496b |
The router is three nouns, and every one of them is concrete
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:
users := app.Group("/v1/users")
users.Post("/", api.Create)
mint := users.Group("/mint").With(middleware.Mint(gate))
mint.Post("/deposit", api.Deposit)
A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.
That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.
Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
a175872aab |
The wire does not move with the toolchain
jsonenc chose encoding/json/v2 on the goexperiment.jsonv2 tag, and Go 1.27 sets
that tag by default. Moving a module's go directive to 1.27 therefore changed what
every zip service writes: a zero number or false kept under omitempty, a nil slice
as [] and a nil map as {}, field names matched case-sensitively, map keys unsorted.
hanzoai/cloud went from green to 54 failing wire tests on that bump alone.
jsonenc is stdlib encoding/json and nothing else. Since 1.27 that API runs on the
v2 engine with v1 semantics, so the speed arrives with the toolchain and the bytes
stay put. A test pins the four differences.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
cf68b751f3 |
Shadow is removed: a braided program is unbraided, not accommodated
v1.24.2 shipped two fixes. The operation-id one stays and is untouched here: a declared WithOperationID survives composition verbatim (measured on hanzoai/o11y — 353 ops, 217 declare an id, all 217 were being renamed by a host prefix and now are not). The address one, App.Shadow(), is removed. Shadow existed to make an op declarable in one place and answerable in another: a service installing its published table of ops onto the router its implementation already serves on, a test registering a second handler at an address deliberately. Both are the same shape — a declaration and the handler that satisfies it, written apart, with a framework verb holding the two ends together. That is a braid, and a framework that grows a verb per braid grows one verb per caller. The fix belongs in the caller: a typed registration is ALREADY both halves, the handler the router installs and the op the document, MCP tool list, CLI and SDK all read, so the declaration and the answer can simply be the same line. Use composes definitions, Group scopes them, and between them every shape Shadow was written for is expressible with one claim per address. So the address check goes back to what it was, exactly: one registration at an address, both parties named when there are two, no scope and no option that can talk it out of an accident. addrClaim, addressErrors and addressRules are gone with it — 190 lines of walk.go that existed only to reason about the SHAPE of multiple claims at one address, a question the language no longer asks. Removed: App.Shadow, OpScope.Shadow, route.shadow, App.shadow, wrapRouter.shadow and yields, App.method's shadow parameter. wrapChain and OpScope lose their nil-wrap branches, which existed only for a Shadow-only scope with no middleware to compose. No caller outside this repo referenced any of it at a released tag. Tests: shadow_test.go deleted (250 lines, ten cases, all about the removed verb). oneclaim_test.go replaces it with three that pin the rule from both sides — the braid is refused with both parties named, one registration declares AND answers, and Use+Group place a definition's ops wherever a host wants them with one claim per address. Everything else is unchanged, including all of declaredid_test.go and the unnamedApp/billingApp split in compose_test.go, which are the id fix and not this one. 319 root-package tests pass on a clean testcache and under -race. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
0ce276b638 |
a declared id is not composition's to edit, and a yielded address has a spelling
Two defects, one theme: a declaration the programmer WROTE is not something the
shape around it gets to overrule.
occurrenceID qualified every operation id with its occurrence's prefix,
including one the author had written with WithOperationID. Including
github.com/hanzoai/o11y under /v1/o11y therefore renamed its published ops to
v1.o11y.<id> — every MCP tool an agent had cached, every operationId, every CLI
command, every generated SDK method — as a silent side effect of one wiring
line. LLM.md's own Graft table has said `operationIds | untouched` since v1.18;
the walk had stopped obeying it. Measured on o11y: 217 of its 353 ops carry a
declared id, and all 217 were renamed by a host prefix. They are not now.
The rule asks one question first. A DECLARED id survives verbatim, because it is
a published name and a service embedded at a prefix publishes the same
operations it publishes standalone. An UNDECLARED one is still qualified by its
occurrence's prefix, deterministically and from the composition's shape, because
there no promise exists and a real ambiguity does: one definition included twice
is two operations and a document cannot hold two under one id. Where the two
meet — declared AND twice — is now a refused program that names both occurrences
and both fixes, instead of a silent rename that resolved an ambiguity the author
never saw.
Two registrations at one address was a refused program, and that refusal is
right. It also refused two shapes the estate writes on purpose: a service
installing its own published TABLE of ops onto the router its implementation
already answers on (o11y: 353 ops, publish() after the service's own routes —
"order is the contract"), and a second handler at an address declared to pin
what the router does with two (cloud's openapi generator test). Nothing in the
program said which it was, so the check could not tell them from an accident.
App.Shadow() says it. Scoped like With, and for the same reason — a group is an
App, so app.Shadow().Group(p) carries the property onto the group and every leaf
beneath it inherits it, including leaves registered later. Nothing about the
wire, the registry or any projection changes: the route installs exactly as
before and fiber chains it behind whatever came first. Only the conflict check
changes, and into two NARROWER rules rather than none —
- exactly one claim at an address may decline to yield, and it must be the
FIRST, because the router answers with the first registration. A shadow
ahead of what it yields to would stand in front of it, so "order is the
contract" is now checked rather than stated. Two claims that both mean to
answer is still the accidental collision, refused in the same words.
- at most one claim may carry an op. A document keys an operation on method
and path; there is one slot, and two ops asking for it would publish one and
drop the other. Shadow yields an address to another HANDLER, never a
document slot to another op.
A shadow that is the only claim simply answers, which is what lets ONE table be
mounted into a host that has an implementation and into one that does not.
Shadow is on the concrete *App and deliberately NOT on the Router interface:
Router is implemented from outside this package method by method (cloud's scope,
commerce's mintRouter), and widening it is what stalled v1.19 adoption once.
Tests: declaredid_test.go and shadow_test.go, twelve cases, each failing before
the change — the id one with the literal v1.o11y.CreateRole, the address ones
with the refusal origin/main emits. compose_test.go gains unnamedApp, the same
definition with no declared id, because a definition that names its ops cannot
be the one included twice: the diamond is a test about the UNDECLARED half of
the rule and now says so.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
5dfa4ef616 |
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]> |
||
|
|
fb32c54715 |
runtime: the JS toolchain is opt-in — Loader/Module are declared at the root
The root zip package imported zip/runtime for exactly ONE thing: the type of
Config.Loader. zip/runtime imports dop251/goja (a JS interpreter) and
evanw/esbuild/pkg/api (a JS bundler) directly, so every consumer of zip
compiled a JavaScript toolchain to name an interface — and the interface was
itself only a type ALIAS of a dependency-free leaf, internal/runtime.
Loader and Module now live where they are consumed: the zip root, next to
App.Module, which is the only thing that calls them. internal/runtime and the
zip/runtime aliases are deleted — one home, one name. zip/runtime keeps goja
and esbuild and stays the JavaScript implementation; it is simply a separate
import now, so the cost lands on the binaries that ask for JS.
There is no hot-path coupling to invert: the root only ever calls through the
interface (a.loader.LoadOne) into a loader the consumer constructs and passes
in via Config.Loader. That contract is unchanged.
Measured with `go list -deps . | wc -l` in a CLEAN module importing only zip,
outside any other module graph:
before 315 packages, 33 of them goja + esbuild
after 258 packages, 0
opt-in 314 packages, 33 (a consumer that DOES import zip/runtime)
57 packages leave and none arrive: esbuild 25, goja 8, x/text 9, regexp2 3,
sourcemap 2, pprof 1, go/ast+token+scanner, archive/zip, encoding/base32,
hash/maphash — plus the two zip packages no longer reachable.
hanzoai/cloud, which imports zip/runtime nowhere, measured against this commit
vs its parent (same zip baseline, so the delta is only this change):
cmd/cloud 402 -> 346 (JS 33 -> 0)
cloud root 603 -> 547
plugin/crm 605 -> 549
plugin/storage 716 -> 664
cmd/cloud builds, and `go build ./...` yields the byte-identical error set
before and after (3 pre-existing k8s/autoscaling failures, unrelated).
Also corrected a doc claim that was already false: Config.Loader said it "is
satisfied by *extruntime.Loader". It is not, and was not before this commit —
extruntime.LoadDir returns map[string]extruntime.Module, and Go requires
IDENTICAL result types, so an equivalent method set does not satisfy the
interface. The docs now say an adapter returning zip.Module is required.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
572aeb8750 |
docs: say precisely why the two registrars that project nothing cannot
app.Module registers a route and no op, so an extension endpoint has a JSON contract that reaches no document, no tool list, no CLI and no call plane. That hole is now stated at the seam instead of discovered, along with the two structural reasons it cannot close today — a module is loaded at run time, so there is no Go type to derive a schema from, and a typed op cannot express the module's status/headers response envelope — and the one thing that would close it: an extension that DECLARES its contract on runtime.Module. zaprpc's HTTPHandler doc showed `app.ZAPRegistry()`, a method that has never existed in this module (the only implementation was in the dead hanzoai/zip fork), and justified its fiber.Handler return with an import cycle that does not exist — zip does not import zaprpc. Both are replaced with an example that works. The package is marked Deprecated and says what to use instead. It is NOT what hanzoai/cloud's service plane imports: cloud imports github.com/zap-proto/go/rpc under the local alias `zaprpc`, which is a different package in a different module — u32 ordinals and promise pipelining against this one's 24-byte string-keyed envelope, mutually undecodable. Nothing in the estate imports zip/zaprpc, including zip. Deleting it is owed, but removing an exported package from a public module is a breaking change and does not ride a patch. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
b426c2b2a8 |
feat: plugins — a service builds as its own binary and loads at run time
A plugin is an ordinary zip app. It needs no SDK and no schema: it serves
routes, and a host mounts them over ZAP on a unix socket.
//go:embed bin/billing
var billingBin []byte
app.Add(
billing.New(deps), // linked in
zip.Load("/v1/billing", zip.Plugin{Name: "billing", // its own binary
Bin: billingBin}),
zip.Load("/v1/ml", zip.Plugin{Name: "ml", // already running
Addr: os.Getenv("ML_ADDR")}),
)
One type covers all three, so where a service runs is a deployment decision
rather than a code change. Service is func(*App) error — a constructor taking
dependencies and returning one is the same thing curried, which is what lets a
composition root see only Service.
The host links zip and a transport, never a plugin's dependency graph. Plugins
build in parallel, one changing rebuilds only itself, and go:embed keeps the
deployment a single artifact.
Reload swaps a running plugin for a new build without dropping a request. This
is why a plugin is a process: Go can map a shared object in but never unmap
one, so an in-process plugin's code, globals and goroutines are unreclaimable
for the life of the host. A child releases every byte when it exits. Three
invariants keep repeated reloads flat in memory:
- routes register ONCE at Load and resolve their target per request, so the
route table never grows
- the replacement must be listening before any request moves to it, so a bad
build cannot take the route down
- the old process is killed, reaped exactly once, its pooled connections
closed and its directory removed
Unload leaves the routes answering 503 rather than vanishing, so load/unload
cycles are flat too and a Reload restores the same paths.
Tests build two genuinely different binaries and prove which one answered
across four swaps, a failed swap, and an unload/restore. Race detector clean.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
c45ea875e9 |
router: one chain, gin/express order — variadic (middleware..., handler-last) on every route method; Ctx.Redirect
splitChain flips to fiber's handler-first convention in exactly one place; zero-handler registration panics at boot. With() wraps only the terminal. |
||
|
|
971bc93b7d |
zip: delete deprecated + redundant surface — one way to do everything (#8)
Breaking (minor, forwards-only per house rule): remove redundant exported symbols so each operation has one and only one path. - App.Mount(prefix, http.Handler) [was Deprecated] → app.All(prefix+"/*", zip.AdaptNetHTTP(h)). Mount WAS literally that composition. - Router.Route(prefix, func(Router)) → app.Group(prefix), register on the returned Router. Route was Group + callback sugar; zero consumers. - App.ModuleFn(method,path,fn,rt,dir) folded into App.Module — Module is the one HIP-0105 mount verb (fn is derived from the path); no external caller. - App.UseFiber(fiber.Handler...) → Use(Handler)/With; unused fiber-middleware escape hatch. Kept (live external consumers): AdaptNetHTTP, AdaptNetHTTPFunc (cloud websearch), AdaptNetHTTPMiddleware (gateway zipFromGin — the net/http middleware bridge). Docs (MIGRATION.md, HANZO_CHANGES.md) and the deprecated-alias test updated. Core surface (Use/With/Group/Get..All/typed Get[In,Out]/Static/AdaptNetHTTP/ OnShutdown/Listen/Shutdown) untouched. Suggested tag: v1.6.0. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
509a645e32 |
zip: OnShutdown — LIFO lifecycle hooks for express-style composition (#4)
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]>
|
||
|
|
c581e0af7b |
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).
|