@query-farm/vgi 0.26.0 → 0.27.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.
- package/dist/client-entry.js +2 -1
- package/dist/client-entry.js.map +4 -4
- package/dist/http/fetch.d.ts +12 -4
- package/dist/index.js +46 -3
- package/dist/index.js.map +5 -5
- package/dist/serve-entry.js +47 -5
- package/dist/serve-entry.js.map +6 -6
- package/dist/types.d.ts +1 -0
- package/dist/worker-cf-entry.d.ts +2 -2
- package/dist/worker-cf-entry.js +49 -8
- package/dist/worker-cf-entry.js.map +9 -9
- package/package.json +1 -1
- package/src/http/fetch.ts +14 -5
- package/src/http/landing.ts +75 -2
- package/src/protocol/serializers/bind.ts +2 -0
- package/src/serve-entry.ts +5 -3
- package/src/types.ts +4 -0
- package/src/worker-cf-entry.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@query-farm/vgi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "TypeScript framework for extending DuckDB with user-defined functions via the Vector Gateway Interface (VGI) over Arrow IPC.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Query Farm LLC (https://query.farm)",
|
package/src/http/fetch.ts
CHANGED
|
@@ -29,10 +29,18 @@ export interface VgiFetchOptions {
|
|
|
29
29
|
prefix?: string;
|
|
30
30
|
/** Server ID for state-token attribution (default "vgi-cf"). */
|
|
31
31
|
serverId?: string;
|
|
32
|
-
/** CORS allowed origins
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
32
|
+
/** CORS allowed origins, added to all responses so the preflight `OPTIONS`
|
|
33
|
+
* that browser clients send before `__describe__` succeeds.
|
|
34
|
+
*
|
|
35
|
+
* **Defaults to `"*"`.** A VGI worker exists to be attached and explored, and
|
|
36
|
+
* the hosted Cupola UI reaches it from another origin, so cross-origin is the
|
|
37
|
+
* normal case rather than the exception — and a worker that omits this gets
|
|
38
|
+
* no CORS headers at all, which fails only in a browser and only for someone
|
|
39
|
+
* else's page, i.e. nowhere its author is looking. `serveVgiWorker` has
|
|
40
|
+
* always defaulted this way; this matches it so both entries agree.
|
|
41
|
+
*
|
|
42
|
+
* Pass `null` to disable CORS. */
|
|
43
|
+
corsOrigins?: string | null;
|
|
36
44
|
/** Public source-repository URL, surfaced on the landing page. */
|
|
37
45
|
repositoryUrl?: string;
|
|
38
46
|
/** Worker identity for the standardized VGI landing surface: `GET /` serves
|
|
@@ -92,7 +100,8 @@ export function createVgiFetch(opts: VgiFetchOptions): (req: Request) => Promise
|
|
|
92
100
|
tokenKey: opts.signingKey,
|
|
93
101
|
tokenTtl,
|
|
94
102
|
stateSerializer: arrowStateSerializer,
|
|
95
|
-
|
|
103
|
+
// `null` is the explicit opt-out; `undefined` (omitted) means "*".
|
|
104
|
+
corsOrigins: opts.corsOrigins === null ? undefined : (opts.corsOrigins ?? "*"),
|
|
96
105
|
repositoryUrl: opts.repositoryUrl,
|
|
97
106
|
// The landing surface is ours, contributed into vgi-rpc's routing rather
|
|
98
107
|
// than built there. `enableLandingPage: false` drops vgi-rpc's generic
|
package/src/http/landing.ts
CHANGED
|
@@ -56,6 +56,62 @@ export interface LandingInfo {
|
|
|
56
56
|
/** Default Cupola deployment the page's "Explore" links point at. */
|
|
57
57
|
export const DEFAULT_CUPOLA_BASE = "https://cupola.query-farm.services";
|
|
58
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
|
+
|
|
59
115
|
/**
|
|
60
116
|
* Build the VGI landing surface as a route contributed to vgi-rpc's HTTP
|
|
61
117
|
* handler.
|
|
@@ -98,7 +154,17 @@ export function createLandingRoutes(info: LandingInfo): ExtraRouteHandler {
|
|
|
98
154
|
ctx.addCorsHeaders(headers);
|
|
99
155
|
return new Response(body, { status: 200, headers });
|
|
100
156
|
}
|
|
101
|
-
|
|
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
|
+
});
|
|
102
168
|
ctx.addCorsHeaders(headers);
|
|
103
169
|
return new Response(LANDING_HTML_BYTES as unknown as BodyInit, { status: 200, headers });
|
|
104
170
|
}
|
|
@@ -109,9 +175,16 @@ export function createLandingRoutes(info: LandingInfo): ExtraRouteHandler {
|
|
|
109
175
|
// CDN dependency would break air-gapped deployments that today need nothing
|
|
110
176
|
// but the worker.
|
|
111
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
|
+
}
|
|
112
184
|
const headers = new Headers({
|
|
113
185
|
"Content-Type": "text/javascript; charset=utf-8",
|
|
114
|
-
"Cache-Control":
|
|
186
|
+
"Cache-Control": REVALIDATE,
|
|
187
|
+
ETag: bundleEtag,
|
|
115
188
|
});
|
|
116
189
|
ctx.addCorsHeaders(headers);
|
|
117
190
|
return new Response(CLIENT_BUNDLE_BYTES as unknown as BodyInit, { status: 200, headers });
|
|
@@ -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/serve-entry.ts
CHANGED
|
@@ -179,9 +179,11 @@ export function createVgiWorkerFetch(
|
|
|
179
179
|
const env = opts.env ?? (process.env as ServeEnv);
|
|
180
180
|
const signingKey = resolveSigningKey(opts.signingKey, env);
|
|
181
181
|
const tokenTtl = resolveTokenTtl(opts.tokenTtl, env);
|
|
182
|
-
// `null` disables CORS
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
// `null` disables CORS and must stay `null` through to createVgiFetch, which
|
|
183
|
+
// now reads a bare `undefined` as "default to *" — passing `undefined` here to
|
|
184
|
+
// mean "off" would silently re-enable it. `undefined` falls through to the env
|
|
185
|
+
// var and then to that same default.
|
|
186
|
+
const corsOrigins = opts.corsOrigins === null ? null : (opts.corsOrigins ?? env.CORS_ORIGINS);
|
|
185
187
|
|
|
186
188
|
return createVgiFetch({
|
|
187
189
|
protocol: { registry: opts.registry, catalogInterface: opts.catalogInterface },
|
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,
|