@query-farm/vgi-rpc 0.6.3 → 0.7.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/access-log.d.ts +50 -0
- package/dist/access-log.d.ts.map +1 -0
- package/dist/arrow/impl-arrowjs/index.d.ts +96 -0
- package/dist/arrow/impl-arrowjs/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/index.d.ts +102 -0
- package/dist/arrow/impl-flechette/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts +11 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts.map +1 -0
- package/dist/arrow/index.d.ts +4 -0
- package/dist/arrow/index.d.ts.map +1 -0
- package/dist/arrow/predicates.d.ts +44 -0
- package/dist/arrow/predicates.d.ts.map +1 -0
- package/dist/arrow/types.d.ts +62 -0
- package/dist/arrow/types.d.ts.map +1 -0
- package/dist/client/capabilities.d.ts +25 -0
- package/dist/client/capabilities.d.ts.map +1 -0
- package/dist/client/connect.d.ts.map +1 -1
- package/dist/client/introspect.d.ts +7 -0
- package/dist/client/introspect.d.ts.map +1 -1
- package/dist/client/ipc.d.ts +8 -2
- package/dist/client/ipc.d.ts.map +1 -1
- package/dist/client/pipe.d.ts.map +1 -1
- package/dist/client/stream.d.ts +11 -2
- package/dist/client/stream.d.ts.map +1 -1
- package/dist/client/uploadUrl.d.ts +25 -0
- package/dist/client/uploadUrl.d.ts.map +1 -0
- package/dist/constants.d.ts +15 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/crypto.d.ts +22 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/dispatch/describe.d.ts +10 -6
- package/dist/dispatch/describe.d.ts.map +1 -1
- package/dist/dispatch/stream.d.ts +2 -2
- package/dist/dispatch/stream.d.ts.map +1 -1
- package/dist/dispatch/unary.d.ts +2 -2
- package/dist/dispatch/unary.d.ts.map +1 -1
- package/dist/errors.d.ts +46 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/external.d.ts +25 -5
- package/dist/external.d.ts.map +1 -1
- package/dist/http/bearer.d.ts.map +1 -1
- package/dist/http/common.d.ts +42 -7
- package/dist/http/common.d.ts.map +1 -1
- package/dist/http/dispatch.d.ts +20 -2
- package/dist/http/dispatch.d.ts.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/mtls.d.ts +2 -1
- package/dist/http/mtls.d.ts.map +1 -1
- package/dist/http/oauth-pkce.d.ts +141 -0
- package/dist/http/oauth-pkce.d.ts.map +1 -0
- package/dist/http/pages.d.ts +3 -0
- package/dist/http/pages.d.ts.map +1 -1
- package/dist/http/sticky.d.ts +124 -0
- package/dist/http/sticky.d.ts.map +1 -0
- package/dist/http/token.d.ts +38 -12
- package/dist/http/token.d.ts.map +1 -1
- package/dist/http/types.d.ts +68 -5
- package/dist/http/types.d.ts.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1275 -3507
- package/dist/index.js.map +19 -37
- package/dist/launcher/hash.d.ts +22 -0
- package/dist/launcher/hash.d.ts.map +1 -0
- package/dist/launcher/index.d.ts +23 -0
- package/dist/launcher/index.d.ts.map +1 -0
- package/dist/launcher/launch.d.ts +27 -0
- package/dist/launcher/launch.d.ts.map +1 -0
- package/dist/launcher/lock.d.ts +19 -0
- package/dist/launcher/lock.d.ts.map +1 -0
- package/dist/launcher/serve-unix.d.ts +54 -0
- package/dist/launcher/serve-unix.d.ts.map +1 -0
- package/dist/launcher/state.d.ts +59 -0
- package/dist/launcher/state.d.ts.map +1 -0
- package/dist/otel.d.ts.map +1 -1
- package/dist/protocol.d.ts +16 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/schema.d.ts +45 -18
- package/dist/schema.d.ts.map +1 -1
- package/dist/server.d.ts +23 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +216 -12
- package/dist/types.d.ts.map +1 -1
- package/dist/util/gzip.d.ts +10 -0
- package/dist/util/gzip.d.ts.map +1 -0
- package/dist/util/schema.d.ts +3 -15
- package/dist/util/schema.d.ts.map +1 -1
- package/dist/util/web-crypto.d.ts +22 -0
- package/dist/util/web-crypto.d.ts.map +1 -0
- package/dist/util/zstd.d.ts +26 -3
- package/dist/util/zstd.d.ts.map +1 -1
- package/dist/wire/opaque.d.ts +11 -0
- package/dist/wire/opaque.d.ts.map +1 -0
- package/dist/wire/reader.d.ts +5 -5
- package/dist/wire/reader.d.ts.map +1 -1
- package/dist/wire/request.d.ts +11 -3
- package/dist/wire/request.d.ts.map +1 -1
- package/dist/wire/response.d.ts +6 -6
- package/dist/wire/response.d.ts.map +1 -1
- package/dist/wire/writer.d.ts +49 -39
- package/dist/wire/writer.d.ts.map +1 -1
- package/package.json +24 -10
- package/src/access-log.ts +195 -0
- package/src/arrow/impl-arrowjs/index.ts +433 -0
- package/src/arrow/impl-flechette/index.ts +414 -0
- package/src/arrow/impl-flechette/message-meta.ts +174 -0
- package/src/arrow/index.ts +89 -0
- package/src/arrow/predicates.ts +56 -0
- package/src/arrow/types.ts +73 -0
- package/src/client/capabilities.ts +84 -0
- package/src/client/connect.ts +103 -26
- package/src/client/introspect.ts +60 -38
- package/src/client/ipc.ts +37 -27
- package/src/client/pipe.ts +12 -9
- package/src/client/stream.ts +34 -19
- package/src/client/uploadUrl.ts +169 -0
- package/src/constants.ts +18 -1
- package/src/crypto.ts +95 -0
- package/src/dispatch/describe.ts +146 -107
- package/src/dispatch/stream.ts +53 -24
- package/src/dispatch/unary.ts +5 -4
- package/src/errors.ts +76 -0
- package/src/external.ts +43 -29
- package/src/http/bearer.ts +2 -5
- package/src/http/common.ts +90 -23
- package/src/http/dispatch.ts +373 -46
- package/src/http/handler.ts +794 -68
- package/src/http/index.ts +1 -0
- package/src/http/mtls.ts +18 -3
- package/src/http/oauth-pkce.ts +1035 -0
- package/src/http/pages.ts +30 -15
- package/src/http/sticky.ts +429 -0
- package/src/http/token.ts +165 -75
- package/src/http/types.ts +69 -5
- package/src/index.ts +40 -1
- package/src/launcher/hash.ts +104 -0
- package/src/launcher/index.ts +35 -0
- package/src/launcher/launch.ts +284 -0
- package/src/launcher/lock.ts +171 -0
- package/src/launcher/serve-unix.ts +385 -0
- package/src/launcher/state.ts +245 -0
- package/src/otel.ts +39 -33
- package/src/protocol.ts +27 -3
- package/src/schema.ts +107 -56
- package/src/server.ts +196 -20
- package/src/types.ts +322 -18
- package/src/util/gzip.ts +63 -0
- package/src/util/schema.ts +4 -22
- package/src/util/web-crypto.ts +98 -0
- package/src/util/zstd.ts +133 -14
- package/src/wire/opaque.ts +37 -0
- package/src/wire/reader.ts +5 -4
- package/src/wire/request.ts +67 -8
- package/src/wire/response.ts +51 -85
- package/src/wire/writer.ts +165 -69
- package/dist/util/conform.d.ts +0 -18
- package/dist/util/conform.d.ts.map +0 -1
- package/src/util/conform.ts +0 -94
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { batchFromColumns, isBatch, type VgiBatch, type VgiSchema } from "./arrow/index.js";
|
|
5
5
|
import { AuthContext } from "./auth.js";
|
|
6
6
|
import { buildLogBatch, coerceInt64 } from "./wire/response.js";
|
|
7
7
|
|
|
@@ -10,14 +10,167 @@ export enum MethodType {
|
|
|
10
10
|
STREAM = "stream",
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Coarse identifier of the transport binding a {@link VgiRpcServer} or
|
|
15
|
+
* HTTP handler. Workers (RPC implementations) read this via
|
|
16
|
+
* {@link CallContext.kind} or the {@link ServeStartHook} lifecycle hook
|
|
17
|
+
* to tailor startup behaviour (skip HTTP-only caching, enable
|
|
18
|
+
* transport-specific metrics, etc.).
|
|
19
|
+
*
|
|
20
|
+
* Values are wire/log-friendly strings to match Python's `TransportKind`
|
|
21
|
+
* StrEnum byte-for-byte across language boundaries.
|
|
22
|
+
*
|
|
23
|
+
* - `PIPE` — Stdio worker (the standalone {@link VgiRpcServer} loop).
|
|
24
|
+
* - `HTTP` — Fetch-style HTTP handler (`createHttpHandler`).
|
|
25
|
+
* - `UNIX` — AF_UNIX socket handler (the launcher path).
|
|
26
|
+
*/
|
|
27
|
+
export enum TransportKind {
|
|
28
|
+
PIPE = "pipe",
|
|
29
|
+
HTTP = "http",
|
|
30
|
+
UNIX = "unix",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Optional lifecycle hook fired once per process before the first
|
|
35
|
+
* dispatched request.
|
|
36
|
+
*
|
|
37
|
+
* For the stdio server, fires inside `VgiRpcServer.run()` before the
|
|
38
|
+
* first read. For HTTP, fires lazily on the first request handled
|
|
39
|
+
* (fork-safe for pre-fork servers).
|
|
40
|
+
*
|
|
41
|
+
* If the hook raises, the server logs the exception and propagates it,
|
|
42
|
+
* leaving the bind state unset so the next attempt re-fires the hook
|
|
43
|
+
* rather than silently skipping it.
|
|
44
|
+
*/
|
|
45
|
+
export type ServeStartHook = (kind: TransportKind) => void | Promise<void>;
|
|
46
|
+
|
|
13
47
|
/** Logging interface available to handlers. */
|
|
14
48
|
export interface LogContext {
|
|
15
49
|
clientLog(level: string, message: string, extra?: Record<string, string>): void;
|
|
16
50
|
}
|
|
17
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Attributes for a Set-Cookie directive queued via {@link CallContext.setCookie}.
|
|
54
|
+
* All fields are optional; omitted attributes are not serialized onto the header.
|
|
55
|
+
*/
|
|
56
|
+
export interface CookieAttrs {
|
|
57
|
+
expires?: Date;
|
|
58
|
+
maxAge?: number;
|
|
59
|
+
domain?: string;
|
|
60
|
+
path?: string;
|
|
61
|
+
secure?: boolean;
|
|
62
|
+
httpOnly?: boolean;
|
|
63
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
64
|
+
partitioned?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A queued cookie mutation for the HTTP response. Internal — callers
|
|
69
|
+
* interact through {@link CallContext.setCookie} / {@link CallContext.deleteCookie}.
|
|
70
|
+
*/
|
|
71
|
+
export interface CookieSpec extends CookieAttrs {
|
|
72
|
+
name: string;
|
|
73
|
+
value: string;
|
|
74
|
+
delete: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Per-request sticky-session sink. Internal — populated by the HTTP handler
|
|
78
|
+
* when sticky sessions are enabled, read/mutated by {@link CallContext}'s
|
|
79
|
+
* `openSession` / `closeSession` / `session` getters. */
|
|
80
|
+
export interface StickyContext {
|
|
81
|
+
readonly acceptOpens: boolean;
|
|
82
|
+
state: unknown | null;
|
|
83
|
+
sessionId: string | null;
|
|
84
|
+
mintToken: string | null;
|
|
85
|
+
closed: boolean;
|
|
86
|
+
action: "none" | "resume" | "open" | "close";
|
|
87
|
+
_open(state: unknown, ttl: number | undefined): void;
|
|
88
|
+
_close(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
18
91
|
/** Extended context with authentication info, available to handlers. */
|
|
19
92
|
export interface CallContext extends LogContext {
|
|
20
93
|
readonly auth: AuthContext;
|
|
94
|
+
/** Coarse identifier of the bound transport, or `undefined` until the
|
|
95
|
+
* server begins serving (the value is committed by the lifecycle hook
|
|
96
|
+
* on the very first request). */
|
|
97
|
+
readonly kind?: TransportKind;
|
|
98
|
+
/**
|
|
99
|
+
* Wire body bytes the framework will accept this iteration before
|
|
100
|
+
* triggering a continuation token (producer streams) or strict-fail
|
|
101
|
+
* with an EXCEPTION batch (unary / stream-exchange). Snapshot at
|
|
102
|
+
* collector construction; not live. `undefined` when no cap is
|
|
103
|
+
* configured or the transport doesn't expose one (stdio).
|
|
104
|
+
*/
|
|
105
|
+
readonly remainingResponseBytes?: number;
|
|
106
|
+
/**
|
|
107
|
+
* External-channel bytes left this iteration. Always a hard cap —
|
|
108
|
+
* externalised uploads have no escape valve like producer
|
|
109
|
+
* continuation tokens. Undefined when no cap is configured or
|
|
110
|
+
* externalisation is disabled.
|
|
111
|
+
*/
|
|
112
|
+
readonly remainingExternalizedResponseBytes?: number;
|
|
113
|
+
/** True iff the server has an externalisation backend wired up. */
|
|
114
|
+
readonly externalizationEnabled?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Incoming request cookies. Empty for non-HTTP transports.
|
|
117
|
+
*/
|
|
118
|
+
readonly cookies: ReadonlyMap<string, string>;
|
|
119
|
+
/**
|
|
120
|
+
* Queue a Set-Cookie header on the HTTP response. Only valid inside a
|
|
121
|
+
* unary RPC method served over HTTP; throws otherwise.
|
|
122
|
+
*/
|
|
123
|
+
setCookie(name: string, value: string, attrs?: CookieAttrs): void;
|
|
124
|
+
/**
|
|
125
|
+
* Queue an unset-cookie directive on the HTTP response. Only valid
|
|
126
|
+
* inside a unary RPC method served over HTTP; throws otherwise.
|
|
127
|
+
*/
|
|
128
|
+
deleteCookie(name: string, opts?: { path?: string; domain?: string }): void;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Live sticky-session state object, or `null` when no session is bound to
|
|
132
|
+
* this request. HTTP-only — other transports always return `null`.
|
|
133
|
+
*/
|
|
134
|
+
readonly session: unknown | null;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Opaque 24-char-hex session ID, or `null` when no session is bound.
|
|
138
|
+
* Survives {@link closeSession} so post-close access-log records still
|
|
139
|
+
* carry the id.
|
|
140
|
+
*/
|
|
141
|
+
readonly sessionId: string | null;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Register a sticky session holding *state* for subsequent requests on
|
|
145
|
+
* this transport. HTTP-only — throws on other transports, on calls
|
|
146
|
+
* without the `VGI-Session-Accept: true` opt-in header, or when a
|
|
147
|
+
* session is already bound to this request.
|
|
148
|
+
*/
|
|
149
|
+
openSession(state: unknown, ttl?: number): void;
|
|
150
|
+
|
|
151
|
+
/** Invalidate the sticky session bound to this request. Idempotent. */
|
|
152
|
+
closeSession(): void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const EMPTY_COOKIES: ReadonlyMap<string, string> = new Map();
|
|
156
|
+
|
|
157
|
+
function cookieNotUnaryHttpError(): Error {
|
|
158
|
+
return new Error("setCookie/deleteCookie is only supported inside unary RPC methods served over HTTP");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Surface as `exception_type: "RuntimeError"` on the EXCEPTION batch — the
|
|
162
|
+
* wire serializer reads `error.constructor.name`, so we need a real subclass
|
|
163
|
+
* rather than just `err.name = "RuntimeError"`. Matches Python's pattern of
|
|
164
|
+
* raising `RuntimeError` from the runtime API methods. */
|
|
165
|
+
class RuntimeError extends Error {
|
|
166
|
+
constructor(message: string) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.name = "RuntimeError";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function runtimeError(message: string): Error {
|
|
173
|
+
return new RuntimeError(message);
|
|
21
174
|
}
|
|
22
175
|
|
|
23
176
|
/** Handler for unary (request-response) RPC methods. */
|
|
@@ -34,25 +187,34 @@ export type ProducerFn<S = any> = (state: S, out: OutputCollector) => Promise<vo
|
|
|
34
187
|
/** Initialization function for exchange streams. Returns the initial state object. */
|
|
35
188
|
export type ExchangeInit<S = any> = (params: Record<string, any>) => Promise<S> | S;
|
|
36
189
|
/** Called once per input batch. Must emit exactly one output batch per call. */
|
|
37
|
-
export type ExchangeFn<S = any> = (state: S, input:
|
|
190
|
+
export type ExchangeFn<S = any> = (state: S, input: VgiBatch, out: OutputCollector) => Promise<void> | void;
|
|
38
191
|
|
|
39
192
|
/** Produces a header batch sent before the first output batch in a stream. */
|
|
40
193
|
export type HeaderInit = (params: Record<string, any>, state: any, ctx: LogContext) => Record<string, any>;
|
|
41
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Optional handler invoked when the client signals cancellation by writing an
|
|
197
|
+
* input batch carrying the ``vgi_rpc.cancel`` metadata key. The server runs
|
|
198
|
+
* this hook once, before breaking out of the streaming loop, giving state
|
|
199
|
+
* objects a chance to release resources. Errors are logged and swallowed.
|
|
200
|
+
*/
|
|
201
|
+
export type OnCancelFn<S = any> = (state: S) => Promise<void> | void;
|
|
202
|
+
|
|
42
203
|
export interface MethodDefinition {
|
|
43
204
|
name: string;
|
|
44
205
|
type: MethodType;
|
|
45
|
-
paramsSchema:
|
|
46
|
-
resultSchema:
|
|
47
|
-
outputSchema?:
|
|
48
|
-
inputSchema?:
|
|
206
|
+
paramsSchema: VgiSchema;
|
|
207
|
+
resultSchema: VgiSchema;
|
|
208
|
+
outputSchema?: VgiSchema;
|
|
209
|
+
inputSchema?: VgiSchema;
|
|
49
210
|
handler?: UnaryHandler;
|
|
50
211
|
producerInit?: ProducerInit;
|
|
51
212
|
producerFn?: ProducerFn;
|
|
52
213
|
exchangeInit?: ExchangeInit;
|
|
53
214
|
exchangeFn?: ExchangeFn;
|
|
54
|
-
headerSchema?:
|
|
215
|
+
headerSchema?: VgiSchema;
|
|
55
216
|
headerInit?: HeaderInit;
|
|
217
|
+
onCancel?: OnCancelFn;
|
|
56
218
|
doc?: string;
|
|
57
219
|
defaults?: Record<string, any>;
|
|
58
220
|
paramTypes?: Record<string, string>;
|
|
@@ -68,6 +230,36 @@ export interface DispatchInfo {
|
|
|
68
230
|
serverId: string;
|
|
69
231
|
/** Client-supplied request identifier, or null. */
|
|
70
232
|
requestId: string | null;
|
|
233
|
+
/** Coarse transport identifier — `pipe` for stdio, `http` for fetch
|
|
234
|
+
* handlers, `unix` for AF_UNIX. */
|
|
235
|
+
kind?: TransportKind;
|
|
236
|
+
/** Logical service / protocol name. */
|
|
237
|
+
protocol?: string;
|
|
238
|
+
/** SHA-256 hex of the canonical __describe__ payload (always required in access log). */
|
|
239
|
+
protocolHash?: string;
|
|
240
|
+
/** Operator-supplied protocol-contract version label (optional). */
|
|
241
|
+
protocolVersion?: string;
|
|
242
|
+
/** Authenticated principal, empty string when anonymous. */
|
|
243
|
+
principal?: string;
|
|
244
|
+
/** Authentication domain, empty string when anonymous. */
|
|
245
|
+
authDomain?: string;
|
|
246
|
+
/** True when the call was authenticated. */
|
|
247
|
+
authenticated?: boolean;
|
|
248
|
+
/** HTTP transport: remote IP:port. */
|
|
249
|
+
remoteAddr?: string;
|
|
250
|
+
/** Self-contained Arrow IPC stream of the request batch (unary + stream init only). */
|
|
251
|
+
requestData?: Uint8Array;
|
|
252
|
+
/** Stream lifecycle identifier (32-char lowercase hex); empty on unary. */
|
|
253
|
+
streamId?: string;
|
|
254
|
+
/** True when a stream was cancelled by the client. */
|
|
255
|
+
cancelled?: boolean;
|
|
256
|
+
/** Sticky session ID (24-char hex). Present only when the request was bound
|
|
257
|
+
* to a sticky session or the method opened/closed one. */
|
|
258
|
+
sessionId?: string;
|
|
259
|
+
/** Sticky-session lifecycle action observed during dispatch — one of
|
|
260
|
+
* `"none"` / `"resume"` / `"open"` / `"close"`. Omitted when sticky is
|
|
261
|
+
* disabled or the request never touched the sticky middleware. */
|
|
262
|
+
sessionAction?: "none" | "resume" | "open" | "close";
|
|
71
263
|
}
|
|
72
264
|
|
|
73
265
|
/** Per-call I/O counters, matching Python's CallStatistics. */
|
|
@@ -93,7 +285,7 @@ export interface DispatchHook {
|
|
|
93
285
|
}
|
|
94
286
|
|
|
95
287
|
export interface EmittedBatch {
|
|
96
|
-
batch:
|
|
288
|
+
batch: VgiBatch;
|
|
97
289
|
metadata?: Map<string, string>;
|
|
98
290
|
}
|
|
99
291
|
|
|
@@ -106,26 +298,132 @@ export class OutputCollector implements CallContext {
|
|
|
106
298
|
private _dataBatchIdx: number | null = null;
|
|
107
299
|
private _finished = false;
|
|
108
300
|
private _producerMode: boolean;
|
|
109
|
-
private _outputSchema:
|
|
301
|
+
private _outputSchema: VgiSchema;
|
|
110
302
|
private _serverId: string;
|
|
111
303
|
private _requestId: string | null;
|
|
304
|
+
private _cookieSinkEnabled = false;
|
|
305
|
+
private _responseCookies: CookieSpec[] = [];
|
|
306
|
+
private _stickyContext: StickyContext | null = null;
|
|
112
307
|
readonly auth: AuthContext;
|
|
308
|
+
readonly cookies: ReadonlyMap<string, string>;
|
|
309
|
+
readonly kind?: TransportKind;
|
|
310
|
+
readonly remainingResponseBytes?: number;
|
|
311
|
+
readonly remainingExternalizedResponseBytes?: number;
|
|
312
|
+
readonly externalizationEnabled?: boolean;
|
|
113
313
|
|
|
114
314
|
constructor(
|
|
115
|
-
outputSchema:
|
|
315
|
+
outputSchema: VgiSchema,
|
|
116
316
|
producerMode = true,
|
|
117
317
|
serverId = "",
|
|
118
318
|
requestId: string | null = null,
|
|
119
319
|
authContext?: AuthContext,
|
|
320
|
+
cookies?: ReadonlyMap<string, string>,
|
|
321
|
+
kind?: TransportKind,
|
|
322
|
+
/** Snapshot budget fields exposed to worker code via {@link CallContext}.
|
|
323
|
+
* Optional — non-HTTP transports omit them and existing call sites
|
|
324
|
+
* remain source-compatible. */
|
|
325
|
+
budgets?: {
|
|
326
|
+
remainingResponseBytes?: number;
|
|
327
|
+
remainingExternalizedResponseBytes?: number;
|
|
328
|
+
externalizationEnabled?: boolean;
|
|
329
|
+
},
|
|
120
330
|
) {
|
|
121
331
|
this._outputSchema = outputSchema;
|
|
122
332
|
this._producerMode = producerMode;
|
|
123
333
|
this._serverId = serverId;
|
|
124
334
|
this._requestId = requestId;
|
|
125
335
|
this.auth = authContext ?? AuthContext.anonymous();
|
|
336
|
+
this.cookies = cookies ?? EMPTY_COOKIES;
|
|
337
|
+
this.kind = kind;
|
|
338
|
+
this.remainingResponseBytes = budgets?.remainingResponseBytes;
|
|
339
|
+
this.remainingExternalizedResponseBytes = budgets?.remainingExternalizedResponseBytes;
|
|
340
|
+
this.externalizationEnabled = budgets?.externalizationEnabled;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Mark this collector as able to accept Set-Cookie directives. Called
|
|
345
|
+
* by the unary HTTP dispatcher only; streaming and non-HTTP paths leave
|
|
346
|
+
* the sink disabled so setCookie/deleteCookie throw.
|
|
347
|
+
* @internal
|
|
348
|
+
*/
|
|
349
|
+
enableCookieSink(): void {
|
|
350
|
+
this._cookieSinkEnabled = true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Return and clear all queued cookie mutations.
|
|
355
|
+
* @internal
|
|
356
|
+
*/
|
|
357
|
+
drainResponseCookies(): CookieSpec[] {
|
|
358
|
+
const cookies = this._responseCookies;
|
|
359
|
+
this._responseCookies = [];
|
|
360
|
+
return cookies;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
setCookie(name: string, value: string, attrs?: CookieAttrs): void {
|
|
364
|
+
if (!this._cookieSinkEnabled) throw cookieNotUnaryHttpError();
|
|
365
|
+
this._responseCookies.push({
|
|
366
|
+
name,
|
|
367
|
+
value,
|
|
368
|
+
delete: false,
|
|
369
|
+
...(attrs ?? {}),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
deleteCookie(name: string, opts?: { path?: string; domain?: string }): void {
|
|
374
|
+
if (!this._cookieSinkEnabled) throw cookieNotUnaryHttpError();
|
|
375
|
+
this._responseCookies.push({
|
|
376
|
+
name,
|
|
377
|
+
value: "",
|
|
378
|
+
delete: true,
|
|
379
|
+
path: opts?.path,
|
|
380
|
+
domain: opts?.domain,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Attach the sticky-session sink the HTTP handler built for this request.
|
|
385
|
+
* @internal */
|
|
386
|
+
attachStickyContext(ctx: StickyContext): void {
|
|
387
|
+
this._stickyContext = ctx;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
get session(): unknown | null {
|
|
391
|
+
return this._stickyContext?.state ?? null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
get sessionId(): string | null {
|
|
395
|
+
return this._stickyContext?.sessionId ?? null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
openSession(state: unknown, ttl?: number): void {
|
|
399
|
+
const sink = this._stickyContext;
|
|
400
|
+
if (!sink) {
|
|
401
|
+
throw runtimeError("sticky sessions not available on this transport");
|
|
402
|
+
}
|
|
403
|
+
if (!sink.acceptOpens) {
|
|
404
|
+
throw runtimeError(
|
|
405
|
+
"client did not opt in to sticky sessions " +
|
|
406
|
+
"(missing VGI-Session-Accept: true header — open the call inside " +
|
|
407
|
+
"an HttpConnection.with_session_token() block)",
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (sink.state !== null) {
|
|
411
|
+
throw runtimeError("a sticky session is already active for this request");
|
|
412
|
+
}
|
|
413
|
+
sink._open(state, ttl);
|
|
414
|
+
sink.action = "open";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
closeSession(): void {
|
|
418
|
+
const sink = this._stickyContext;
|
|
419
|
+
if (!sink) {
|
|
420
|
+
throw runtimeError("sticky sessions not available on this transport");
|
|
421
|
+
}
|
|
422
|
+
sink._close();
|
|
423
|
+
sink.action = "close";
|
|
126
424
|
}
|
|
127
425
|
|
|
128
|
-
get outputSchema():
|
|
426
|
+
get outputSchema(): VgiSchema {
|
|
129
427
|
return this._outputSchema;
|
|
130
428
|
}
|
|
131
429
|
|
|
@@ -137,17 +435,23 @@ export class OutputCollector implements CallContext {
|
|
|
137
435
|
return this._batches;
|
|
138
436
|
}
|
|
139
437
|
|
|
140
|
-
/** Emit a pre-built
|
|
141
|
-
emit(batch:
|
|
438
|
+
/** Emit a pre-built batch as the data batch for this call. */
|
|
439
|
+
emit(batch: VgiBatch, metadata?: Map<string, string>): void;
|
|
142
440
|
/** Emit a data batch from column arrays keyed by field name. Int64 Number values are coerced to BigInt. */
|
|
143
441
|
emit(columns: Record<string, any[]>): void;
|
|
144
|
-
emit(batchOrColumns:
|
|
145
|
-
let batch:
|
|
146
|
-
if (batchOrColumns
|
|
442
|
+
emit(batchOrColumns: VgiBatch | Record<string, any[]>, metadata?: Map<string, string>): void {
|
|
443
|
+
let batch: VgiBatch;
|
|
444
|
+
if (isBatch(batchOrColumns)) {
|
|
147
445
|
batch = batchOrColumns;
|
|
148
446
|
} else {
|
|
149
|
-
const coerced = coerceInt64(this._outputSchema, batchOrColumns);
|
|
150
|
-
|
|
447
|
+
const coerced = coerceInt64(this._outputSchema, batchOrColumns as Record<string, any[]>);
|
|
448
|
+
// Build columns dict ensuring each field has an array (vectorFromArray-equivalent under the hood).
|
|
449
|
+
const cols: Record<string, any[]> = {};
|
|
450
|
+
for (const f of this._outputSchema.fields) {
|
|
451
|
+
const v = coerced[f.name];
|
|
452
|
+
cols[f.name] = Array.isArray(v) ? v : [v];
|
|
453
|
+
}
|
|
454
|
+
batch = batchFromColumns(this._outputSchema, cols);
|
|
151
455
|
}
|
|
152
456
|
if (this._dataBatchIdx !== null) {
|
|
153
457
|
throw new Error("Only one data batch may be emitted per call");
|
package/src/util/gzip.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cross-runtime gzip compression/decompression.
|
|
6
|
+
*
|
|
7
|
+
* The Python `vgi-rpc[http]` client now defaults to `Content-Encoding: gzip`
|
|
8
|
+
* on every request, so every HTTP server in the framework must decode it.
|
|
9
|
+
* We use the Web platform `DecompressionStream`/`CompressionStream` APIs,
|
|
10
|
+
* which Bun, Node 18+, Deno, and Cloudflare workerd all expose.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
async function streamThrough(
|
|
14
|
+
data: Uint8Array,
|
|
15
|
+
transform: ReadableWritablePair<Uint8Array, BufferSource>,
|
|
16
|
+
maxOutputSize?: number,
|
|
17
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
18
|
+
const ws = transform.writable.getWriter();
|
|
19
|
+
const rs = transform.readable.getReader();
|
|
20
|
+
// Copy into a freshly-allocated ArrayBuffer-backed view to satisfy TS lib
|
|
21
|
+
// BufferSource narrowing (rules out SharedArrayBuffer-backed Uint8Array).
|
|
22
|
+
const view = new Uint8Array(data.byteLength);
|
|
23
|
+
view.set(data);
|
|
24
|
+
const writePromise = (async () => {
|
|
25
|
+
await ws.write(view as BufferSource);
|
|
26
|
+
await ws.close();
|
|
27
|
+
})();
|
|
28
|
+
const chunks: Uint8Array[] = [];
|
|
29
|
+
let total = 0;
|
|
30
|
+
while (true) {
|
|
31
|
+
const { value, done } = await rs.read();
|
|
32
|
+
if (done) break;
|
|
33
|
+
const chunk = value as Uint8Array;
|
|
34
|
+
total += chunk.byteLength;
|
|
35
|
+
if (maxOutputSize != null && total > maxOutputSize) {
|
|
36
|
+
throw new Error(`gzip decompressed size (${total}) exceeds cap (${maxOutputSize})`);
|
|
37
|
+
}
|
|
38
|
+
chunks.push(chunk);
|
|
39
|
+
}
|
|
40
|
+
await writePromise;
|
|
41
|
+
const out = new Uint8Array(total);
|
|
42
|
+
let offset = 0;
|
|
43
|
+
for (const c of chunks) {
|
|
44
|
+
out.set(c, offset);
|
|
45
|
+
offset += c.byteLength;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decompress gzip-encoded data, optionally bounded by `maxOutputSize`.
|
|
52
|
+
*
|
|
53
|
+
* The gzip footer's ISIZE field is mod 2^32 so it can't be trusted for a
|
|
54
|
+
* pre-check — we bound output incrementally during streaming decode.
|
|
55
|
+
*/
|
|
56
|
+
export async function gzipDecompress(data: Uint8Array, maxOutputSize?: number): Promise<Uint8Array<ArrayBuffer>> {
|
|
57
|
+
return streamThrough(data, new DecompressionStream("gzip"), maxOutputSize);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Compress data with gzip. `level` is accepted for API parity with zstd but ignored — the Web API doesn't expose a level. */
|
|
61
|
+
export async function gzipCompress(data: Uint8Array, _level?: number): Promise<Uint8Array<ArrayBuffer>> {
|
|
62
|
+
return streamThrough(data, new CompressionStream("gzip"));
|
|
63
|
+
}
|
package/src/util/schema.ts
CHANGED
|
@@ -1,31 +1,13 @@
|
|
|
1
1
|
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { serializeSchema as facadeSerializeSchema, type VgiSchema } from "../arrow/index.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Serialize a Schema to the Arrow IPC Schema message format.
|
|
8
8
|
* This produces bytes compatible with Python's `pa.ipc.read_schema()`.
|
|
9
|
-
*
|
|
10
|
-
* We serialize by writing an empty-batch IPC stream and extracting
|
|
11
|
-
* the bytes, which includes the schema message. Python's read_schema()
|
|
12
|
-
* uses `pa.ipc.read_schema(pa.py_buffer(bytes))` which expects
|
|
13
|
-
* the schema flatbuffer message bytes directly — but the Python side
|
|
14
|
-
* actually uses `schema.serialize()` which produces Schema message bytes.
|
|
15
|
-
*
|
|
16
|
-
* In arrow-js, we can get the equivalent by using Message.from(schema)
|
|
17
|
-
* and encoding it, or by serializing a zero-batch stream.
|
|
18
|
-
*
|
|
19
|
-
* The Python `schema.serialize()` produces the Schema flatbuffer message bytes,
|
|
20
|
-
* and `pa.ipc.read_schema()` expects an IPC stream containing a schema message.
|
|
21
|
-
* The actual format is: continuation marker (0xFFFFFFFF) + length + flatbuffer bytes.
|
|
9
|
+
* Equivalent to writing an empty-batch IPC stream — schema message + EOS marker.
|
|
22
10
|
*/
|
|
23
|
-
export function serializeSchema(schema:
|
|
24
|
-
|
|
25
|
-
// This writes: Schema message + EOS marker.
|
|
26
|
-
// Python's pa.ipc.read_schema() can read this format.
|
|
27
|
-
const writer = new RecordBatchStreamWriter();
|
|
28
|
-
writer.reset(undefined, schema);
|
|
29
|
-
writer.close();
|
|
30
|
-
return writer.toUint8Array(true);
|
|
11
|
+
export function serializeSchema(schema: VgiSchema): Uint8Array {
|
|
12
|
+
return facadeSerializeSchema(schema);
|
|
31
13
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// © Copyright 2025-2026, Query.Farm LLC - https://query.farm
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// Web Crypto helpers — universal across Node 18+, Bun, Cloudflare workerd,
|
|
5
|
+
// Deno, and modern browsers. Used in place of node:crypto (`createHmac`,
|
|
6
|
+
// `createHash`, `randomBytes`, `timingSafeEqual`) so the wire-protocol code
|
|
7
|
+
// bundles cleanly for workerd without requiring `nodejs_compat`.
|
|
8
|
+
//
|
|
9
|
+
// All HMAC/digest operations are async because `crypto.subtle` is async-only.
|
|
10
|
+
// Callers that previously had sync signatures should await the result.
|
|
11
|
+
|
|
12
|
+
/** Cryptographically-strong random bytes. */
|
|
13
|
+
export function randomBytes(length: number): Uint8Array {
|
|
14
|
+
const buf = new Uint8Array(length);
|
|
15
|
+
crypto.getRandomValues(buf);
|
|
16
|
+
return buf;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Constant-time byte-array comparison. Returns false fast on length mismatch
|
|
21
|
+
* (the length itself is not a secret), and otherwise XOR-accumulates without
|
|
22
|
+
* an early return so the comparison takes the same wall time regardless of
|
|
23
|
+
* which byte differs.
|
|
24
|
+
*/
|
|
25
|
+
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
26
|
+
if (a.length !== b.length) return false;
|
|
27
|
+
let diff = 0;
|
|
28
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
29
|
+
return diff === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cache imported `CryptoKey` per (raw key bytes, usage) so that repeated
|
|
34
|
+
* sign/verify calls don't re-import. The key bytes are fingerprinted with a
|
|
35
|
+
* stable string (base64 of the raw bytes); the WeakRef-friendly Map keeps the
|
|
36
|
+
* cache bounded by typical key turnover (one or two signing keys per process).
|
|
37
|
+
*/
|
|
38
|
+
const _hmacKeyCache = new Map<string, Promise<CryptoKey>>();
|
|
39
|
+
|
|
40
|
+
function _hmacKeyCacheKey(rawKey: Uint8Array, usage: "sign" | "verify"): string {
|
|
41
|
+
// Cheap stable fingerprint — collisions only matter for the cache hit/miss,
|
|
42
|
+
// not security. Use a string concat of byte values; for typical 32-byte
|
|
43
|
+
// signing keys this is ~70 chars.
|
|
44
|
+
let s = usage + ":";
|
|
45
|
+
for (let i = 0; i < rawKey.length; i++) s += rawKey[i].toString(16);
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function _importHmacKey(rawKey: Uint8Array, usage: "sign" | "verify"): Promise<CryptoKey> {
|
|
50
|
+
const cacheKey = _hmacKeyCacheKey(rawKey, usage);
|
|
51
|
+
let promise = _hmacKeyCache.get(cacheKey);
|
|
52
|
+
if (!promise) {
|
|
53
|
+
promise = crypto.subtle.importKey(
|
|
54
|
+
"raw",
|
|
55
|
+
// Copy into a fresh ArrayBuffer — importKey requires an ArrayBuffer or
|
|
56
|
+
// ArrayBufferView; passing a SharedArrayBuffer view (or one whose
|
|
57
|
+
// underlying buffer outlives this caller) can be rejected on some
|
|
58
|
+
// runtimes. The copy is cheap (≤32 bytes typical).
|
|
59
|
+
rawKey.slice().buffer as ArrayBuffer,
|
|
60
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
61
|
+
false,
|
|
62
|
+
[usage],
|
|
63
|
+
);
|
|
64
|
+
_hmacKeyCache.set(cacheKey, promise);
|
|
65
|
+
}
|
|
66
|
+
return promise;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** HMAC-SHA256 over `data` with `key`. Returns a 32-byte tag. */
|
|
70
|
+
export async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
|
|
71
|
+
const cryptoKey = await _importHmacKey(key, "sign");
|
|
72
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, data as BufferSource);
|
|
73
|
+
return new Uint8Array(sig);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Verify an HMAC-SHA256 tag in constant time. Equivalent to
|
|
78
|
+
* `constantTimeEqual(await hmacSha256(key, data), tag)`, but routes through
|
|
79
|
+
* `crypto.subtle.verify` which is also constant-time on conforming runtimes.
|
|
80
|
+
*/
|
|
81
|
+
export async function hmacSha256Verify(key: Uint8Array, data: Uint8Array, tag: Uint8Array): Promise<boolean> {
|
|
82
|
+
const cryptoKey = await _importHmacKey(key, "verify");
|
|
83
|
+
return crypto.subtle.verify("HMAC", cryptoKey, tag as BufferSource, data as BufferSource);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** SHA-256 of `data` as raw bytes. */
|
|
87
|
+
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
|
88
|
+
const digest = await crypto.subtle.digest("SHA-256", data as BufferSource);
|
|
89
|
+
return new Uint8Array(digest);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** SHA-256 of `data` as lower-case hex. */
|
|
93
|
+
export async function sha256Hex(data: Uint8Array): Promise<string> {
|
|
94
|
+
const bytes = await sha256(data);
|
|
95
|
+
let s = "";
|
|
96
|
+
for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
|
|
97
|
+
return s;
|
|
98
|
+
}
|