HTTP Transport
In addition to stdin/stdout, vgi-rpc can serve methods over HTTP using the createHttpHandler() function. This enables browser clients, load balancing, and standard HTTP infrastructure.
Quick start
Section titled “Quick start”import { Protocol, float, createHttpHandler } from "@query-farm/vgi-rpc";
const protocol = new Protocol("Calculator");
protocol.unary("add", { params: { a: float, b: float }, result: { result: float }, handler: async ({ a, b }) => ({ result: a + b }),});
const handler = createHttpHandler(protocol);Bun.serve({ port: 8080, fetch: handler });Compatibility
Section titled “Compatibility”createHttpHandler(protocol, options) returns a standard (Request) => Response | Promise<Response> function compatible with:
- Bun —
Bun.serve({ fetch: handler }) - Deno —
Deno.serve(handler) - Cloudflare Workers —
export default { fetch: handler }
Routes
Section titled “Routes”All routes are served under a configurable prefix. The default prefix is "" (root), so the examples below show the root-mounted paths. When you set a prefix (e.g. /api), it is prepended to every path.
RPC routes are namespaced by protocol. A server hosts one or more protocols and dispatch resolves the pair (protocol, method), so method names may collide across protocols without colliding on the wire. Pass a VgiRpcServer rather than a bare Protocol for its co-hosted protocols — vgi_rpc.Reflection.v1, vgi_rpc.Identity.v1 — to be reachable over HTTP.
| Route | Method | Description |
|---|---|---|
/{protocol}/{method} |
POST | Unary method call |
/{protocol}/{method}/init |
POST | Initialize a stream (producer or exchange) |
/{protocol}/{method}/exchange |
POST | Send/receive a stream batch |
/__upload_url__/init |
POST | Request a pre-signed upload URL (when uploadUrlProvider is set) |
/__session__ |
DELETE | Tear down a sticky session (when enableSticky is set) |
/describe |
GET | HTML describe / API-reference page |
/ |
GET | HTML landing page |
/health |
GET | JSON health probe (auth-exempt) |
Reserved endpoints below the RPC rows belong to the server rather than to any one protocol, so they stay flat under the prefix.
Introspection is not among them. vgi_rpc.Reflection.v1 is an ordinary
co-hosted protocol and is reached on the ordinary RPC routes —
/vgi_rpc.Reflection.v1/list_protocols and /vgi_rpc.Reflection.v1/describe.
Handing createHttpHandler a bare Protocol hosts it alongside that protocol,
since a server no client can bootstrap against is not a useful default; hand it
a VgiRpcServer and its own decision stands.
POST {prefix}/__describe__ is retired. It answers 404 with a message
naming the replacement protocol and both entry points, rather than a bare “no
such method” — which a stale client cannot tell apart from “this server was
built without introspection”, and the two need opposite fixes.
Build these paths with the exported helpers rather than by hand: rpcPath(protocol, method, { prefix, suffix }), rpcPathFromPrefix(namespacedPrefix, method, { suffix }), and reservedPath(method, { prefix }).
POST requests carrying Arrow bodies must use Content-Type: application/vnd.apache.arrow.stream (exported as ARROW_CONTENT_TYPE).
The path is a projection; the metadata is canonical
Section titled “The path is a projection; the metadata is canonical”On HTTP the protocol rides twice: in the request batch’s vgi_rpc.protocol metadata, and as that path segment. On stdio, unix and named pipes the metadata is the only carrier and is therefore required. On HTTP the path segment is a faithful projection of it, present so a proxy, WAF or gateway can act on the protocol without an Arrow parser — and it is what actually resolves the binding, before anything reads the body.
Three rules follow, and the server enforces all three:
vgi_rpc.protocolis optional on HTTP and required everywhere else. By the time the server looks, the path segment has already resolved the binding, so absent is the single-carrier case rather than an unrouted request. (An earlier version of this port required it here too. That reading is wrong on HTTP and the shared conformance suite pins the permissive half as a 200.)- A request whose two carriers disagree is rejected (HTTP 400,
error_kind: protocol_not_supported). Unchecked, edge policy would be applied to one protocol while the worker dispatched another. - A
%anywhere in the protocol path segment is rejected without decoding (HTTP 404). The name charset never requires percent-encoding, so a percent sign is a bug or an attempt to make the edge and the worker read different strings.
An unknown protocol is a 404 (protocol_not_supported); a hosted protocol that lacks the named method is a 404 with method_not_implemented. The difference is the documented capability probe — a client testing for an optional method must be able to tell “you do not speak this protocol” from “you speak it but lack this method”.
/exchange continuations carry no routing metadata and are not checked against the path: the protocol is bound into the AEAD associated data of the stream’s cursor and call tokens, so a continuation presented under another protocol fails the tag check and is rejected exactly as an invalid token — including on the call-state cache-hit path, where the call token is never opened at all.
When OAuth is configured (see OAuth), additional routes are mounted: GET /_oauth/callback, GET /_oauth/logout, POST /_oauth/token, and GET /.well-known/oauth-protected-resource{prefix}.
Stateless streaming
Section titled “Stateless streaming”HTTP streaming uses sealed state tokens to carry stream state across requests, so no server-side session storage is required:
- Client calls
/{protocol}/{method}/initwith parameters → receives the first batch plus a state token - Client calls
/{protocol}/{method}/exchangewith the state token + input batch → receives an output batch plus a fresh token - Repeat step 2 until the stream ends (the final response carries no token)
State tokens are sealed with XChaCha20-Poly1305 AEAD (wire format v4):
- The Poly1305 authentication tag makes tokens tamper- and forgery-resistant — any modification to the ciphertext fails to decrypt.
- Tokens are bound to the authenticated principal via the AEAD associated data (AAD). A token sealed for principal A cannot be replayed by principal B (or by an anonymous caller, and vice versa).
- Tokens are time-limited (default
tokenTtl: 1 hour;0disables the TTL check). - The sealing key is the 32-byte
tokenKeymaster key. If you omit it, a random key is generated at startup — fine for a single process, but tokens then won’t survive a restart or work across load-balanced workers. Supply a sharedtokenKeyto make tokens portable across workers.
Configuration
Section titled “Configuration”Pass HttpHandlerOptions to customize behavior:
const handler = createHttpHandler(protocol, { prefix: "/api", // path prefix for all routes (default: "" / root) tokenKey: myKey, // 32-byte XChaCha20-Poly1305 master key (random if omitted) tokenTtl: 7200, // state-token TTL in seconds (default: 3600; 0 disables) serverId: "my-server", // server ID in response metadata (random if omitted) corsOrigins: "*", // CORS allowed origins corsMaxAge: 300, // Access-Control-Max-Age for preflight (default: 300; null omits) maxRequestBytes: 10_000_000, // max request body size maxDecompressedRequestBytes: 160_000_000, // cap on decompressed request size (zstd-bomb defense) maxResponseBytes: 5_000_000, // hard response-body cap for every RPC shape maxExternalizedResponseBytes: 50_000_000, // cap on externalized payload bytes per response compressionLevel: 1, // zstd level for responses (default: 1; null disables) authenticate: myAuthFn, // authentication callback (see Authentication) enableSticky: true, // opt-in sticky sessions});Key options:
| Option | Description |
|---|---|
prefix |
Path prefix for all routes. Default "" (root). |
tokenKey |
32-byte XChaCha20-Poly1305 master key sealing stream-state tokens. Random if omitted. |
tokenTtl |
State-token TTL in seconds. Default 3600; 0 disables TTL checks. |
corsOrigins |
CORS allowed origins; enables CORS headers when set. |
corsMaxAge |
Access-Control-Max-Age for preflight responses. Default 300; null omits it. |
maxRequestBytes |
Max request body size. Advertised via VGI-Max-Request-Bytes. |
maxDecompressedRequestBytes |
Cap on post-decompression request size (zstd/gzip bomb defense). Defaults to maxRequestBytes * 16 when that is set. |
maxResponseBytes |
Hard response-body cap for unary, exchange, and every lock-step producer turn. Overshoot is an in-band Arrow ResponseTooLargeError with no cursor. |
maxExternalizedResponseBytes |
Hard cap on bytes uploaded to external storage during one response. |
compressionLevel |
zstd level for response compression. Default 1 (compression is on by default); null disables it. |
authenticate |
Per-request authentication callback. |
proxyProofRequired |
Advertise VGI-Proxy-Proof-Required: true (advertisement only). See Authentication. |
enableSticky |
Opt-in sticky sessions. See Sticky Sessions. |
externalLocation / uploadUrlProvider / maxUploadBytes |
Large-payload externalization. See Large Payloads. |
dispatchHook / onServeStart |
Observability / lifecycle hooks. |
serverId |
Server ID in response metadata. Random if omitted. |
maxStreamResponseBytes |
Deprecated — use maxResponseBytes. Legacy producer budget; it never changes one-request/one-invocation lock-step dispatch. |
See Configuration for full details on each option.
Request and response compression
Section titled “Request and response compression”The handler transparently decompresses request bodies sent with Content-Encoding: zstd or gzip. maxDecompressedRequestBytes caps the decompressed size to defend against decompression bombs (a tiny compressed frame inflating to hundreds of MB).
Responses are compressed by default at zstd level 1 (gzip on runtimes without a zstd encoder), following the client’s stated codec preference across X-VGI-Accept-Encoding and Accept-Encoding. The server advertises what it can produce via VGI-Supported-Encodings. Pass compressionLevel: null to turn response compression off.
See the Compression guide for details.
Set corsOrigins to enable CORS headers on responses:
const handler = createHttpHandler(protocol, { corsOrigins: "*",});The handler answers OPTIONS preflight requests automatically. The preflight reflects the request’s Access-Control-Request-Headers back in Access-Control-Allow-Headers, so clients can send custom VGI request headers without a hard-coded allow-list. corsMaxAge controls how long browsers cache the preflight (default 5 minutes; null omits the header), and the relevant VGI response headers are surfaced via Access-Control-Expose-Headers.
Health endpoint
Section titled “Health endpoint”GET /health returns a small JSON document ({ "status": "ok", ... }) and is exempt from authentication, so orchestrators and load balancers can probe the server even when every RPC endpoint requires auth. Disable it with enableHealthEndpoint: false.
Cookies for unary methods
Section titled “Cookies for unary methods”Unary handlers can read request cookies — the handler parses the incoming Cookie header per request and exposes the values to the handler context. OAuth uses a _vgi_auth cookie to carry the authenticated session for browser flows.
Sticky sessions
Section titled “Sticky sessions”Sticky sessions are opt-in via enableSticky (with stickyDefaultTtl and stickyEchoHeaders). When enabled, the server advertises VGI-Sticky-Enabled: true, honors session headers, and exposes a DELETE /__session__ teardown endpoint. See Sticky Sessions for the full lifecycle.
Large payloads / external storage
Section titled “Large payloads / external storage”Large response (and request) payloads can be externalized to object storage instead of riding inline on the wire, leaving only small pointer batches. Configure externalLocation, uploadUrlProvider, and maxUploadBytes. See Large Payloads for details.
Authentication
Section titled “Authentication”Pass an authenticate callback to protect your endpoints. vgi-rpc ships with factories for bearer tokens, JWT, mTLS, and XFCC. See the Authentication guide and the OAuth guide for full details.
Quick example with bearer tokens:
import { createHttpHandler, bearerAuthenticateStatic, AuthContext } from "@query-farm/vgi-rpc";
const auth = bearerAuthenticateStatic({ tokens: { "key-abc123": new AuthContext("apikey", true, "alice"), },});
const handler = createHttpHandler(protocol, { tokenKey: myKey, authenticate: auth,});Multiple strategies can be combined with chainAuthenticate — for example, accepting both mTLS and bearer tokens:
import { chainAuthenticate, mtlsAuthenticateSubject, bearerAuthenticateStatic, AuthContext } from "@query-farm/vgi-rpc";
const auth = chainAuthenticate( mtlsAuthenticateSubject({ allowedSubjects: new Set(["my-service"]) }), bearerAuthenticateStatic({ tokens: { "sk-ci": new AuthContext("apikey", true, "ci-bot") } }),);Testing with the CLI
Section titled “Testing with the CLI”The Python CLI supports HTTP transport:
# Describevgi-rpc --url http://localhost:8080 describe
# Call a unary methodvgi-rpc --url http://localhost:8080 call add a=1.0 b=2.0