Skip to content

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.

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 });

createHttpHandler(protocol, options) returns a standard (Request) => Response | Promise<Response> function compatible with:

  • BunBun.serve({ fetch: handler })
  • DenoDeno.serve(handler)
  • Cloudflare Workersexport default { fetch: handler }

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.

RouteMethodDescription
/{method}POSTUnary method call
/{method}/initPOSTInitialize a stream (producer or exchange)
/{method}/exchangePOSTSend/receive a stream batch
/__describe__POSTMachine-readable Arrow describe batch
/__upload_url__/initPOSTRequest a pre-signed upload URL (when uploadUrlProvider is set)
/__session__DELETETear down a sticky session (when enableSticky is set)
/describeGETHTML describe / API-reference page
/GETHTML landing page
/healthGETJSON health probe (auth-exempt)

POST requests carrying Arrow bodies must use Content-Type: application/vnd.apache.arrow.stream (exported as ARROW_CONTENT_TYPE).

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}.

HTTP streaming uses sealed state tokens to carry stream state across requests, so no server-side session storage is required:

  1. Client calls /{method}/init with parameters → receives the first batch plus a state token
  2. Client calls /{method}/exchange with the state token + input batch → receives an output batch plus a fresh token
  3. 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; 0 disables the TTL check).
  • The sealing key is the 32-byte tokenKey master 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 shared tokenKey to make tokens portable across workers.

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: 7200, // Access-Control-Max-Age for preflight (default: 7200; 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, // response body cap (hard for unary/exchange, soft for producer)
maxExternalizedResponseBytes: 50_000_000, // cap on externalized payload bytes per response
compressionLevel: 3, // enable response compression (gzip/zstd) at this level
authenticate: myAuthFn, // authentication callback (see Authentication)
enableSticky: true, // opt-in sticky sessions
});

Key options:

OptionDescription
prefixPath prefix for all routes. Default "" (root).
tokenKey32-byte XChaCha20-Poly1305 master key sealing stream-state tokens. Random if omitted.
tokenTtlState-token TTL in seconds. Default 3600; 0 disables TTL checks.
corsOriginsCORS allowed origins; enables CORS headers when set.
corsMaxAgeAccess-Control-Max-Age for preflight responses. Default 7200; null omits it.
maxRequestBytesMax request body size. Advertised via VGI-Max-Request-Bytes.
maxDecompressedRequestBytesCap on post-decompression request size (zstd/gzip bomb defense). Defaults to maxRequestBytes * 16 when that is set.
maxResponseBytesResponse body cap. Hard for unary/exchange, soft for producer (overshoot mints a continuation token).
maxExternalizedResponseBytesHard cap on bytes uploaded to external storage during one response.
compressionLevelEnables response compression (gzip/zstd) at this level.
authenticatePer-request authentication callback.
enableStickyOpt-in sticky sessions. See Sticky Sessions.
externalLocation / uploadUrlProvider / maxUploadBytesLarge-payload externalization. See Large Payloads.
dispatchHook / onServeStartObservability / lifecycle hooks.
serverIdServer ID in response metadata. Random if omitted.
maxStreamResponseBytesDeprecated — use maxResponseBytes. The producer-only soft cap; kept for backward compatibility.

See Configuration for full details on each option.

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).

When compressionLevel is set, responses are compressed (zstd preferred when the runtime can encode it, otherwise gzip) according to the client’s Accept-Encoding. The server advertises what it can produce via VGI-Supported-Encodings.

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 2 hours; null omits the header), and the relevant VGI response headers are surfaced via Access-Control-Expose-Headers.

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.

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 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 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.

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") } }),
);

The Python CLI supports HTTP transport:

Terminal window
# Describe
vgi-rpc --url http://localhost:8080 describe
# Call a unary method
vgi-rpc --url http://localhost:8080 call add a=1.0 b=2.0