@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.
Files changed (160) hide show
  1. package/dist/access-log.d.ts +50 -0
  2. package/dist/access-log.d.ts.map +1 -0
  3. package/dist/arrow/impl-arrowjs/index.d.ts +96 -0
  4. package/dist/arrow/impl-arrowjs/index.d.ts.map +1 -0
  5. package/dist/arrow/impl-flechette/index.d.ts +102 -0
  6. package/dist/arrow/impl-flechette/index.d.ts.map +1 -0
  7. package/dist/arrow/impl-flechette/message-meta.d.ts +11 -0
  8. package/dist/arrow/impl-flechette/message-meta.d.ts.map +1 -0
  9. package/dist/arrow/index.d.ts +4 -0
  10. package/dist/arrow/index.d.ts.map +1 -0
  11. package/dist/arrow/predicates.d.ts +44 -0
  12. package/dist/arrow/predicates.d.ts.map +1 -0
  13. package/dist/arrow/types.d.ts +62 -0
  14. package/dist/arrow/types.d.ts.map +1 -0
  15. package/dist/client/capabilities.d.ts +25 -0
  16. package/dist/client/capabilities.d.ts.map +1 -0
  17. package/dist/client/connect.d.ts.map +1 -1
  18. package/dist/client/introspect.d.ts +7 -0
  19. package/dist/client/introspect.d.ts.map +1 -1
  20. package/dist/client/ipc.d.ts +8 -2
  21. package/dist/client/ipc.d.ts.map +1 -1
  22. package/dist/client/pipe.d.ts.map +1 -1
  23. package/dist/client/stream.d.ts +11 -2
  24. package/dist/client/stream.d.ts.map +1 -1
  25. package/dist/client/uploadUrl.d.ts +25 -0
  26. package/dist/client/uploadUrl.d.ts.map +1 -0
  27. package/dist/constants.d.ts +15 -1
  28. package/dist/constants.d.ts.map +1 -1
  29. package/dist/crypto.d.ts +22 -0
  30. package/dist/crypto.d.ts.map +1 -0
  31. package/dist/dispatch/describe.d.ts +10 -6
  32. package/dist/dispatch/describe.d.ts.map +1 -1
  33. package/dist/dispatch/stream.d.ts +2 -2
  34. package/dist/dispatch/stream.d.ts.map +1 -1
  35. package/dist/dispatch/unary.d.ts +2 -2
  36. package/dist/dispatch/unary.d.ts.map +1 -1
  37. package/dist/errors.d.ts +46 -0
  38. package/dist/errors.d.ts.map +1 -1
  39. package/dist/external.d.ts +25 -5
  40. package/dist/external.d.ts.map +1 -1
  41. package/dist/http/bearer.d.ts.map +1 -1
  42. package/dist/http/common.d.ts +42 -7
  43. package/dist/http/common.d.ts.map +1 -1
  44. package/dist/http/dispatch.d.ts +20 -2
  45. package/dist/http/dispatch.d.ts.map +1 -1
  46. package/dist/http/handler.d.ts.map +1 -1
  47. package/dist/http/index.d.ts +1 -0
  48. package/dist/http/index.d.ts.map +1 -1
  49. package/dist/http/mtls.d.ts +2 -1
  50. package/dist/http/mtls.d.ts.map +1 -1
  51. package/dist/http/oauth-pkce.d.ts +141 -0
  52. package/dist/http/oauth-pkce.d.ts.map +1 -0
  53. package/dist/http/pages.d.ts +3 -0
  54. package/dist/http/pages.d.ts.map +1 -1
  55. package/dist/http/sticky.d.ts +124 -0
  56. package/dist/http/sticky.d.ts.map +1 -0
  57. package/dist/http/token.d.ts +38 -12
  58. package/dist/http/token.d.ts.map +1 -1
  59. package/dist/http/types.d.ts +68 -5
  60. package/dist/http/types.d.ts.map +1 -1
  61. package/dist/index.d.ts +6 -4
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1275 -3507
  64. package/dist/index.js.map +19 -37
  65. package/dist/launcher/hash.d.ts +22 -0
  66. package/dist/launcher/hash.d.ts.map +1 -0
  67. package/dist/launcher/index.d.ts +23 -0
  68. package/dist/launcher/index.d.ts.map +1 -0
  69. package/dist/launcher/launch.d.ts +27 -0
  70. package/dist/launcher/launch.d.ts.map +1 -0
  71. package/dist/launcher/lock.d.ts +19 -0
  72. package/dist/launcher/lock.d.ts.map +1 -0
  73. package/dist/launcher/serve-unix.d.ts +54 -0
  74. package/dist/launcher/serve-unix.d.ts.map +1 -0
  75. package/dist/launcher/state.d.ts +59 -0
  76. package/dist/launcher/state.d.ts.map +1 -0
  77. package/dist/otel.d.ts.map +1 -1
  78. package/dist/protocol.d.ts +16 -2
  79. package/dist/protocol.d.ts.map +1 -1
  80. package/dist/schema.d.ts +45 -18
  81. package/dist/schema.d.ts.map +1 -1
  82. package/dist/server.d.ts +23 -2
  83. package/dist/server.d.ts.map +1 -1
  84. package/dist/types.d.ts +216 -12
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/util/gzip.d.ts +10 -0
  87. package/dist/util/gzip.d.ts.map +1 -0
  88. package/dist/util/schema.d.ts +3 -15
  89. package/dist/util/schema.d.ts.map +1 -1
  90. package/dist/util/web-crypto.d.ts +22 -0
  91. package/dist/util/web-crypto.d.ts.map +1 -0
  92. package/dist/util/zstd.d.ts +26 -3
  93. package/dist/util/zstd.d.ts.map +1 -1
  94. package/dist/wire/opaque.d.ts +11 -0
  95. package/dist/wire/opaque.d.ts.map +1 -0
  96. package/dist/wire/reader.d.ts +5 -5
  97. package/dist/wire/reader.d.ts.map +1 -1
  98. package/dist/wire/request.d.ts +11 -3
  99. package/dist/wire/request.d.ts.map +1 -1
  100. package/dist/wire/response.d.ts +6 -6
  101. package/dist/wire/response.d.ts.map +1 -1
  102. package/dist/wire/writer.d.ts +49 -39
  103. package/dist/wire/writer.d.ts.map +1 -1
  104. package/package.json +24 -10
  105. package/src/access-log.ts +195 -0
  106. package/src/arrow/impl-arrowjs/index.ts +433 -0
  107. package/src/arrow/impl-flechette/index.ts +414 -0
  108. package/src/arrow/impl-flechette/message-meta.ts +174 -0
  109. package/src/arrow/index.ts +89 -0
  110. package/src/arrow/predicates.ts +56 -0
  111. package/src/arrow/types.ts +73 -0
  112. package/src/client/capabilities.ts +84 -0
  113. package/src/client/connect.ts +103 -26
  114. package/src/client/introspect.ts +60 -38
  115. package/src/client/ipc.ts +37 -27
  116. package/src/client/pipe.ts +12 -9
  117. package/src/client/stream.ts +34 -19
  118. package/src/client/uploadUrl.ts +169 -0
  119. package/src/constants.ts +18 -1
  120. package/src/crypto.ts +95 -0
  121. package/src/dispatch/describe.ts +146 -107
  122. package/src/dispatch/stream.ts +53 -24
  123. package/src/dispatch/unary.ts +5 -4
  124. package/src/errors.ts +76 -0
  125. package/src/external.ts +43 -29
  126. package/src/http/bearer.ts +2 -5
  127. package/src/http/common.ts +90 -23
  128. package/src/http/dispatch.ts +373 -46
  129. package/src/http/handler.ts +794 -68
  130. package/src/http/index.ts +1 -0
  131. package/src/http/mtls.ts +18 -3
  132. package/src/http/oauth-pkce.ts +1035 -0
  133. package/src/http/pages.ts +30 -15
  134. package/src/http/sticky.ts +429 -0
  135. package/src/http/token.ts +165 -75
  136. package/src/http/types.ts +69 -5
  137. package/src/index.ts +40 -1
  138. package/src/launcher/hash.ts +104 -0
  139. package/src/launcher/index.ts +35 -0
  140. package/src/launcher/launch.ts +284 -0
  141. package/src/launcher/lock.ts +171 -0
  142. package/src/launcher/serve-unix.ts +385 -0
  143. package/src/launcher/state.ts +245 -0
  144. package/src/otel.ts +39 -33
  145. package/src/protocol.ts +27 -3
  146. package/src/schema.ts +107 -56
  147. package/src/server.ts +196 -20
  148. package/src/types.ts +322 -18
  149. package/src/util/gzip.ts +63 -0
  150. package/src/util/schema.ts +4 -22
  151. package/src/util/web-crypto.ts +98 -0
  152. package/src/util/zstd.ts +133 -14
  153. package/src/wire/opaque.ts +37 -0
  154. package/src/wire/reader.ts +5 -4
  155. package/src/wire/request.ts +67 -8
  156. package/src/wire/response.ts +51 -85
  157. package/src/wire/writer.ts +165 -69
  158. package/dist/util/conform.d.ts +0 -18
  159. package/dist/util/conform.d.ts.map +0 -1
  160. 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 { RecordBatch, recordBatchFromArrays, type Schema } from "@query-farm/apache-arrow";
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: RecordBatch, out: OutputCollector) => Promise<void> | void;
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: Schema;
46
- resultSchema: Schema;
47
- outputSchema?: Schema;
48
- inputSchema?: Schema;
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?: Schema;
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: RecordBatch;
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: Schema;
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: Schema,
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(): Schema {
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 RecordBatch as the data batch for this call. */
141
- emit(batch: RecordBatch, metadata?: Map<string, string>): void;
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: RecordBatch | Record<string, any[]>, metadata?: Map<string, string>): void {
145
- let batch: RecordBatch;
146
- if (batchOrColumns instanceof RecordBatch) {
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
- batch = recordBatchFromArrays(coerced, this._outputSchema);
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");
@@ -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
+ }
@@ -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 { RecordBatchStreamWriter, type Schema } from "@query-farm/apache-arrow";
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: Schema): Uint8Array {
24
- // Write a complete IPC stream with no batches.
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
+ }