@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.
- package/README.md +6 -0
- package/dist/client-entry.js +2 -1
- package/dist/client-entry.js.map +4 -4
- package/dist/http/client-bundle.d.ts +2 -0
- package/dist/http/fetch.d.ts +15 -6
- package/dist/http/landing-html.d.ts +2 -0
- package/dist/http/landing.d.ts +36 -0
- package/dist/index.core.d.ts +2 -1
- package/dist/index.js +107 -1
- package/dist/index.js.map +9 -6
- package/dist/serve-entry.js +111 -2
- package/dist/serve-entry.js.map +8 -5
- package/dist/types.d.ts +1 -0
- package/dist/worker-cf-entry.d.ts +4 -3
- package/dist/worker-cf-entry.js +115 -6
- package/dist/worker-cf-entry.js.map +11 -8
- package/package.json +3 -3
- package/src/http/client-bundle.ts +24 -0
- package/src/http/fetch.ts +32 -7
- package/src/http/landing-html.ts +23 -0
- package/src/http/landing.ts +195 -0
- package/src/index.core.ts +2 -1
- package/src/protocol/serializers/bind.ts +2 -0
- package/src/types.ts +4 -0
- package/src/worker-cf-entry.ts +9 -1
|
@@ -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 "
|
|
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
|
|
package/src/worker-cf-entry.ts
CHANGED
|
@@ -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 "
|
|
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
|