Files
zeekay 999e2153b3 Type every wire: bytes, forms, streams, sockets, redirects, unions
A typed op could only take one JSON value and answer one, so uploads,
signed webhooks, form posts, YAML and merge-patch bodies, PDFs, SSE,
WebSockets, OAuth redirects and upstream relays stayed untyped routes
with no schema, tool, command or SDK method. Each is now a typed op and
its REST wire is unchanged.

Requests: a zip.Body field takes the body and Content-Type exactly as
sent; an In implementing Parser decodes itself; form: fields and
*zip.File parts bind url-encoded and multipart forms; cookie: binds a
request cookie as an in: cookie parameter. Consumes names the media.

Answers: zip.Body (bytes, filename, its own status and headers, a
Reader streams chunked), Verbatim[T] (a relay documented as T), Sse[T]
(events with keep-alive), Socket[M] (an upgrade after the contract
runs; 426 without one, 501 over ZAP), Redirect, CookieCoder, Produces,
and unions: a type whose OneOf method's results are the alternatives,
with Or[A,B] as the stock one filing each under the status it states.

Projections follow from the manifest: the document publishes binary
media, form schemas, x-events, x-socket with 101, 3xx with Location
and oneOf; MCP takes bytes as base64 and returns bytes, text or
collected events as tool content, listing a socket op in _meta as no
tool; the CLI reads --body @file|-, --field name=@file and --out,
prints events as they arrive and bridges a socket to stdin and stdout.
tools/list is ProjectMCP over the manifest, the projection MCPTools
already was.

Prose reaches promoted fields (filed under the declaring struct), a
type defined over another struct, and the type arguments of zip's
kinds and OneOf results; an instantiated generic is published under
its name followed by its argument names instead of an illegal
component key. The unused reflect-only document and tool builders go.
2026-10-09 09:42:00 -07:00

607 lines
26 KiB
Go

