Edge Runtime
Anvil runs the HTTP-family listener when an app registers HTTP, GraphQL, or gRPC drivers.
Drivers register capabilities. The app collects those drivers, builds one edge listener, and routes each parsed request to the selected driver. The driver does not decide whether an incoming request is REST, GraphQL, or gRPC.
Registration Flow
Section titled “Registration Flow”Application code stays compact:
app := anvil.New( httpstd.Driver(), anvilgrpc.Driver(), anvilgraphql.Driver(),)During app.Run or app.Listen, Anvil finalizes transports:
- It creates the core edge transport.
- It inspects registered transports.
- If exactly one transport claims direct listener ownership and no other HTTP-family edge target is registered, Anvil registers that transport directly with the core app.
- Transports that implement
http.Handlerand one of the SDK protocol contracts become edge targets:sdk.HTTPTransportsdk.GraphQLTransportsdk.GRPCTransport
- Queue transports and other non-HTTP-family transports run as background transports through the core app.
- If at least one HTTP-family target exists, Anvil registers one
edgetransport with the core app. The edge receives the publicaddr. Background transports remain registered too, but Anvil starts them with an empty address because they run outside the public HTTP-family listener.
The important rule is simple: Anvil accepts HTTP-family traffic first, then dispatches to the selected driver.
Generated wiring resolves drivers before edge finalization. It uses the
standard protocol names http, graphql, grpc, and queue, then checks the
matching SDK transport interface. WebSocket wiring finds the first registered
sdk.WebSocketTransport. Driver authors keep those protocol strings for
generated Anvil apps.
Edge Target Detection
Section titled “Edge Target Detection”A transport becomes an edge target only when both statements are true:
- It implements
http.Handler. - It implements one of the SDK transport contracts that belongs to the
HTTP-family edge:
sdk.GraphQLTransport,sdk.GRPCTransport, orsdk.HTTPTransport.
If a transport implements an SDK protocol contract but does not implement
http.Handler, Anvil registers it as a background transport. Background
transports receive an empty string when their Start method is called. This is
the normal path for queues and any custom transport that runs work outside the
shared HTTP listener.
An HTTP-family transport can also implement Anvil’s internal listener-owner
marker. That marker is intentionally narrow. Anvil only honors it when exactly
one transport claims ownership and there are no other HTTP-family edge targets.
The native HTTP driver uses this path to avoid the shared net/http edge in
HTTP-only apps. When GraphQL or gRPC are present, the native driver remains a
normal edge target through its http.Handler compatibility path.
If more than one transport claims listener ownership, Anvil rejects the app before it starts.
When a custom transport implements more than one edge protocol contract, Anvil classifies the transport itself in this order:
sdk.GraphQLTransportsdk.GRPCTransportsdk.HTTPTransport
Official drivers expose one edge protocol per driver. Use that shape for
third-party drivers unless a combined driver intentionally wants the
classification above. WebSocket support is part of the HTTP driver contract
because sdk.WS starts as an HTTP upgrade route.
Anvil rejects duplicate transport protocol names before finalization. In a normal app that means one HTTP driver, one GraphQL driver, one gRPC driver, and one queue driver per protocol string.
HTTP-family drivers run behind the shared public address in edge mode. The edge
transport receives the public addr, runs the net/http.Server, classifies
the parsed request, and calls the selected driver through http.Handler.
Driver Start(addr) is reserved for standalone driver use, non-edge
transports, or the direct listener-owner path described above.
Generated route registration happens during app.Wire(...). Edge target
finalization happens later, during app.Run, app.Listen, app.RunTLS, or
app.ListenTLS. Run does not call Wire for you; generated apps call
Wire first, then start listening.
The app runtime is intended to run once. After a run starts, transport registration is closed, edge targets have been finalized, and driver state belongs to the active process lifecycle. After shutdown, create a new app and new driver instances for another run.
Classification Order
Section titled “Classification Order”The edge dispatcher checks requests in this order:
- gRPC
- GraphQL
- HTTP fallback
gRPC matches when the request is HTTP/2 and the Content-Type header begins
with application/grpc, case-insensitive after trimming whitespace.
GraphQL matches by exact request path. The GraphQL driver exposes its registered
paths through GraphQLPaths(). Before generated GraphQL routes are registered,
the driver reports its configured mux path, defaulting to /graphql.
HTTP is the fallback when an HTTP driver is registered. If no target matches,
the edge returns 404.
This means normal REST over HTTP/2 still goes to the HTTP driver. It is not classified as gRPC unless it uses the gRPC content type.
GraphQL classification does not inspect the GraphQL body. The edge only checks the path. The GraphQL driver then validates method, body/query shape, subscriptions, and response encoding.
Path matching uses request.URL.Path exactly. Driver-reported GraphQL paths
are whitespace-trimmed; an empty path becomes /graphql, and a non-empty path
without a leading slash gets one. The edge does not lowercase paths, remove
trailing slashes, or run route-pattern matching for GraphQL classification.
Path Matching for GraphQL
Section titled “Path Matching for GraphQL”GraphQL classification is path-based because GraphQL-over-HTTP is ordinary HTTP at the transport layer.
type ProjectGraph struct { sdk.GraphQLEndpointWith[GraphPolicy] `path:"/api/v1/graphql"`}A request to /api/v1/graphql is sent to the GraphQL driver. A request to
/api/v1/projects is sent to the HTTP driver when one is registered.
GraphQL endpoints can live inside the same group tree as HTTP controllers. The compiler produces full paths before the driver receives route metadata.
Generated GraphQL endpoints cannot share the same final path; the compiler
rejects that before code generation. The app also rejects duplicate
transport protocol names, so normal applications register one GraphQL driver.
If custom code uses core/edge directly and registers overlapping GraphQL
paths, the first matching GraphQL target wins because edge targets are checked
in registration order.
Listener Defaults
Section titled “Listener Defaults”The edge listener uses net/http.Server with defensive defaults when the
application does not provide values:
| Setting | Default |
|---|---|
ReadHeaderTimeout | 5s |
ReadTimeout | 30s |
WriteTimeout | 30s |
IdleTimeout | 2m |
The edge enables HTTP/1, HTTP/2, and unencrypted HTTP/2 by default through
http.Protocols when the application has not supplied its own protocol set.
If anvil.WithListener supplies a server with Protocols already set, Anvil
keeps that protocol set. Make sure it still allows the protocols your app needs;
gRPC on the shared edge requires HTTP/2.
Custom Listener Settings
Section titled “Custom Listener Settings”Use anvil.WithListener when the application needs to control net/http
server settings:
app := anvil.New( anvil.WithListener(&http.Server{ ReadHeaderTimeout: 2 * time.Second, MaxHeaderBytes: 32 << 10, }), httpstd.Driver(), anvilgrpc.Driver(),)Leave Handler nil on the supplied server. Anvil installs the handler because
the edge dispatcher has to see every HTTP-family request before any driver.
Passing nil or a server with Handler already set is recorded as an
initialization error and returned before the app starts.
The server struct is copied before the edge starts, so Anvil does not mutate the
caller-owned struct. Pointer fields such as TLSConfig and Protocols still
refer to the same objects. Configure them before passing the server to Anvil and
keep them immutable while the app is running.
The final address comes from app.Run(ctx, addr), app.Listen(addr), or the
TLS variants. Anvil overwrites the cloned server’s Addr with that value when
the edge starts.
Trusted Proxy Client IP
Section titled “Trusted Proxy Client IP”If the application runs behind Cloudflare, nginx, Envoy, Tailscale Funnel, a cloud load balancer, or another reverse proxy, configure the proxy trust rule on Anvil’s edge listener:
app := anvil.New( anvil.WithProxy(anvil.ProxyConfig{ ProxyHeader: "CF-Connecting-IP", TrustedProxies: []string{ "203.0.113.0/24", "2001:db8:100::/48", }, }), httpstd.Driver(),)For Cloudflare, use the current Cloudflare published proxy ranges in your application config. Do not hard-code old copies of provider IP ranges in source.
Handlers and middleware read the verified client IP through the SDK request:
func (Audit) BeforeHTTP(ctx sdk.Ctx) error { audit.Record(ctx.Context(), ctx.Request().IP(), ctx.Request().Path()) return nil}Anvil trusts the configured proxy header only when the direct socket peer is in
TrustedProxies. If a client connects directly and sends CF-Connecting-IP or
X-Forwarded-For, Anvil ignores the header and uses the socket peer IP.
For comma-separated headers such as X-Forwarded-For, Anvil walks the chain
from right to left and returns the first address that is not a trusted proxy.
For a single-address header such as CF-Connecting-IP, Anvil returns that
address when the peer is trusted.
TrustedProxies accepts CIDR ranges and literal IP addresses. Configuring a
proxy header without at least one trusted proxy is a startup error because that
would allow client-IP spoofing.
Use RunTLS or ListenTLS when Anvil terminates TLS directly:
if err := app.ListenTLS(":443", "cert.pem", "key.pem"); err != nil { log.Fatal(err)}TLS classification happens after net/http has parsed the request. Anvil does
not classify raw TLS bytes. If TLS terminates at a reverse proxy, run the app
with Listen behind that proxy.
Shutdown
Section titled “Shutdown”When the edge transport shuts down, it stops accepting requests through
http.Server.Shutdown. It then shuts down each unique HTTP-family target
driver once.
Background transports, such as queue consumers, are managed by the core app alongside the edge listener. They are ordinary core transports, so their shutdown is coordinated by the core app rather than by the edge dispatcher.
Edge-target drivers are called through http.Handler in the normal
multi-protocol runtime. The edge serves the public socket and calls the selected
driver after classification. Their Shutdown(ctx) method still runs during edge
shutdown.
The edge de-duplicates target shutdown by Protocol(). Official drivers use
one protocol per module, so each target driver is shut down once. Custom edge
drivers should keep protocol names stable and unique for the same reason.
At the core runtime layer, all registered core transports start concurrently. If the app context is canceled, Anvil shuts everything down. If any transport returns first, Anvil also shuts everything down and returns the joined transport and shutdown errors. Shutdown hooks run after transport shutdown, in reverse registration order.
Transport Start(addr) methods are expected to block until the transport is
stopped or fails. If a transport returns nil immediately, the core runtime
treats that as the first transport return, starts shutdown for the rest of the
app, and returns after shutdown completes. A driver that starts its own listener
goroutine and returns from Start should document that behavior explicitly.
Lower-Level TCP Mux
Section titled “Lower-Level TCP Mux”core/mux is a lower-level TCP multiplexer for transports that implement
ServeConn(net.Conn). It peeks at initial bytes and hands the connection to the
first matching registered transport.
The public HTTP, GraphQL, and gRPC runtime uses core/edge because all three
protocols are HTTP-family protocols and need request-level classification.
Use the edge model when reasoning about normal Anvil applications.
The TCP mux is useful for lower-level protocol experiments where the first bytes are enough to identify the protocol. Its defaults are:
| Setting | Default |
|---|---|
| Peek bytes | 24 |
| Peek timeout | 5s |
Operational behavior:
- Matchers run in registration order.
- A matcher panic is treated as “no match” for that matcher.
- If the peer closes after sending fewer than the requested peek bytes, the mux still tries to match the partial header.
- If no matcher accepts the header, the mux closes the connection and returns
mux.ErrNoMatchingTransport. Shutdowncloses the listener, waits for activeServeConnhandlers, and then callsShutdownon registered transports.
The TCP mux does not parse TLS or HTTP. A matcher sees the raw bytes on the connection. For normal HTTPS, REST over HTTP/2, GraphQL-over-HTTP, and gRPC, use the edge runtime instead.