Skip to content

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.

Application code stays compact:

app := anvil.New(
httpstd.Driver(),
anvilgrpc.Driver(),
anvilgraphql.Driver(),
)

During app.Run or app.Listen, Anvil finalizes transports:

  1. It creates the core edge transport.
  2. It inspects registered transports.
  3. 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.
  4. Transports that implement http.Handler and one of the SDK protocol contracts become edge targets:
    • sdk.HTTPTransport
    • sdk.GraphQLTransport
    • sdk.GRPCTransport
  5. Queue transports and other non-HTTP-family transports run as background transports through the core app.
  6. If at least one HTTP-family target exists, Anvil registers one edge transport with the core app. The edge receives the public addr. 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.

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, or sdk.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:

  1. sdk.GraphQLTransport
  2. sdk.GRPCTransport
  3. sdk.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.

The edge dispatcher checks requests in this order:

  1. gRPC
  2. GraphQL
  3. 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.

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.

The edge listener uses net/http.Server with defensive defaults when the application does not provide values:

SettingDefault
ReadHeaderTimeout5s
ReadTimeout30s
WriteTimeout30s
IdleTimeout2m

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.

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.

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.

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.

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:

SettingDefault
Peek bytes24
Peek timeout5s

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.
  • Shutdown closes the listener, waits for active ServeConn handlers, and then calls Shutdown on 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.