@query-farm/vgi 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // Bun HTTP entrypoint for a VGI worker: `import { serveVgiWorker } from "@query-farm/vgi/serve"`.
3
+ //
4
+ // A worker's HTTP server is the same nine moving parts every time (protocol,
5
+ // state-token key, TTL, CORS, landing surface, port, Bun.serve, startup log).
6
+ // This collapses them to one call so a worker repo's serve script is its
7
+ // registry + catalog and nothing else:
8
+ //
9
+ // serveVgiWorker({
10
+ // name: "ishares",
11
+ // doc: "iShares (BlackRock) US fund data.",
12
+ // version: "0.1.0",
13
+ // repositoryUrl: "https://github.com/Query-farm/vgi-etf-ishares",
14
+ // registry,
15
+ // catalogInterface,
16
+ // });
17
+ //
18
+ // Mounted at the ROOT prefix, so DuckDB attaches the bare origin:
19
+ // ATTACH 'ishares' AS ishares (TYPE vgi, LOCATION 'http://localhost:8787');
20
+ //
21
+ // Bun-only — it calls Bun.serve. Cloudflare Workers use `vgi/worker-cf`'s
22
+ // createVgiFetch instead; both share the handler factory in http/fetch.ts.
23
+
24
+ import type { CatalogInterface } from "./catalog/interface.js";
25
+ import type { FunctionRegistry } from "./functions/registry.js";
26
+ import { createLandingDescribe } from "./http/describe-json.js";
27
+ import { createVgiFetch } from "./http/fetch.js";
28
+
29
+ /** Environment variables `serveVgiWorker` reads. Injectable for testing. */
30
+ export interface ServeEnv {
31
+ PORT?: string;
32
+ VGI_SIGNING_KEY?: string;
33
+ VGI_TOKEN_TTL?: string;
34
+ CORS_ORIGINS?: string;
35
+ }
36
+
37
+ export const DEFAULT_PORT = 8787;
38
+ export const DEFAULT_TOKEN_TTL = 3600;
39
+ /** Bytes of HMAC key `createHttpHandler` expects; 64 hex characters. */
40
+ export const SIGNING_KEY_BYTES = 32;
41
+
42
+ export interface ServeVgiWorkerOptions {
43
+ /** Short worker name shown on the landing page, e.g. "ishares". */
44
+ name: string;
45
+ /** One-line description of what the worker serves. */
46
+ doc: string;
47
+ /** Worker version string shown on the landing page. */
48
+ version: string;
49
+ /** Functions the worker serves, already registered. */
50
+ registry: FunctionRegistry;
51
+ /** Catalog DuckDB attaches to. */
52
+ catalogInterface: CatalogInterface;
53
+
54
+ /** Public source-repository URL, linked from the landing page. */
55
+ repositoryUrl?: string;
56
+ /** Overrides `$PORT` (default 8787). Pass 0 to bind an ephemeral port. */
57
+ port?: number;
58
+ /** Overrides `$VGI_SIGNING_KEY`. 32 bytes. */
59
+ signingKey?: Uint8Array;
60
+ /** Overrides `$VGI_TOKEN_TTL` (default 3600 seconds). */
61
+ tokenTtl?: number;
62
+ /** Overrides `$CORS_ORIGINS` (default "*"). Pass `null` to disable CORS. */
63
+ corsOrigins?: string | null;
64
+ /** Server ID for state-token attribution (default: `name`). */
65
+ serverId?: string;
66
+ /** Mount point for the VGI endpoints (default "", the origin root). */
67
+ prefix?: string;
68
+ /** Suppress the startup banner on stdout. */
69
+ quiet?: boolean;
70
+ /** Environment source (default `process.env`). */
71
+ env?: ServeEnv;
72
+ }
73
+
74
+ /** The slice of `Bun.serve`'s return value this module uses. */
75
+ export interface VgiHttpServer {
76
+ readonly port: number;
77
+ readonly url: URL;
78
+ stop(closeActiveConnections?: boolean): void;
79
+ }
80
+
81
+ interface BunLike {
82
+ serve(options: {
83
+ port: number;
84
+ fetch: (req: Request) => Promise<Response>;
85
+ }): VgiHttpServer;
86
+ }
87
+
88
+ /**
89
+ * Decode a 32-byte signing key from its hex representation.
90
+ *
91
+ * Throws on anything that is not exactly 64 hex characters. A silent truncate
92
+ * or zero-pad here would produce a worker that starts happily with a key far
93
+ * weaker than the operator believed they supplied.
94
+ */
95
+ export function parseSigningKeyHex(hex: string): Uint8Array {
96
+ const cleaned = hex.trim();
97
+ const expected = SIGNING_KEY_BYTES * 2;
98
+ if (cleaned.length !== expected || !/^[0-9a-fA-F]+$/.test(cleaned)) {
99
+ throw new Error(
100
+ `VGI_SIGNING_KEY must be exactly ${expected} hex characters ` +
101
+ `(${SIGNING_KEY_BYTES} bytes); got ${cleaned.length}. ` +
102
+ `Generate one with: openssl rand -hex ${SIGNING_KEY_BYTES}`,
103
+ );
104
+ }
105
+ const key = new Uint8Array(SIGNING_KEY_BYTES);
106
+ for (let i = 0; i < SIGNING_KEY_BYTES; i++) {
107
+ key[i] = Number.parseInt(cleaned.slice(i * 2, i * 2 + 2), 16);
108
+ }
109
+ return key;
110
+ }
111
+
112
+ /**
113
+ * Resolve the state-token signing key: explicit option, then `$VGI_SIGNING_KEY`,
114
+ * then a fresh random key.
115
+ *
116
+ * The random fallback is safe but ephemeral, and that tradeoff is why it warns:
117
+ * state tokens minted under it stop validating after a restart, and never
118
+ * validate across a second instance behind a load balancer. It exists so
119
+ * `bun run scripts/serve.ts` works with no setup, not so deployments can skip
120
+ * the key.
121
+ */
122
+ export function resolveSigningKey(
123
+ explicit: Uint8Array | undefined,
124
+ env: ServeEnv,
125
+ warn: (msg: string) => void = console.error,
126
+ ): Uint8Array {
127
+ if (explicit) {
128
+ if (explicit.length !== SIGNING_KEY_BYTES) {
129
+ throw new Error(
130
+ `signingKey must be ${SIGNING_KEY_BYTES} bytes; got ${explicit.length}.`,
131
+ );
132
+ }
133
+ return explicit;
134
+ }
135
+ const fromEnv = env.VGI_SIGNING_KEY?.trim();
136
+ if (fromEnv) return parseSigningKeyHex(fromEnv);
137
+
138
+ warn(
139
+ "WARNING: VGI_SIGNING_KEY is not set; generated a random state-token key.\n" +
140
+ " State tokens will not survive a restart, and will not validate\n" +
141
+ " across multiple instances behind a load balancer.\n" +
142
+ ` Set it for any real deployment: openssl rand -hex ${SIGNING_KEY_BYTES}`,
143
+ );
144
+ return crypto.getRandomValues(new Uint8Array(SIGNING_KEY_BYTES));
145
+ }
146
+
147
+ function resolvePort(explicit: number | undefined, env: ServeEnv): number {
148
+ if (explicit !== undefined) return explicit;
149
+ const raw = env.PORT?.trim();
150
+ if (!raw) return DEFAULT_PORT;
151
+ const port = Number(raw);
152
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
153
+ throw new Error(`PORT must be an integer between 0 and 65535; got ${JSON.stringify(raw)}.`);
154
+ }
155
+ return port;
156
+ }
157
+
158
+ function resolveTokenTtl(explicit: number | undefined, env: ServeEnv): number {
159
+ if (explicit !== undefined) return explicit;
160
+ const raw = env.VGI_TOKEN_TTL?.trim();
161
+ if (!raw) return DEFAULT_TOKEN_TTL;
162
+ const ttl = Number(raw);
163
+ if (!Number.isInteger(ttl) || ttl < 0) {
164
+ throw new Error(`VGI_TOKEN_TTL must be a non-negative integer; got ${JSON.stringify(raw)}.`);
165
+ }
166
+ return ttl;
167
+ }
168
+
169
+ /**
170
+ * Build the fetch handler a VGI worker serves over HTTP, with the standardized
171
+ * landing surface mounted at `prefix`.
172
+ *
173
+ * Exposed separately from {@link serveVgiWorker} so a worker can put the VGI
174
+ * routes inside its own Bun/Hono/Elysia server, or drive them from a test
175
+ * without binding a port.
176
+ */
177
+ export function createVgiWorkerFetch(
178
+ opts: ServeVgiWorkerOptions,
179
+ ): (req: Request) => Promise<Response> {
180
+ const env = opts.env ?? (process.env as ServeEnv);
181
+ const signingKey = resolveSigningKey(opts.signingKey, env);
182
+ const tokenTtl = resolveTokenTtl(opts.tokenTtl, env);
183
+ // `null` disables CORS; `undefined` falls through to the env var, then "*".
184
+ const corsOrigins =
185
+ opts.corsOrigins === null ? undefined : (opts.corsOrigins ?? env.CORS_ORIGINS ?? "*");
186
+
187
+ return createVgiFetch({
188
+ protocol: { registry: opts.registry, catalogInterface: opts.catalogInterface },
189
+ signingKey,
190
+ tokenTtl,
191
+ prefix: opts.prefix ?? "",
192
+ serverId: opts.serverId ?? opts.name,
193
+ corsOrigins,
194
+ repositoryUrl: opts.repositoryUrl,
195
+ landingDescribe: createLandingDescribe(opts.catalogInterface, {
196
+ name: opts.name,
197
+ doc: opts.doc,
198
+ version: opts.version,
199
+ }),
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Serve a VGI worker over HTTP on Bun and return the running server.
205
+ *
206
+ * Reads `PORT`, `VGI_SIGNING_KEY`, `VGI_TOKEN_TTL`, and `CORS_ORIGINS` from the
207
+ * environment; every one has an explicit option that takes precedence.
208
+ */
209
+ export function serveVgiWorker(opts: ServeVgiWorkerOptions): VgiHttpServer {
210
+ const runtime = (globalThis as { Bun?: BunLike }).Bun;
211
+ if (!runtime) {
212
+ throw new Error(
213
+ '"@query-farm/vgi/serve" requires the Bun runtime (it calls Bun.serve). ' +
214
+ 'On Cloudflare Workers use createVgiFetch from "@query-farm/vgi/worker-cf"; ' +
215
+ "on other runtimes use createVgiWorkerFetch and bind the port yourself.",
216
+ );
217
+ }
218
+
219
+ const env = opts.env ?? (process.env as ServeEnv);
220
+ const port = resolvePort(opts.port, env);
221
+ const fetch = createVgiWorkerFetch(opts);
222
+ const server = runtime.serve({ port, fetch });
223
+
224
+ if (!opts.quiet) {
225
+ const base = `http://localhost:${server.port}`;
226
+ console.log(`${opts.name} VGI HTTP worker listening on ${base}`);
227
+ console.log(` landing page ${base}/`);
228
+ console.log(` describe.json ${base}/describe.json`);
229
+ console.log(` health ${base}/health`);
230
+ console.log(` attach ATTACH '${opts.name}' AS ${opts.name} (TYPE vgi, LOCATION '${base}');`);
231
+ }
232
+ return server;
233
+ }
@@ -13,57 +13,8 @@
13
13
  // `createVgiFetch` rather than a hard-coded singleton, so a CF Worker module
14
14
  // can compose multiple registries the same way the Bun worker does.
15
15
 
16
- import { createHttpHandler, unpackStateToken, type Protocol } from "@query-farm/vgi-rpc";
17
- import { arrowStateSerializer } from "./protocol/state-serializer.js";
18
- import { buildVgiProtocol, type ProtocolConfig } from "./protocol/dispatch.js";
19
-
20
- export interface VgiFetchOptions {
21
- /** Wire-protocol config (registry + catalogInterface). */
22
- protocol: Omit<ProtocolConfig, "recoverExchangeState">;
23
- /** HMAC key for state-token signing. Pass a stable, secret 32-byte key
24
- * (e.g. derived from a Wrangler secret). Required because Workers don't
25
- * preserve in-memory state across requests/instances. */
26
- signingKey: Uint8Array;
27
- /** State-token TTL in seconds (default 3600). */
28
- tokenTtl?: number;
29
- /** URL path prefix for VGI requests (default "/vgi"). */
30
- prefix?: string;
31
- /** Server ID for state-token attribution (default "vgi-cf"). */
32
- serverId?: string;
33
- }
34
-
35
- /**
36
- * Build a fetch handler suitable for `export default { fetch }` in a CF
37
- * Worker module. The returned handler is async-safe across Workers'
38
- * isolate-per-request execution model — all state round-trips through the
39
- * signed state token created here.
40
- */
41
- export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise<Response> {
42
- const tokenTtl = opts.tokenTtl ?? 3600;
43
- const prefix = opts.prefix ?? "/vgi";
44
- const serverId = opts.serverId ?? "vgi-cf";
45
-
46
- const protocol: Protocol = buildVgiProtocol({
47
- signingKey: opts.signingKey,
48
- ...opts.protocol,
49
- recoverExchangeState: async (opaqueData: Uint8Array) => {
50
- const tokenString = new TextDecoder().decode(opaqueData);
51
- // principal binding is enforced by the HTTP handler on the request that
52
- // carried this token; the recovery path itself is not principal-scoped.
53
- const unpacked = await unpackStateToken(tokenString, opts.signingKey, tokenTtl, undefined);
54
- return arrowStateSerializer.deserialize(unpacked.stateBytes);
55
- },
56
- });
57
-
58
- const handler = createHttpHandler(protocol, {
59
- prefix,
60
- serverId,
61
- tokenKey: opts.signingKey,
62
- tokenTtl,
63
- stateSerializer: arrowStateSerializer,
64
- });
65
- return async (req: Request) => handler(req);
66
- }
16
+ export { createVgiFetch } from "./http/fetch.js";
17
+ export type { VgiFetchOptions } from "./http/fetch.js";
67
18
 
68
19
  // Re-export the public types CF Worker authors need to wire a registry +
69
20
  // catalog. They import from "vgi/worker-cf" rather than from "vgi" directly
@@ -102,6 +53,11 @@ export type {
102
53
 
103
54
  export type { ProtocolConfig } from "./protocol/dispatch.js";
104
55
 
56
+ // Landing surface. CF workers pass the provider as `landingDescribe` so
57
+ // `GET /` serves the standardized VGI landing page for the worker's catalog.
58
+ export { createLandingDescribe } from "./http/describe-json.js";
59
+ export type { LandingDescribeProvider, WorkerDescribeInfo } from "./http/describe-json.js";
60
+
105
61
  // Backend-agnostic Arrow facade. Same source resolves to arrow-js on
106
62
  // Node/Bun and flechette under workerd/worker/browser via the package's
107
63
  // `#arrow-impl` conditional import. Worker authors should always go