Skip to content
Query.Farm
Talk with Us

Serve over HTTP

Run a worker as a network service instead of a subprocess DuckDB spawns — on another machine, in a container, or behind a shared endpoint.

@query-farm/vgi/serve assembles the protocol, the signed state-token key, the TTL, CORS, and the standardized landing surface, then binds the port. A worker repo’s serve script becomes its registry and catalog and nothing else:

// serve.ts
import { serveVgiWorker } from "@query-farm/vgi/serve";
import { FunctionRegistry, ReadOnlyCatalogInterface } from "@query-farm/vgi";
import { double } from "./calcscalar.ts";

const registry = new FunctionRegistry();
const catalogInterface = new ReadOnlyCatalogInterface(
{ name: "calc", schemas: [{ name: "main", functions: [double] }] },
registry,
);

serveVgiWorker({
name: "calc",
doc: "Tutorial worker over HTTP.",
version: "0.1.0",
registry,
catalogInterface,
});
VGI_SIGNING_KEY=$(openssl rand -hex 32) PORT=8791 bun run serve.ts
calc VGI HTTP worker listening on http://localhost:8791
landing page   http://localhost:8791/
client bundle  http://localhost:8791/vgi-client.js
health         http://localhost:8791/health
attach         ATTACH 'calc' AS calc (TYPE vgi, LOCATION 'http://localhost:8791');

Note the shape of the worker here: serveVgiWorker takes a registry plus a catalog interface, not the Worker class the stdio tutorial used. ReadOnlyCatalogInterface builds one from the same declarative descriptor, so the function definitions carry over unchanged.

The routes are mounted at the origin root, so DuckDB attaches the bare URL:

LOAD vgi;
ATTACH 'calc' (TYPE vgi, LOCATION 'http://localhost:8791');
SELECT calc.double(21);

Output

double(21)
42

Every variable has an explicit option that takes precedence over it.

VariableDefaultMeaning
PORT8787Listen port. 0 binds an ephemeral port.
VGI_SIGNING_KEYrandomState-token HMAC key, exactly 64 hex characters.
VGI_TOKEN_TTL3600State-token lifetime in seconds.
CORS_ORIGINS*Allowed origins. Pass corsOrigins: null to disable CORS entirely.
Set a signing key before running more than one instance

The HTTP transport is stateless: a scan’s cursor round-trips through the client inside a self-contained token, sealed with an HMAC key. That is what lets requests be load-balanced across hosts at all.

Without VGI_SIGNING_KEY a random key is generated per process and a warning is printed. One process never notices. Two do, intermittently: a token minted by instance A presents to instance B, which cannot verify it, and the failure reads as flakiness rather than as misconfiguration. A restart has the same effect on a single instance — every outstanding token becomes invalid.

A key that isn’t exactly 64 hex characters is rejected rather than silently truncated, which is the behaviour you want from this particular knob.

CORS is open by default

Since 0.27.0 CORS_ORIGINS defaults to *, matching what the browser client needs to work out of the box. That is a deliberate default for a data endpoint you intend to be public, and the wrong one for a worker on an internal network — set CORS_ORIGINS to the origins you actually serve, or corsOrigins: null to switch CORS off.

Note that CORS is a browser policy, not an access control: it does not stop a non-browser client. An HTTP worker with no authentication is open to anyone who can reach the port.

  • GET / — a landing page for browsers, or a JSON status document for health checks and ?format=json. Content negotiation decides.
  • GET /describe.json — the catalog contract, for tooling.
  • GET /health{"status":"ok","server_id":"calc","protocol":"vgi"}.
  • GET /vgi-client.js — the browser client build the landing page itself uses.
createVgiWorkerFetch

serveVgiWorker calls Bun.serve for you, so it is Bun-only. To add the VGI routes to a server you already own, use createVgiWorkerFetch — same module, @query-farm/vgi/serve — which returns the fetch handler without binding a port. On Cloudflare Workers use createVgiFetch from @query-farm/vgi/worker-cf instead; all three share one implementation. See Runtimes.

The same worker serves all of them; only the entry point changes.

TransportHowWhen
stdin/stdoutnew Worker(…).run()The default. DuckDB spawns the process.
AF_UNIXrun() with –unix <path> in argvA long-lived warm worker, reused across calls.
HTTPserveVgiWorkerRemote, shared, or load-balanced.

run() parses --unix, --tcp and --idle-timeout out of argv itself and dispatches accordingly, so one entry point covers both local transports without a flag of your own. With no --unix it falls back to stdin/stdout.