@query-farm/vgi 0.25.4 → 0.26.1

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,195 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: LicenseRef-QueryFarm-Source-Available-1.0
3
+
4
+ // The standardized VGI landing surface.
5
+ //
6
+ // This lives here, not in `@query-farm/vgi-rpc`, because everything it serves
7
+ // is VGI vocabulary: a page that renders catalogs, schemas, tables, functions
8
+ // and macros, and the browser build of *this package's* client that reads them
9
+ // over the protocol. vgi-rpc is generic RPC over Arrow and depends on nothing
10
+ // here — yet it used to vendor both assets, which put a compiled artifact of
11
+ // the higher layer inside the lower one and added ~900 KB to every consumer of
12
+ // vgi-rpc, whether or not they served a VGI page. vgi-python already drew the
13
+ // line this way: its landing surface is in `vgi/http/`, and `vgi_rpc/http/` has
14
+ // neither asset.
15
+ //
16
+ // It reaches the request through vgi-rpc's `extraRoutes` hook rather than by
17
+ // wrapping the returned fetch handler, because ordering matters. Contributed
18
+ // routes run at the same pipeline position the surface occupied when it lived
19
+ // inside vgi-rpc — after the OAuth browser-redirect branch, and before the
20
+ // generic landing page and the 404. Wrapping from outside would answer ahead of
21
+ // that redirect, so a worker with OAuth configured would show this page to a
22
+ // browser that should have been sent to its identity provider first.
23
+ //
24
+ // That redirect is the only thing guarding these two routes, and only for
25
+ // browsers: vgi-rpc gates GET pages on `authenticate && pkceConfig`, and a
26
+ // non-browser caller that fails auth falls through to normal page serving. So
27
+ // on a worker with no PKCE configured, the page and the client bundle are
28
+ // public — as the generic landing page always was. Worth knowing before
29
+ // treating anything on this surface as private; vgi-rpc-python is stricter and
30
+ // answers 401 here.
31
+
32
+ import type { ExtraRouteContext, ExtraRouteHandler } from "@query-farm/vgi-rpc";
33
+ import { LANDING_HTML_BYTES } from "./landing-html.js";
34
+ import { CLIENT_BUNDLE_BYTES } from "./client-bundle.js";
35
+
36
+ /**
37
+ * Worker identity for the standardized VGI landing surface.
38
+ *
39
+ * The shared `landing.html` reads catalog metadata by speaking the VGI protocol
40
+ * through the client bundle the worker serves beside it, so nothing about the
41
+ * catalog belongs here. What the protocol has no method for — which worker this
42
+ * is, what it is called, what version it runs — rides on the JSON status
43
+ * document at `GET {prefix}/?format=json`.
44
+ */
45
+ export interface LandingInfo {
46
+ /** Worker name shown as the page heading, e.g. "ishares". */
47
+ name: string;
48
+ /** One-line description shown under the heading. */
49
+ doc?: string;
50
+ /** Worker version string shown in the footer. */
51
+ version?: string;
52
+ /** Override the Cupola base URL the "Explore" links point at. */
53
+ cupolaBase?: string;
54
+ }
55
+
56
+ /** Default Cupola deployment the page's "Explore" links point at. */
57
+ export const DEFAULT_CUPOLA_BASE = "https://cupola.query-farm.services";
58
+
59
+ // Caching. Both assets are immutable for a given build but live at URLs that
60
+ // never change, which is the combination that goes wrong quietly: the page used
61
+ // to be served with no Cache-Control and no validator at all, so browsers
62
+ // applied heuristic caching with nothing to revalidate against and could hold a
63
+ // stale page indefinitely — every landing-page fix needed a shift-reload to
64
+ // see. The bundle was worse in a different way: `max-age=3600` with no
65
+ // validator meant a full hour where a client could not learn that a release had
66
+ // happened, which is how a bundle that could not decode workerd's compressed
67
+ // responses stayed live in a browser long after it was fixed.
68
+ //
69
+ // `no-cache` does not mean "do not store" — it means "store, but revalidate
70
+ // before reuse". With a strong ETag a repeat visit is a conditional GET that
71
+ // answers 304 with no body, so the bandwidth is the same as a long TTL and the
72
+ // staleness window closes to zero. `public` is kept so shared caches (the
73
+ // Cloudflare edge in front of a Workers deployment) can hold and revalidate it
74
+ // too, rather than each viewer paying the full transfer.
75
+ const REVALIDATE = "public, no-cache";
76
+
77
+ /**
78
+ * FNV-1a over the asset bytes, as a strong ETag.
79
+ *
80
+ * Computed lazily and memoised rather than at module load: most requests to a
81
+ * worker are RPC and never touch these routes, and hashing ~700 KB on every
82
+ * isolate start would tax cold starts for a page nobody may open. Once per
83
+ * isolate on first use is the right trade.
84
+ */
85
+ function etagFor(bytes: Uint8Array): string {
86
+ let hash = 0x811c9dc5;
87
+ for (let i = 0; i < bytes.length; i++) {
88
+ hash ^= bytes[i]!;
89
+ hash = Math.imul(hash, 0x01000193) >>> 0;
90
+ }
91
+ // Length is folded in so two builds that collide on the hash still differ.
92
+ return `"${hash.toString(16)}-${bytes.length.toString(16)}"`;
93
+ }
94
+
95
+ let landingEtag: string | null = null;
96
+ let bundleEtag: string | null = null;
97
+
98
+ /** 304 when the client already holds this exact body, else null. */
99
+ function notModified(request: Request, etag: string, extra?: HeadersInit): Response | null {
100
+ const ifNoneMatch = request.headers.get("If-None-Match");
101
+ if (!ifNoneMatch) return null;
102
+ // A conditional GET may list several validators, and a cache is allowed to
103
+ // weaken ours to `W/"…"` on the way back.
104
+ const matches = ifNoneMatch
105
+ .split(",")
106
+ .map((c) => c.trim().replace(/^W\//, ""))
107
+ .includes(etag);
108
+ if (!matches) return null;
109
+ const headers = new Headers(extra);
110
+ headers.set("ETag", etag);
111
+ headers.set("Cache-Control", REVALIDATE);
112
+ return new Response(null, { status: 304, headers });
113
+ }
114
+
115
+ /**
116
+ * Build the VGI landing surface as a route contributed to vgi-rpc's HTTP
117
+ * handler.
118
+ *
119
+ * Serves two paths, both relative to the handler's mount prefix:
120
+ *
121
+ * - `GET {prefix}/` — the shared `landing.html` for browsers, or a JSON status
122
+ * document for health checks, `?format=json`, and the page's own identity
123
+ * read. Content negotiation matches vgi-python's `LandingPageResource`.
124
+ * - `GET {prefix}/vgi-client.js` — the browser build the page imports.
125
+ *
126
+ * Anything else returns `null`, which lets normal routing continue.
127
+ */
128
+ export function createLandingRoutes(info: LandingInfo): ExtraRouteHandler {
129
+ return (request: Request, ctx: ExtraRouteContext): Response | null => {
130
+ const path = ctx.url.pathname;
131
+ const { prefix } = ctx;
132
+
133
+ if (path === prefix || path === `${prefix}/`) {
134
+ const accept = request.headers.get("Accept") ?? "";
135
+ const wantJson =
136
+ ctx.url.searchParams.get("format") === "json" ||
137
+ (accept.includes("application/json") && !accept.includes("text/html"));
138
+ if (wantJson) {
139
+ // Rebuilt per request rather than cached: `oauthActive` and `serverId`
140
+ // come from the handler, so this cannot be pre-rendered at construction
141
+ // time the way it was when it lived inside vgi-rpc.
142
+ const body = JSON.stringify({
143
+ status: "ok",
144
+ server_id: ctx.serverId,
145
+ protocol: "vgi",
146
+ worker: info.name,
147
+ doc: info.doc ?? "",
148
+ version: info.version ?? "",
149
+ lang: "typescript",
150
+ oauth: ctx.oauthActive,
151
+ cupola_base: info.cupolaBase ?? DEFAULT_CUPOLA_BASE,
152
+ });
153
+ const headers = new Headers({ "Content-Type": "application/json" });
154
+ ctx.addCorsHeaders(headers);
155
+ return new Response(body, { status: 200, headers });
156
+ }
157
+ landingEtag ??= etagFor(LANDING_HTML_BYTES);
158
+ const conditional = notModified(request, landingEtag);
159
+ if (conditional) {
160
+ ctx.addCorsHeaders(conditional.headers);
161
+ return conditional;
162
+ }
163
+ const headers = new Headers({
164
+ "Content-Type": "text/html; charset=utf-8",
165
+ "Cache-Control": REVALIDATE,
166
+ ETag: landingEtag,
167
+ });
168
+ ctx.addCorsHeaders(headers);
169
+ return new Response(LANDING_HTML_BYTES as unknown as BodyInit, { status: 200, headers });
170
+ }
171
+
172
+ // Served by the worker rather than from a CDN: this page is same-origin
173
+ // with an authenticated worker and carries its session cookie, so
174
+ // third-party script here would run with full access to that origin — and a
175
+ // CDN dependency would break air-gapped deployments that today need nothing
176
+ // but the worker.
177
+ if (path === `${prefix}/vgi-client.js`) {
178
+ bundleEtag ??= etagFor(CLIENT_BUNDLE_BYTES);
179
+ const conditional = notModified(request, bundleEtag);
180
+ if (conditional) {
181
+ ctx.addCorsHeaders(conditional.headers);
182
+ return conditional;
183
+ }
184
+ const headers = new Headers({
185
+ "Content-Type": "text/javascript; charset=utf-8",
186
+ "Cache-Control": REVALIDATE,
187
+ ETag: bundleEtag,
188
+ });
189
+ ctx.addCorsHeaders(headers);
190
+ return new Response(CLIENT_BUNDLE_BYTES as unknown as BodyInit, { status: 200, headers });
191
+ }
192
+
193
+ return null;
194
+ };
195
+ }
package/src/index.core.ts CHANGED
@@ -157,7 +157,8 @@ export { buildVgiProtocol, type ProtocolConfig } from "./protocol/dispatch.js";
157
157
  // speaking the protocol through the client bundle the worker serves beside it,
158
158
  // so a worker supplies only its own identity via `createHttpHandler`'s
159
159
  // `landingInfo` option.
160
- export type { LandingInfo } from "@query-farm/vgi-rpc";
160
+ export type { LandingInfo } from "./http/landing.js";
161
+ export { createLandingRoutes } from "./http/landing.js";
161
162
 
162
163
  // Wire schemas — needed by workers that synthesize TableInfo.bind_result /
163
164
  // TableInfo.scan_function bytes to short-circuit per-scan RPCs.
@@ -150,6 +150,8 @@ export function deserializeBindRequest(
150
150
  functionType = FunctionType.SCALAR;
151
151
  } else if (ftStr === "table" || ftStr === "TABLE") {
152
152
  functionType = FunctionType.TABLE;
153
+ } else if (ftStr === "table_buffering" || ftStr === "TABLE_BUFFERING") {
154
+ functionType = FunctionType.TABLE_BUFFERING;
153
155
  } else if (ftStr === "aggregate" || ftStr === "AGGREGATE") {
154
156
  functionType = FunctionType.AGGREGATE;
155
157
  } else {
package/src/types.ts CHANGED
@@ -4,6 +4,10 @@
4
4
  export enum FunctionType {
5
5
  SCALAR = "scalar",
6
6
  TABLE = "table",
7
+ // Sink+source function bound through defineTableBufferingFunction. Mirrors
8
+ // vgi-python's FunctionType.TABLE_BUFFERING; without it a buffering bind fell
9
+ // through deserializeBindRequest's normalization to a bare `as` cast.
10
+ TABLE_BUFFERING = "table_buffering",
7
11
  AGGREGATE = "aggregate",
8
12
  }
9
13
 
@@ -36,6 +36,11 @@ export {
36
36
  // The blended config has no `argConstraints` of its own, so a worker that
37
37
  // wants ge/le/choices/pattern on a row-transform's args applies them itself.
38
38
  constraintSpecFields,
39
+ // Result-cache control (`vgi.cache.*`). Advertised as metadata on the FIRST
40
+ // emitted batch, so it composes with `parentRowsMetadata` via the `extra`
41
+ // argument. Belongs on this facade for the same reason the blended helpers do:
42
+ // a workerd worker cannot reach it through the package root.
43
+ cacheControlMetadata,
39
44
  FunctionRegistry,
40
45
  ReadOnlyCatalogInterface,
41
46
  CompositeCatalogInterface,
@@ -50,6 +55,8 @@ export {
50
55
  } from "./index.core.js";
51
56
 
52
57
  export type {
58
+ CacheControl,
59
+ CacheScope,
53
60
  AttachOptionSpec,
54
61
  ColumnStatistics,
55
62
  CatalogAttachResult,
@@ -73,7 +80,8 @@ export type { ProtocolConfig } from "./protocol/dispatch.js";
73
80
  // Landing surface. CF workers pass their identity as `landingInfo` so `GET /`
74
81
  // serves the standardized VGI landing page, and `GET /vgi-client.js` the
75
82
  // browser client build it reads the catalog with.
76
- export type { LandingInfo } from "@query-farm/vgi-rpc";
83
+ export type { LandingInfo } from "./http/landing.js";
84
+ export { createLandingRoutes } from "./http/landing.js";
77
85
 
78
86
  // Backend-agnostic Arrow facade. Same source resolves to arrow-js on
79
87
  // Node/Bun and flechette under workerd/worker/browser via the package's