// Package zip is Hanzo's canonical Go web framework. Built on
// Fiber v3 / fasthttp. ZAP-typed handlers.
// Multi-language extension support via HIP-0105.
//
// ONE framework, ZERO escape hatches. zip IS fast.
//
// app := zip.New(zip.Config{Logger: luxlog.NewLogger("svc")})
// app.Use(middleware.Recover(), middleware.RequestID())
// app.Get("/health", func(c *zip.Ctx) error {
// return c.JSON(200, fiber.Map{"ok": true})
// })
// app.Listen(":9653", "http://:8080") // ZAP primary + HTTP extra, one verb
//
// Public surface — types/functions exposed at the package root:
//
// type App, Config, Ctx, Handler
// func New(Config) *App
// func Get[I, O](app *App, path string, fn func(ctx, *I) (*O, error))
// func Post[I, O](app *App, path string, fn func(ctx, *I) (*O, error))
// func Dial(addr string) (*Conn, error) / DialApp(name string) (*Conn, error)
// func Call[I, O](ctx, *Conn, op string, in *I) (*O, error)
// ...
//
// All other behavior lives in subpackages: `middleware`, `js`.
package zip
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/fiber/v3"
zapmcp "github.com/zap-proto/mcp"
"github.com/zap-proto/zip/internal/jsonenc"
)
// JSONVariant names the JSON implementation on the edge and the semantics it
// writes, which zip.New logs once at startup. Per HIP-0106 the wire stack is
// "JSON only at edge, ZAP between services", so this is the one place the
// edge's encoder is stated. What it means is in [jsonenc.Variant].
const JSONVariant = jsonenc.Variant
// Handler is zip's request handler signature. Returning an error causes
// Fiber's error chain to write a JSON response.
type Handler func(c *Ctx) error
// Config configures the zip App. Most fields pass through to Fiber's own
// Config; a few zip-specific knobs control runtime loading.
type Config struct {
// Logger is the luxfi/log Logger zip uses internally. Required.
// If nil, a default one is created via luxlog.NewLogger("zip").
Logger luxlog.Logger
// Loader is the HIP-0105 extension runtime loader. nil disables
// app.Module() — only native handlers will work. zip does NOT take a hard
// dep on hanzoai/base: the consumer builds the loader and passes it in.
// Note that [Loader] is satisfied structurally but NOT loosely — LoadDir
// 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/js] — a separate import so its goja +
// esbuild cost lands only on binaries that evaluate JS.
Loader Loader
// AllowedRuntimes restricts which extension runtimes app.Module()
// will accept (e.g. ["goja","wazero"] for hard multi-tenant safety).
// nil = allow whatever the Loader has registered.
AllowedRuntimes []string
// ServerHeader is sent as the Server: response header (default "zip").
// Set to "-" to suppress.
ServerHeader string
// BodyLimit is the maximum request body size (default 4 MiB).
BodyLimit int
// AppName forwards to fiber.Config.AppName. 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
// Warm is how many plugin processes this app may hold at once. Zero means
// unbounded, the historical behaviour. It is enforced where a plugin STARTS,
// so it is a ceiling rather than something a sweep restores a minute later,
// and again by [App.Evict] alongside each plugin's IdleAfter.
//
// A process serving a request is never the one stopped for room. When every
// candidate is busy, a starter waits for one to finish, for as long as its
// own [Plugin.Start] allows, and then answers 503 rather than kill a request.
//
// The host states it because the number is a fact about the host's memory: the
// processes are its own children in its own cgroup, and a library cannot know
// how much of it they may have.
Warm int
// ErrorHandler replaces the handler that writes every refusal. Unset, a
// refusal is an RFC 9457 problem document (application/problem+json), or
// RFC 6749's {error, error_description} at an address declared with [OAuth].
// See problem.go.
ErrorHandler fiber.ErrorHandler
// Concurrency caps the maximum number of concurrent connections the
// server will accept. Default 0 means fasthttp's own default
// (256*1024). Ops should cap this at the per-replica budget — see
// `~/work/hanzo/hips/docs/SCALE_STANDARD.md`. With Hanzo's verified
// 8 KiB/conn budget, 100_000 sits at ~800 MiB inside a 1 GiB pod.
Concurrency int
// SocketMode is the file mode a unix socket is bound with. Zero is 0600:
// only the user running the app may connect, and the socket is the
// boundary. A server that instead attests every peer from what the
// kernel says about it — [PeerOf], [Peer.PodUID] — may open the mode,
// because the attestation is then the boundary and the mode is only the
// door.
SocketMode os.FileMode
// ReadBufferSize is fasthttp's per-conn request-read buffer (default
// 4 KiB). Raise only for header-heavy upstreams; raising it inflates
// the per-conn memory budget and breaks the conn-memory regression
// gate (see SCALE_STANDARD.md §8).
ReadBufferSize int
// WriteBufferSize is fasthttp's per-conn response-write buffer
// (default 4 KiB). Raise only for streaming-heavy responses; same
// budget caveat as ReadBufferSize.
WriteBufferSize int
// OpsAddr is where this app's ops listener binds — /healthz, /readyz and
// /metrics on a socket of their own, so a liveness probe never queues behind
// public traffic and a metrics endpoint is never on a public port. Empty
// means this process owns no ops listener, which is what a child composed
// into a host wants: the ops port belongs to the deployment, and a child's
// private socket is not one of the deployment's listeners (HIP-0106 §1.3(f)).
//
// It is an address like every other address zip takes, and the address names
// its own transport — so an ops listener a kubelet probes and Prometheus
// scrapes is [DefaultOpsAddr], an HTTP one. A bare ":9090" would be ZAP
// (DefaultScheme), which those two clients cannot speak.
OpsAddr string
// TrustProxy says this app sits behind a proxy whose forwarded headers may
// be believed — and it is OFF by default, deliberately.
//
// [Caller.IP] is the socket peer unless this is set, because X-Forwarded-For
// is attacker-controlled input: anyone can send one. An IP that silently
// reports the load balancer is worse than no IP at all, since someone will
// rate-limit or audit on it.
//
// The opt-in is allowlist-gated rather than a boolean promise: with
// TrustedProxies naming the proxies (IPs or CIDRs), the value in ProxyHeader
// is honoured ONLY when the peer is one of them, and falls back to the peer
// anywhere else. Setting TrustProxy with no TrustedProxies trusts nothing —
// fiber skips every spoofable header — which is the safe reading of an
// incomplete configuration.
TrustProxy bool
// TrustedProxies is the allowlist TrustProxy consults: proxy IPs or CIDR
// ranges whose forwarded headers this app will believe.
TrustedProxies []string
// ProxyHeader names the header a trusted proxy states the caller's address
// in — "X-Forwarded-For" for most ingress, "X-Real-IP" for some. Read only
// when TrustProxy is set and the peer is in TrustedProxies.
ProxyHeader string
// OpenAPI configures the auto-generated /.well-known/openapi.json
// served when typed handlers are registered.
OpenAPI OpenAPIConfig
// MCP configures the Model Context Protocol tool surface auto-derived from
// typed handlers (Get/Post[In,Out]). Enabled by default — it's free (the
// same op registry that feeds OpenAPI), served over every transport. Set
// MCP.Disabled to suppress.
MCP MCPConfig
// Telemetry says where this app's report goes and whether it makes one. The
// report itself is not configured: every zip app logs every request,
// measures it, and carries trace context, because it is serving. See
// [Telemetry].
Telemetry Telemetry
}
// 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
// The PROGRAM. entries is this app's ordered composition — middleware,
// routes and other Apps included by reference — and prefix is the path this
// app answers under when it is included somewhere ([App.Group] sets it).
// There is no op-registry field: the registry is a PROJECTION over a walk of
// this list (see [App.Registry]), which is what let one verb replace five.
prefix string
entries []entry
// The GENERATIONS. live is the sealed, immutable program currently serving,
// swapped whole and read without a lock; draft is the throwaway build that
// answers inspection before anything is live. buildMu serialises building —
// never reading. See generation.go.
live atomic.Pointer[generation]
draft *generation
draftAt uint64
draftErr error
buildMu sync.Mutex
// control is zip's own projection routes. They belong to the BUILD, not to
// the program — see [App.control].
ctl []route
// frozen: this definition has appeared in a built generation, so editing it
// in place is refused. Monotonic, and propagated across the whole reachable
// graph at install.
frozen atomic.Bool
freezeSite callsite
// wrap is the Middleware a scoped [App.With] installed on this App (see
// wrapRouter.Group). nil for every ordinary App, and the common case pays
// nothing for it.
wrap Middleware
// oauth: every route registered on this definition answers RFC 6749 §5.2.
// Set by [OAuth] on the group it hands back, so the vocabulary is a property
// of where a route was declared and not of what its handler returns.
oauth bool
// undeclared: every route registered on this definition serves but is left
// out of [App.Declaration]. See [App.Undeclared].
undeclared bool
servers []Server // the running transport listeners, set by Listen
// unbind withdraws this app from the by-address table [Serving] reads, one
// entry per bound listener. Held beside servers because they have the same
// lifetime: an app answers in-process for exactly as long as it answers on
// the wire.
unbind []func()
// 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
// handler will act on. nil defers to [App.over], and nil there too leaves
// every decoded request unauthorized. See [App.rule].
authorizer Authorizer
// onResult, when set via OnResult, is told the outcome of every typed op this
// app serves, however it was reached. nil defers to [App.over]. See
// [App.result].
onResult func(context.Context, Op, error)
// over is the App whose rule this one answers to: the nearest definition
// ABOVE it in the composition that declared an Authorizer. Settled at build
// by [adopt], which is the only place that knows — a definition is written
// without knowing what will include it, and what includes it is what decides
// whose rule its ops serve under.
//
// Atomic because a build runs while the previous generation serves, the same
// reason a plugin learns its owner atomically.
over atomic.Pointer[App]
srvMu sync.Mutex
// Teardown lifecycle. hooks are drained LIFO by Shutdown after
// in-flight requests finish; shuttingDown guards against re-running
// them and against post-shutdown registration. hookMu guards both.
hooks []func(context.Context) error
shuttingDown bool
hookMu sync.Mutex
// Running plugins, by name, so Reload can find one after Load composed it.
// Guarded by plugMu.
plugins map[string]*plugin
// The composed MCP surface of the plugins this app Load'ed: every tool
// descriptor a plugin's build-time catalogue declared, and which plugin owns
// each name. Written by load (under plugMu), read by installMCP and by a
// tools/call. It is what lets a host expose 112 lazy children's tools without
// running any of them — see [Plugin.Tools].
pluginTools []mcpTool
toolOwners map[string]*plugin
// open is the one plugin whose catalogue is INCOMPLETE by construction — the
// tenant half of the door, asked per caller instead of embedded. See
// [Plugin.Open]. At most one, so a name no catalogue claims has one owner.
open *plugin
plugMu sync.Mutex
// warm is Config.Warm: how many plugin processes may run at once. It is state
// rather than an argument to Evict because the ceiling must be known where a
// process is STARTED — a bound only a periodic sweep consults is a bound a
// burst outruns. See makeRoom.
warm int
// roomMu makes "is there room" and "take it" one step, and starting counts
// the processes that have taken room but are not running yet. Without both,
// two starters each read 34 of 35 and each start, and the ceiling is a race.
roomMu sync.Mutex
starting int
// mcpList is the whole tools/list array, rendered by installMCP and re-rendered
// by any later load: own ops ++ every plugin catalogue, sorted by name. Serving
// bytes is what makes the most-called MCP method free. Atomic because a Load
// after Listen re-renders it while requests are reading it — nil until the door
// is installed, which is also how installTools knows whether to re-render.
mcpList atomic.Pointer[json.RawMessage]
// mcpNames is the set of names inside mcpList, stored with it so the
// per-caller half can tell whether a tenant tool would shadow a projected one
// without re-parsing the array on every request.
mcpNames atomic.Pointer[map[string]bool]
// mcpRefused names the ops that are not tools, and why — rendered with
// mcpList and answered in tools/list's _meta, so a client reading the list
// sees every op accounted for.
mcpRefused atomic.Pointer[map[string]string]
// The ZAP-native MCP listener, when Config.MCP.Addr named one. It is a
// zapmcp.Server over App.MCP — the door served on the wire it was designed
// for, with no HTTP anywhere in the path. Guarded by mcpMu so a second Listen
// does not bind a second one.
mcpSrv *zapmcp.Server
mcpMu sync.Mutex
// mcpOnce renders the tool list for whichever door asks first, so an app
// served only over ZAP is not left with the empty projection the HTTP
// installer used to be the sole producer of.
mcpOnce sync.Once
// 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
// This app's instruments. Built once with the app and never rebuilt: a
// generation swap re-installs the request boundary, and counters that reset
// on every composition change would read as a restart to anything watching
// the rate.
telemetry *telemetry
// 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
// prepared reports whether that has happened, which sync.Once cannot be
// asked. Composition needs the answer: the document, the tool list and the
// call plane are rendered ONCE from the registry, so an op that arrives
// afterwards would serve and never describe.
prepared atomic.Bool
}
// Name is the app's [Config.AppName] — the one name it answers to.
//
// Exported because a caller holding several apps has to tell them apart, and
// the name is the only thing that does. [ReadZAP] answers with one app per
// interface a schema declares, and picking one of them out is exactly that
// question.
func (a *App) Name() string { return a.cfg.AppName }
// New constructs an App with the given config. Defaults are applied
// for any zero-valued field.
func New(cfg Config) *App {
a := newApp(cfg)
a.logger.Info("zip new", "json_variant", jsonenc.Variant)
return a
}
// newApp is New without the line in the log. [App.Group] builds an App per
// scope, and a group is a lexical construct rather than a deployment, so it
// does not announce itself.
//
// No router is constructed here. A router is a BUILD of the program, and the
// program is empty — materialising one now would mean materialising one per
// group, and rebuilding every one of them the moment anything is appended.
func newApp(cfg Config) *App {
if cfg.Logger == nil {
cfg.Logger = luxlog.New("module", "zip")
}
if cfg.BodyLimit == 0 {
cfg.BodyLimit = 4 << 20
}
if cfg.ServerHeader == "" {
cfg.ServerHeader = "zip"
}
return &App{
cfg: cfg,
logger: cfg.Logger,
loader: cfg.Loader,
born: time.Now(),
warm: cfg.Warm,
telemetry: newTelemetry(cfg),
}
}
// jsonMarshal / jsonUnmarshal route every fiber JSON path through zip's jsonenc
// package: c.JSON(), c.Bind().Body(), and the default error handler when it
// serialises an HTTPError. Named here so [App.fiberConfig] is the one place
// that says it.
var (
jsonMarshal = jsonenc.Marshal
jsonUnmarshal = jsonenc.Unmarshal
)
// Fiber returns the underlying *fiber.App, materialising the program if it has
// changed since the last build. Use for one-off escape into Fiber-only APIs
// (rare). Prefer staying on the zip surface.
//
// It does NOT seal: inspecting the router is not executing it, and a test or a
// codegen step that looks must not turn the next legitimate Use into a panic.
func (a *App) Fiber() *fiber.App { return a.router() }
// Logger returns the App's logger.
func (a *App) Logger() luxlog.Logger { return a.logger }
// Shutdown gracefully stops every transport, then runs teardown hooks.
// The process is ending, so hooks receive context.Background() — no
// cancellation or deadline. Use ShutdownWithContext to bound teardown.
// Idempotent: a second call is a no-op and hooks run at most once.
func (a *App) Shutdown() error {
return a.shutdown(context.Background())
}
// ShutdownWithContext is Shutdown bounded by ctx: ctx bounds the in-flight
// drain and is passed to every teardown hook (values and deadline).
// Shares Shutdown's once-guard, so mixing the two still runs hooks once.
func (a *App) ShutdownWithContext(ctx context.Context) error {
return a.shutdown(ctx)
}
// With returns a group whose subsequent leaf registrations
// have mw wrapped around the handler at registration time — pure
// composition (RateLimit(CSRF(handler))). It does NOT touch the global Use
// stack and does NOT route through c.Next(); it is the per-route counterpart to
// Use. Routes registered on the returned group still obey specificity
// precedence exactly like any other route.
//
// app.With(RateLimit, CSRF).Post("/v1/keys", mintKey)
func (a *App) With(mw ...Middleware) *Group {
return (&Group{on: a.group(here(1), "")}).chain(mw)
}
// Raw registers a route that is NOT a typed operation — see [Group.Raw] for
// what that means and why it is the longer spelling. Chains are in wrapping
// order: middleware first, the final handler last.
//
// The method is a string because the verb NAMES belong to the typed operations:
//
// app.Raw("GET", "/healthz", ok)
//
// [MethodAll] is the one that answers a path whatever the verb.
func (a *App) Raw(method, path string, handlers ...Handler) *App {
a.raw(here(1), method, path, handlers)
return a
}
// Get declares a GET operation at the root.
func (a *App) Get[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
return &Operation[In, Out]{op: registerTyped(0, a, "GET", path, fn, opts...)}
}
// Post declares a POST operation at the root.
func (a *App) Post[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
return &Operation[In, Out]{op: registerTyped(0, a, "POST", path, fn, opts...)}
}
// Put declares a PUT operation at the root.
func (a *App) Put[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
return &Operation[In, Out]{op: registerTyped(0, a, "PUT", path, fn, opts...)}
}
// Patch declares a PATCH operation at the root.
func (a *App) Patch[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
return &Operation[In, Out]{op: registerTyped(0, a, "PATCH", path, fn, opts...)}
}
// Delete declares a DELETE operation at the root.
func (a *App) Delete[In, Out any](path string, fn TypedHandler[In, Out], opts ...OpOption) *Operation[In, Out] {
return &Operation[In, Out]{op: registerTyped(0, a, "DELETE", path, fn, opts...)}
}
// OAuth is [Group.OAuth] at the root.
func (a *App) OAuth() *Group { return a.Group("").OAuth() }
// Undeclared is [Group.Undeclared] at the root.
func (a *App) Undeclared() *Group { return a.Group("").Undeclared() }
func (a *App) raw(site callsite, method, path string, handlers []Handler) {
// A scoped [App.With] lives on the App itself, so a leaf registered on this
// scope at any time is wrapped — including one registered after the scope was
// handed out. A decorator could only wrap what passed through it.
if len(handlers) == 0 {
panic("zip: route registered with no handler")
}
for i, h := range handlers {
// toFiberHandler wraps a nil Handler into a NON-nil closure, so fiber's
// own nil check passes and the nil surfaces as a segfault on a serve
// goroutine no recover middleware can reach. Boot, not request time.
if h == nil {
panic(fmt.Sprintf("zip: %s %s: handler %d is nil", method, normPath(path), i))
}
}
if a.wrap != nil {
handlers = append([]Handler(nil), handlers...)
handlers[len(handlers)-1] = a.wrap(handlers[len(handlers)-1])
} else {
handlers = append([]Handler(nil), handlers...)
}
a.addRoute(site, route{method: method, path: normPath(path), chain: handlers})
}
// opScope makes the App itself a place a typed op lands. The prefix
// is not reported here: a group's prefix is a property of WHERE the group is
// included, and one definition may be included in two places, so the absolute
// path is computed by the walk and never baked into the op.
func (a *App) opScope() opScope { return opScope{App: a, Middleware: a.wrap} }
// errors.As helper for HTTPError unwrapping in tests / external callers.
func asHTTPError(err error) (*HTTPError, bool) {
var he *HTTPError
if errors.As(err, &he) {
return he, true
}
return nil, false
}
// Routes is every address this program answers, as [Declaration] projects them:
// the program's own entries, absolute, without fiber's HEAD and OPTIONS shadows
// and without zip's control plane.
//
// It exists so that enumerating routes does not require reaching through
// [App.Fiber]. That reach is now refused when it mutates (see checkForeignRoutes)
// and was always wrong when it merely read, because fiber's own GetRoutes cannot
// apply a doctrine zip owns — it reports shadows nobody declared and cannot tell
// an explicit HEAD from a generated one.
func (a *App) Routes() []Route { return a.Declaration().Routes }
// TestConfig bounds a [App.Test] request. It is zip's own type on purpose: Test
// is what callers are told to use INSTEAD of reaching through [App.Fiber], and a
// signature naming fiber sent them straight back to the escape hatch it replaces
// — so every test that wanted a timeout imported the router this package exists
// to keep out of its callers.
//
// Two fields because two are what the estate uses. A knob nobody turns is a
// promise about the router underneath.
type TestConfig struct {
// Timeout bounds the request. Zero means the underlying default (1s).
Timeout time.Duration
// FailOnTimeout returns an error when Timeout is exceeded, rather than a
// 408 response. Default true, matching the router.
FailOnTimeout bool
}
// Test drives one request against the program without binding a socket, which is
// the other thing callers reached through [App.Fiber] for.
//
// It builds the program if nothing is live, exactly as serving would, so a test
// exercises the same generation a request would — including every validation.
//
// "Exactly as serving would" includes the DEFERRED PROJECTIONS. Serving calls
// prepare, which installs /mcp, the OpenAPI document, the op-call plane and the
// plugin route; building alone does not. Test used to skip it, so those four
// addresses answered 404 under test and 200 in production — a test could not see
// the surface it was written to check, and the closer the test got to the real
// program the more confidently it reported the wrong thing. Callers papered over
// it with an exported Prepare, which is why unexporting that broke them.
func (a *App) Test(req *http.Request, cfg ...TestConfig) (*http.Response, error) {
a.prepare()
if len(cfg) == 0 {
return a.router().Test(req)
}
c := cfg[0]
return a.router().Test(req, fiber.TestConfig{
Timeout: c.Timeout,
FailOnTimeout: c.FailOnTimeout,
})
}