@query-farm/vgi-rpc 0.6.4 → 0.7.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.
Files changed (177) hide show
  1. package/dist/access-log.d.ts +55 -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/auth.d.ts +5 -0
  16. package/dist/auth.d.ts.map +1 -1
  17. package/dist/client/capabilities.d.ts +25 -0
  18. package/dist/client/capabilities.d.ts.map +1 -0
  19. package/dist/client/connect.d.ts +10 -0
  20. package/dist/client/connect.d.ts.map +1 -1
  21. package/dist/client/introspect.d.ts +21 -0
  22. package/dist/client/introspect.d.ts.map +1 -1
  23. package/dist/client/ipc.d.ts +8 -2
  24. package/dist/client/ipc.d.ts.map +1 -1
  25. package/dist/client/oauth.d.ts +9 -0
  26. package/dist/client/oauth.d.ts.map +1 -1
  27. package/dist/client/pipe.d.ts +24 -0
  28. package/dist/client/pipe.d.ts.map +1 -1
  29. package/dist/client/stream.d.ts +19 -2
  30. package/dist/client/stream.d.ts.map +1 -1
  31. package/dist/client/types.d.ts +23 -0
  32. package/dist/client/types.d.ts.map +1 -1
  33. package/dist/client/uploadUrl.d.ts +25 -0
  34. package/dist/client/uploadUrl.d.ts.map +1 -0
  35. package/dist/constants.d.ts +30 -2
  36. package/dist/constants.d.ts.map +1 -1
  37. package/dist/crypto.d.ts +22 -0
  38. package/dist/crypto.d.ts.map +1 -0
  39. package/dist/dispatch/describe.d.ts +10 -6
  40. package/dist/dispatch/describe.d.ts.map +1 -1
  41. package/dist/dispatch/stream.d.ts +2 -2
  42. package/dist/dispatch/stream.d.ts.map +1 -1
  43. package/dist/dispatch/unary.d.ts +2 -2
  44. package/dist/dispatch/unary.d.ts.map +1 -1
  45. package/dist/errors.d.ts +64 -1
  46. package/dist/errors.d.ts.map +1 -1
  47. package/dist/external.d.ts +27 -5
  48. package/dist/external.d.ts.map +1 -1
  49. package/dist/http/auth.d.ts +13 -0
  50. package/dist/http/auth.d.ts.map +1 -1
  51. package/dist/http/bearer.d.ts.map +1 -1
  52. package/dist/http/common.d.ts +43 -7
  53. package/dist/http/common.d.ts.map +1 -1
  54. package/dist/http/dispatch.d.ts +20 -2
  55. package/dist/http/dispatch.d.ts.map +1 -1
  56. package/dist/http/handler.d.ts.map +1 -1
  57. package/dist/http/index.d.ts +1 -0
  58. package/dist/http/index.d.ts.map +1 -1
  59. package/dist/http/jwt.d.ts +1 -0
  60. package/dist/http/jwt.d.ts.map +1 -1
  61. package/dist/http/mtls.d.ts +9 -1
  62. package/dist/http/mtls.d.ts.map +1 -1
  63. package/dist/http/oauth-pkce.d.ts +141 -0
  64. package/dist/http/oauth-pkce.d.ts.map +1 -0
  65. package/dist/http/pages.d.ts +3 -0
  66. package/dist/http/pages.d.ts.map +1 -1
  67. package/dist/http/sticky.d.ts +124 -0
  68. package/dist/http/sticky.d.ts.map +1 -0
  69. package/dist/http/token.d.ts +43 -12
  70. package/dist/http/token.d.ts.map +1 -1
  71. package/dist/http/types.d.ts +68 -5
  72. package/dist/http/types.d.ts.map +1 -1
  73. package/dist/index.d.ts +6 -4
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1275 -3511
  76. package/dist/index.js.map +20 -38
  77. package/dist/launcher/hash.d.ts +22 -0
  78. package/dist/launcher/hash.d.ts.map +1 -0
  79. package/dist/launcher/index.d.ts +23 -0
  80. package/dist/launcher/index.d.ts.map +1 -0
  81. package/dist/launcher/launch.d.ts +27 -0
  82. package/dist/launcher/launch.d.ts.map +1 -0
  83. package/dist/launcher/lock.d.ts +19 -0
  84. package/dist/launcher/lock.d.ts.map +1 -0
  85. package/dist/launcher/serve-unix.d.ts +55 -0
  86. package/dist/launcher/serve-unix.d.ts.map +1 -0
  87. package/dist/launcher/state.d.ts +71 -0
  88. package/dist/launcher/state.d.ts.map +1 -0
  89. package/dist/otel.d.ts.map +1 -1
  90. package/dist/protocol.d.ts +19 -2
  91. package/dist/protocol.d.ts.map +1 -1
  92. package/dist/schema.d.ts +45 -18
  93. package/dist/schema.d.ts.map +1 -1
  94. package/dist/server.d.ts +23 -2
  95. package/dist/server.d.ts.map +1 -1
  96. package/dist/types.d.ts +270 -12
  97. package/dist/types.d.ts.map +1 -1
  98. package/dist/util/gzip.d.ts +10 -0
  99. package/dist/util/gzip.d.ts.map +1 -0
  100. package/dist/util/schema.d.ts +3 -15
  101. package/dist/util/schema.d.ts.map +1 -1
  102. package/dist/util/web-crypto.d.ts +22 -0
  103. package/dist/util/web-crypto.d.ts.map +1 -0
  104. package/dist/util/zstd.d.ts +26 -3
  105. package/dist/util/zstd.d.ts.map +1 -1
  106. package/dist/wire/opaque.d.ts +11 -0
  107. package/dist/wire/opaque.d.ts.map +1 -0
  108. package/dist/wire/reader.d.ts +5 -5
  109. package/dist/wire/reader.d.ts.map +1 -1
  110. package/dist/wire/request.d.ts +11 -3
  111. package/dist/wire/request.d.ts.map +1 -1
  112. package/dist/wire/response.d.ts +6 -6
  113. package/dist/wire/response.d.ts.map +1 -1
  114. package/dist/wire/writer.d.ts +49 -39
  115. package/dist/wire/writer.d.ts.map +1 -1
  116. package/package.json +35 -21
  117. package/src/access-log.ts +200 -0
  118. package/src/arrow/impl-arrowjs/index.ts +433 -0
  119. package/src/arrow/impl-flechette/index.ts +414 -0
  120. package/src/arrow/impl-flechette/message-meta.ts +174 -0
  121. package/src/arrow/index.ts +89 -0
  122. package/src/arrow/predicates.ts +56 -0
  123. package/src/arrow/types.ts +73 -0
  124. package/src/auth.ts +5 -0
  125. package/src/client/capabilities.ts +84 -0
  126. package/src/client/connect.ts +113 -26
  127. package/src/client/introspect.ts +74 -38
  128. package/src/client/ipc.ts +37 -27
  129. package/src/client/oauth.ts +9 -0
  130. package/src/client/pipe.ts +36 -9
  131. package/src/client/stream.ts +43 -20
  132. package/src/client/types.ts +23 -0
  133. package/src/client/uploadUrl.ts +169 -0
  134. package/src/constants.ts +34 -2
  135. package/src/crypto.ts +95 -0
  136. package/src/dispatch/describe.ts +146 -107
  137. package/src/dispatch/stream.ts +53 -24
  138. package/src/dispatch/unary.ts +5 -4
  139. package/src/errors.ts +87 -0
  140. package/src/external.ts +49 -30
  141. package/src/http/auth.ts +13 -0
  142. package/src/http/bearer.ts +2 -5
  143. package/src/http/common.ts +91 -23
  144. package/src/http/dispatch.ts +373 -46
  145. package/src/http/handler.ts +790 -68
  146. package/src/http/index.ts +1 -0
  147. package/src/http/jwt.ts +1 -0
  148. package/src/http/mtls.ts +25 -3
  149. package/src/http/oauth-pkce.ts +1035 -0
  150. package/src/http/pages.ts +30 -15
  151. package/src/http/sticky.ts +429 -0
  152. package/src/http/token.ts +170 -75
  153. package/src/http/types.ts +69 -5
  154. package/src/index.ts +40 -1
  155. package/src/launcher/hash.ts +104 -0
  156. package/src/launcher/index.ts +35 -0
  157. package/src/launcher/launch.ts +284 -0
  158. package/src/launcher/lock.ts +171 -0
  159. package/src/launcher/serve-unix.ts +386 -0
  160. package/src/launcher/state.ts +257 -0
  161. package/src/otel.ts +39 -33
  162. package/src/protocol.ts +30 -3
  163. package/src/schema.ts +107 -56
  164. package/src/server.ts +196 -20
  165. package/src/types.ts +376 -18
  166. package/src/util/gzip.ts +63 -0
  167. package/src/util/schema.ts +4 -22
  168. package/src/util/web-crypto.ts +98 -0
  169. package/src/util/zstd.ts +133 -14
  170. package/src/wire/opaque.ts +37 -0
  171. package/src/wire/reader.ts +5 -4
  172. package/src/wire/request.ts +67 -8
  173. package/src/wire/response.ts +51 -85
  174. package/src/wire/writer.ts +165 -69
  175. package/dist/util/conform.d.ts +0 -18
  176. package/dist/util/conform.d.ts.map +0 -1
  177. package/src/util/conform.ts +0 -94
package/src/client/ipc.ts CHANGED
@@ -7,18 +7,17 @@ import {
7
7
  DataType,
8
8
  Float64,
9
9
  Int64,
10
- makeData,
11
- RecordBatch,
10
+ type RecordBatch,
12
11
  RecordBatchReader,
13
12
  type Schema,
14
- Struct,
15
13
  Utf8,
16
- vectorFromArray,
17
14
  } from "@query-farm/apache-arrow";
15
+ import { emptyBatchWithMetadata, singleRowBatchWithMetadata } from "#vgi-rpc-arrow";
18
16
  import {
19
17
  LOG_EXTRA_KEY,
20
18
  LOG_LEVEL_KEY,
21
19
  LOG_MESSAGE_KEY,
20
+ PROTOCOL_VERSION_KEY,
22
21
  REQUEST_VERSION,
23
22
  REQUEST_VERSION_KEY,
24
23
  RPC_METHOD_KEY,
@@ -79,38 +78,49 @@ function coerceForArrow(type: DataType, value: any): any {
79
78
 
80
79
  /**
81
80
  * Build a 1-row Arrow IPC request batch with method metadata.
81
+ *
82
+ * When `options.protocolVersion` is non-empty, the value is emitted as
83
+ * `vgi_rpc.protocol_version` so servers that declare a Protocol-level
84
+ * version validate the request at the dispatch boundary.
82
85
  */
83
- export function buildRequestIpc(schema: Schema, params: Record<string, any>, method: string): Uint8Array {
86
+ export function buildRequestIpc(
87
+ schema: Schema,
88
+ params: Record<string, any>,
89
+ method: string,
90
+ options?: { protocolVersion?: string },
91
+ ): Uint8Array {
84
92
  const metadata = new Map<string, string>();
85
93
  metadata.set(RPC_METHOD_KEY, method);
86
94
  metadata.set(REQUEST_VERSION_KEY, REQUEST_VERSION);
95
+ if (options?.protocolVersion) {
96
+ metadata.set(PROTOCOL_VERSION_KEY, options.protocolVersion);
97
+ }
87
98
 
99
+ // Build the batch through the impl-agnostic #vgi-rpc-arrow layer so this
100
+ // works under both backends. `buildRequestIpc` previously constructed an
101
+ // apache-arrow RecordBatch directly and handed it to `serializeIpcStream`,
102
+ // which then dispatched to the backend's `serializeBatches`. Under the
103
+ // browser/worker condition that backend is flechette, and flechette's
104
+ // `tablesToIPC` cannot read apache-arrow's RecordBatch shape — the cross-
105
+ // impl mixing was silently broken (no browser tests cover this path; see
106
+ // test/client/ipc-cross-impl.test.ts). The abstract helpers produce a
107
+ // 0-row metadata-bearing batch for the empty-schema case (which servers
108
+ // accept identically to a 1-row × 0-col batch) and a 1-row batch with
109
+ // coerced field values otherwise.
88
110
  if (schema.fields.length === 0) {
89
- const structType = new Struct(schema.fields);
90
- const data = makeData({
91
- type: structType,
92
- length: 1,
93
- children: [],
94
- nullCount: 0,
95
- });
96
- const batch = new RecordBatch(schema, data, metadata);
111
+ const batch = emptyBatchWithMetadata(schema, metadata);
97
112
  return serializeIpcStream(schema, [batch]);
98
113
  }
99
114
 
100
- const children = schema.fields.map((f) => {
101
- const val = coerceForArrow(f.type, params[f.name]);
102
- return vectorFromArray([val], f.type).data[0];
103
- });
104
-
105
- const structType = new Struct(schema.fields);
106
- const data = makeData({
107
- type: structType,
108
- length: 1,
109
- children,
110
- nullCount: 0,
111
- });
112
-
113
- const batch = new RecordBatch(schema, data, metadata);
115
+ const coerced: Record<string, any> = {};
116
+ for (const f of schema.fields) {
117
+ const raw = params[f.name];
118
+ // Missing values must be sent as null. arrow-js's typed-array builders
119
+ // throw "Invalid argument type in ToBigInt" when handed `undefined` for
120
+ // an Int64 column; null builds a proper validity bitmap entry instead.
121
+ coerced[f.name] = raw === undefined ? null : coerceForArrow(f.type, raw);
122
+ }
123
+ const batch = singleRowBatchWithMetadata(schema, coerced, metadata);
114
124
  return serializeIpcStream(schema, [batch]);
115
125
  }
116
126
 
@@ -3,14 +3,23 @@
3
3
 
4
4
  /** RFC 9728 OAuth Protected Resource Metadata (client-side response). */
5
5
  export interface OAuthResourceMetadataResponse {
6
+ /** The protected resource's canonical URL (`resource`). */
6
7
  resource: string;
8
+ /** Authorization-server issuer URLs; the first is used for OIDC discovery (`authorization_servers`). */
7
9
  authorizationServers: string[];
10
+ /** Scopes the resource advertises (`scopes_supported`). */
8
11
  scopesSupported?: string[];
12
+ /** Advertised bearer methods, e.g. `["header"]` (`bearer_methods_supported`). */
9
13
  bearerMethodsSupported?: string[];
14
+ /** JWS algorithms the resource accepts (`resource_signing_alg_values_supported`). */
10
15
  resourceSigningAlgValuesSupported?: string[];
16
+ /** Human-readable resource name (`resource_name`). */
11
17
  resourceName?: string;
18
+ /** Documentation URL for the resource (`resource_documentation`). */
12
19
  resourceDocumentation?: string;
20
+ /** Policy URL for the resource (`resource_policy_uri`). */
13
21
  resourcePolicyUri?: string;
22
+ /** Terms-of-service URL for the resource (`resource_tos_uri`). */
14
23
  resourceTosUri?: string;
15
24
  /** OAuth client_id advertised by the server. */
16
25
  clientId?: string;
@@ -75,6 +75,13 @@ class PipeIncrementalWriter {
75
75
  // PipeStreamSession — lockstep streaming over pipes
76
76
  // ---------------------------------------------------------------------------
77
77
 
78
+ /**
79
+ * {@link StreamSession} implementation for the pipe/subprocess transport.
80
+ * Drives lockstep streaming over a single bidirectional pipe: each
81
+ * {@link PipeStreamSession.exchange} or iteration step writes one input batch
82
+ * and reads one output batch. Holds the connection's single-threaded busy lock
83
+ * until closed.
84
+ */
78
85
  export class PipeStreamSession implements StreamSession {
79
86
  private _reader: IpcStreamReader;
80
87
  private _writeFn: WriteFn;
@@ -109,6 +116,7 @@ export class PipeStreamSession implements StreamSession {
109
116
  this._externalConfig = opts.externalConfig;
110
117
  }
111
118
 
119
+ /** The stream's one-time header row, or `null` if the method declares no header. */
112
120
  get header(): Record<string, any> | null {
113
121
  return this._header;
114
122
  }
@@ -125,17 +133,17 @@ export class PipeStreamSession implements StreamSession {
125
133
 
126
134
  if (batch.numRows === 0) {
127
135
  // Check for external location pointer batch
128
- if (isExternalLocationBatch(batch)) {
129
- return await resolveExternalLocation(batch, this._externalConfig);
136
+ if (isExternalLocationBatch(batch as any)) {
137
+ return (await resolveExternalLocation(batch as any, this._externalConfig)) as any;
130
138
  }
131
139
  // Check if it's a log/error batch. If so, dispatch and continue.
132
140
  // Otherwise it's a zero-row data batch — return it.
133
- if (dispatchLogOrError(batch, this._onLog)) {
141
+ if (dispatchLogOrError(batch as any, this._onLog)) {
134
142
  continue;
135
143
  }
136
144
  }
137
145
 
138
- return batch;
146
+ return batch as any;
139
147
  }
140
148
  }
141
149
 
@@ -335,6 +343,11 @@ export class PipeStreamSession implements StreamSession {
335
343
  }
336
344
  }
337
345
 
346
+ /**
347
+ * End the stream: close the input side (or send an empty stream if nothing
348
+ * was sent yet) and drain the server's remaining output in the background,
349
+ * releasing the connection's busy lock once the drain completes.
350
+ */
338
351
  close(): void {
339
352
  if (this._closed) return;
340
353
  this._closed = true;
@@ -377,6 +390,12 @@ export class PipeStreamSession implements StreamSession {
377
390
  // pipeConnect — create an RpcClient over raw readable/writable streams
378
391
  // ---------------------------------------------------------------------------
379
392
 
393
+ /**
394
+ * Connect to a vgi-rpc server over a raw bidirectional pipe (a readable stream
395
+ * of server output plus a writable for client input). The connection is
396
+ * single-threaded: only one call or stream may be in flight at a time. The
397
+ * `__describe__` handshake is sent before the reader is opened to avoid deadlock.
398
+ */
380
399
  export function pipeConnect(
381
400
  readable: ReadableStream<Uint8Array>,
382
401
  writable: PipeWritable,
@@ -389,6 +408,7 @@ export function pipeConnect(
389
408
  let readerPromise: Promise<IpcStreamReader> | null = null;
390
409
  let methodCache: Map<string, MethodInfo> | null = null;
391
410
  let protocolName = "";
411
+ let serverProtocolVersion = "";
392
412
  let _busy = false;
393
413
  let _drainPromise: Promise<void> | null = null;
394
414
  let closed = false;
@@ -458,8 +478,9 @@ export function pipeConnect(
458
478
  throw new Error("EOF reading __describe__ response");
459
479
  }
460
480
 
461
- const desc = await parseDescribeResponse(response.batches, onLog);
481
+ const desc = await parseDescribeResponse(response.batches as any, onLog);
462
482
  protocolName = desc.protocolName;
483
+ serverProtocolVersion = desc.protocolVersion;
463
484
  methodCache = new Map(desc.methods.map((m) => [m.name, m]));
464
485
  return methodCache;
465
486
  } finally {
@@ -483,7 +504,7 @@ export function pipeConnect(
483
504
  const fullParams = { ...(info.defaults ?? {}), ...(params ?? {}) };
484
505
 
485
506
  // Send request
486
- const body = buildRequestIpc(info.paramsSchema, fullParams, method);
507
+ const body = buildRequestIpc(info.paramsSchema, fullParams, method, { protocolVersion: serverProtocolVersion });
487
508
  writeFn(body);
488
509
 
489
510
  // Read response
@@ -494,7 +515,7 @@ export function pipeConnect(
494
515
 
495
516
  // Process batches: dispatch logs, resolve external pointers, find result
496
517
  let resultBatch: RecordBatch | null = null;
497
- for (let batch of response.batches) {
518
+ for (let batch of response.batches as any[]) {
498
519
  if (batch.numRows === 0) {
499
520
  if (isExternalLocationBatch(batch)) {
500
521
  batch = await resolveExternalLocation(batch, externalConfig);
@@ -537,7 +558,7 @@ export function pipeConnect(
537
558
  const fullParams = { ...(info.defaults ?? {}), ...(params ?? {}) };
538
559
 
539
560
  // Send init request (params as a complete IPC stream)
540
- const body = buildRequestIpc(info.paramsSchema, fullParams, method);
561
+ const body = buildRequestIpc(info.paramsSchema, fullParams, method, { protocolVersion: serverProtocolVersion });
541
562
  writeFn(body);
542
563
 
543
564
  // Read header if method has headerSchema
@@ -545,7 +566,7 @@ export function pipeConnect(
545
566
  if (info.headerSchema) {
546
567
  const headerStream = await r.readStream();
547
568
  if (headerStream) {
548
- for (const batch of headerStream.batches) {
569
+ for (const batch of headerStream.batches as any[]) {
549
570
  if (batch.numRows === 0) {
550
571
  dispatchLogOrError(batch, onLog);
551
572
  continue;
@@ -597,6 +618,7 @@ export function pipeConnect(
597
618
  const methods = await ensureMethodCache();
598
619
  return {
599
620
  protocolName,
621
+ protocolVersion: serverProtocolVersion,
600
622
  methods: [...methods.values()],
601
623
  };
602
624
  },
@@ -613,6 +635,11 @@ export function pipeConnect(
613
635
  // subprocessConnect — spawn a process and wrap with pipeConnect
614
636
  // ---------------------------------------------------------------------------
615
637
 
638
+ /**
639
+ * Spawn a server process (via `Bun.spawn`) and connect to it over its
640
+ * stdin/stdout using {@link pipeConnect}. The returned client's
641
+ * {@link RpcClient.close} also kills the subprocess.
642
+ */
616
643
  export function subprocessConnect(cmd: string[], options?: SubprocessConnectOptions): RpcClient {
617
644
  const proc = Bun.spawn(cmd, {
618
645
  stdin: "pipe",
@@ -9,9 +9,22 @@ import { ARROW_CONTENT_TYPE, serializeIpcStream } from "../http/common.js";
9
9
  import { dispatchLogOrError, extractBatchRows, inferArrowType, readResponseBatches } from "./ipc.js";
10
10
  import type { LogMessage, StreamSession } from "./types.js";
11
11
 
12
- type CompressFn = (data: Uint8Array, level: number) => Uint8Array;
13
- type DecompressFn = (data: Uint8Array) => Uint8Array;
14
-
12
+ type CompressFn = (data: Uint8Array, level: number) => Promise<Uint8Array>;
13
+ type DecompressFn = (data: Uint8Array) => Promise<Uint8Array>;
14
+
15
+ /**
16
+ * Posts an Arrow IPC request body to *url*, transparently handling
17
+ * client-vended request externalization. Provided by the parent connection
18
+ * so a single capability cache can drive both unary and stream call paths.
19
+ */
20
+ export type PostFn = (url: string, body: Uint8Array) => Promise<Response>;
21
+
22
+ /**
23
+ * {@link StreamSession} implementation for the HTTP transport. Stream state is
24
+ * carried statelessly across requests via an HMAC state token: each
25
+ * {@link HttpStreamSession.exchange} or producer-continuation POST sends the
26
+ * current token and receives the next one in the response metadata.
27
+ */
15
28
  export class HttpStreamSession implements StreamSession {
16
29
  private _baseUrl: string;
17
30
  private _prefix: string;
@@ -28,6 +41,7 @@ export class HttpStreamSession implements StreamSession {
28
41
  private _decompressFn?: DecompressFn;
29
42
  private _authorization?: string;
30
43
  private _externalConfig?: ExternalLocationConfig;
44
+ private _postFn?: PostFn;
31
45
 
32
46
  constructor(opts: {
33
47
  baseUrl: string;
@@ -45,6 +59,7 @@ export class HttpStreamSession implements StreamSession {
45
59
  decompressFn?: DecompressFn;
46
60
  authorization?: string;
47
61
  externalConfig?: ExternalLocationConfig;
62
+ postFn?: PostFn;
48
63
  }) {
49
64
  this._baseUrl = opts.baseUrl;
50
65
  this._prefix = opts.prefix;
@@ -61,8 +76,19 @@ export class HttpStreamSession implements StreamSession {
61
76
  this._decompressFn = opts.decompressFn;
62
77
  this._authorization = opts.authorization;
63
78
  this._externalConfig = opts.externalConfig;
79
+ this._postFn = opts.postFn;
64
80
  }
65
81
 
82
+ private async _post(url: string, body: Uint8Array): Promise<Response> {
83
+ if (this._postFn) return this._postFn(url, body);
84
+ return fetch(url, {
85
+ method: "POST",
86
+ headers: this._buildHeaders(),
87
+ body: (await this._prepareBody(body)) as unknown as BodyInit,
88
+ });
89
+ }
90
+
91
+ /** The stream's one-time header row, or `null` if the method declares no header. */
66
92
  get header(): Record<string, any> | null {
67
93
  return this._header;
68
94
  }
@@ -71,8 +97,10 @@ export class HttpStreamSession implements StreamSession {
71
97
  const headers: Record<string, string> = {
72
98
  "Content-Type": ARROW_CONTENT_TYPE,
73
99
  };
74
- if (this._compressionLevel != null) {
100
+ if (this._compressionLevel != null && this._compressFn) {
75
101
  headers["Content-Encoding"] = "zstd";
102
+ }
103
+ if (this._compressionLevel != null && this._decompressFn) {
76
104
  headers["Accept-Encoding"] = "zstd";
77
105
  }
78
106
  if (this._authorization) {
@@ -81,9 +109,9 @@ export class HttpStreamSession implements StreamSession {
81
109
  return headers;
82
110
  }
83
111
 
84
- private _prepareBody(content: Uint8Array): Uint8Array {
112
+ private async _prepareBody(content: Uint8Array): Promise<Uint8Array> {
85
113
  if (this._compressionLevel != null && this._compressFn) {
86
- return this._compressFn(content, this._compressionLevel);
114
+ return await this._compressFn(content, this._compressionLevel);
87
115
  }
88
116
  return content;
89
117
  }
@@ -91,7 +119,7 @@ export class HttpStreamSession implements StreamSession {
91
119
  private async _readResponse(resp: Response): Promise<Uint8Array<ArrayBuffer>> {
92
120
  let body = new Uint8Array(await resp.arrayBuffer());
93
121
  if (resp.headers.get("Content-Encoding") === "zstd" && this._decompressFn) {
94
- body = new Uint8Array(this._decompressFn(body));
122
+ body = new Uint8Array(await this._decompressFn(body));
95
123
  }
96
124
  return body;
97
125
  }
@@ -159,11 +187,7 @@ export class HttpStreamSession implements StreamSession {
159
187
 
160
188
  private async _doExchange(schema: Schema, batches: RecordBatch[]): Promise<Record<string, any>[]> {
161
189
  const body = serializeIpcStream(schema, batches);
162
- const resp = await fetch(`${this._baseUrl}${this._prefix}/${this._method}/exchange`, {
163
- method: "POST",
164
- headers: this._buildHeaders(),
165
- body: this._prepareBody(body) as unknown as BodyInit,
166
- });
190
+ const resp = await this._post(`${this._baseUrl}${this._prefix}/${this._method}/exchange`, body);
167
191
  if (resp.status === 401) {
168
192
  throw new RpcError("AuthenticationError", "Authentication required", "");
169
193
  }
@@ -218,7 +242,7 @@ export class HttpStreamSession implements StreamSession {
218
242
  for (let batch of this._pendingBatches) {
219
243
  if (batch.numRows === 0) {
220
244
  if (isExternalLocationBatch(batch)) {
221
- batch = await resolveExternalLocation(batch, this._externalConfig);
245
+ batch = (await resolveExternalLocation(batch as any, this._externalConfig)) as any;
222
246
  } else {
223
247
  dispatchLogOrError(batch, this._onLog);
224
248
  continue;
@@ -233,7 +257,9 @@ export class HttpStreamSession implements StreamSession {
233
257
 
234
258
  // Follow continuation tokens
235
259
  while (true) {
236
- const responseBody = await this._sendContinuation(this._stateToken);
260
+ const stateToken = this._stateToken;
261
+ if (stateToken === null) return;
262
+ const responseBody = await this._sendContinuation(stateToken);
237
263
  const { batches } = await readResponseBatches(responseBody);
238
264
 
239
265
  let gotContinuation = false;
@@ -248,7 +274,7 @@ export class HttpStreamSession implements StreamSession {
248
274
  }
249
275
  // Check for external location pointer
250
276
  if (isExternalLocationBatch(batch)) {
251
- batch = await resolveExternalLocation(batch, this._externalConfig);
277
+ batch = (await resolveExternalLocation(batch as any, this._externalConfig)) as any;
252
278
  } else {
253
279
  // Log/error batch
254
280
  dispatchLogOrError(batch, this._onLog);
@@ -278,11 +304,7 @@ export class HttpStreamSession implements StreamSession {
278
304
  const batch = new RecordBatch(emptySchema, data, metadata);
279
305
  const body = serializeIpcStream(emptySchema, [batch]);
280
306
 
281
- const resp = await fetch(`${this._baseUrl}${this._prefix}/${this._method}/exchange`, {
282
- method: "POST",
283
- headers: this._buildHeaders(),
284
- body: this._prepareBody(body) as unknown as BodyInit,
285
- });
307
+ const resp = await this._post(`${this._baseUrl}${this._prefix}/${this._method}/exchange`, body);
286
308
  if (resp.status === 401) {
287
309
  throw new RpcError("AuthenticationError", "Authentication required", "");
288
310
  }
@@ -290,6 +312,7 @@ export class HttpStreamSession implements StreamSession {
290
312
  return this._readResponse(resp);
291
313
  }
292
314
 
315
+ /** No-op: the HTTP transport is stateless, so there is nothing to tear down. */
293
316
  close(): void {
294
317
  // No-op for HTTP (stateless)
295
318
  }
@@ -1,9 +1,13 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ /** Options for {@link httpConnect}, the HTTP-transport RPC client. */
4
5
  export interface HttpConnectOptions {
6
+ /** Route prefix the server mounts its methods under (e.g. `/api`). Trailing slashes are stripped. Defaults to no prefix. */
5
7
  prefix?: string;
8
+ /** Callback invoked for each log/error message the server emits during a request. */
6
9
  onLog?: (msg: LogMessage) => void;
10
+ /** When set, request bodies are zstd-compressed at this level and `Accept-Encoding: zstd` is sent. Omit to disable compression. */
7
11
  compressionLevel?: number;
8
12
  /** Authorization header value (e.g. "Bearer <token>"). Sent with every request. */
9
13
  authorization?: string;
@@ -11,27 +15,46 @@ export interface HttpConnectOptions {
11
15
  externalLocation?: import("../external.js").ExternalLocationConfig;
12
16
  }
13
17
 
18
+ /** A log or error message delivered to an {@link HttpConnectOptions.onLog} callback. */
14
19
  export interface LogMessage {
20
+ /** Severity, mirroring the server's log level (e.g. `INFO`, `WARNING`, `EXCEPTION`). */
15
21
  level: string;
22
+ /** The human-readable log text. */
16
23
  message: string;
24
+ /** Optional structured fields attached to the log record. */
17
25
  extra?: Record<string, any>;
18
26
  }
19
27
 
28
+ /**
29
+ * A live streaming method call. Exchange methods drive the server with
30
+ * {@link StreamSession.exchange}; producer methods are consumed by async
31
+ * iteration. Always {@link StreamSession.close} when done.
32
+ */
20
33
  export interface StreamSession {
34
+ /** The method's header row (returned once at stream start), or `null` if the method declares no header. */
21
35
  readonly header: Record<string, any> | null;
36
+ /** Send one batch of input rows and receive the server's corresponding output rows (exchange streams). */
22
37
  exchange(input: Record<string, any>[]): Promise<Record<string, any>[]>;
38
+ /** Iterate the server-produced output batches one row-array at a time (producer streams). */
23
39
  [Symbol.asyncIterator](): AsyncIterableIterator<Record<string, any>[]>;
40
+ /** Tear down the stream, flushing/draining the underlying transport. */
24
41
  close(): void;
25
42
  }
26
43
 
44
+ /** Options for {@link pipeConnect}, the client over raw readable/writable streams. */
27
45
  export interface PipeConnectOptions {
46
+ /** Callback invoked for each log/error message the server emits during a request. */
28
47
  onLog?: (msg: LogMessage) => void;
29
48
  /** External storage config for resolving externalized batches. */
30
49
  externalLocation?: import("../external.js").ExternalLocationConfig;
31
50
  }
32
51
 
52
+ /** Options for {@link subprocessConnect}, which spawns a server process and pipes to it. */
33
53
  export interface SubprocessConnectOptions extends PipeConnectOptions {
54
+ /** Working directory for the spawned process. Defaults to the current directory. */
34
55
  cwd?: string;
56
+ /** Extra environment variables, merged over the current `process.env`. */
35
57
  env?: Record<string, string>;
58
+ /** How to handle the child's stderr. Defaults to `"ignore"`. */
36
59
  stderr?: "inherit" | "pipe" | "ignore";
37
60
  }
@@ -0,0 +1,169 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Client-side request-body externalization.
6
+ *
7
+ * When a request body would exceed the server-advertised `maxRequestBytes`,
8
+ * we (1) call `{prefix}/__upload_url__/init` to get a pre-signed upload URL,
9
+ * (2) PUT the body to that URL, and (3) replace the inline body with a
10
+ * zero-row "pointer" IPC stream that carries the original RPC dispatch
11
+ * metadata plus `vgi_rpc.location: <download-url>`. The server then resolves
12
+ * the pointer and dispatches normally.
13
+ *
14
+ * Mirrors Python's `_externalize_via_upload_url()` and `request_upload_urls()`.
15
+ */
16
+
17
+ import { Field, Int64, RecordBatchReader, Schema } from "@query-farm/apache-arrow";
18
+ import { REQUEST_VERSION, REQUEST_VERSION_KEY, RPC_METHOD_KEY } from "../constants.js";
19
+ import { RpcError } from "../errors.js";
20
+ import { makeExternalLocationBatch } from "../external.js";
21
+ import { ARROW_CONTENT_TYPE, serializeIpcStream } from "../http/common.js";
22
+ import { buildRequestIpc } from "./ipc.js";
23
+
24
+ const UPLOAD_URL_METHOD = "__upload_url__";
25
+ const UPLOAD_URL_PARAMS_SCHEMA = new Schema([new Field("count", new Int64(), false)]);
26
+
27
+ export interface UploadUrlPair {
28
+ uploadUrl: string;
29
+ downloadUrl: string;
30
+ expiresAt: Date;
31
+ }
32
+
33
+ /**
34
+ * POST `__upload_url__/init` and return the requested number of pre-signed
35
+ * URL pairs. Server must have an `uploadUrlProvider` configured; otherwise
36
+ * the route returns 404 and we surface that as `RpcError("NotSupported")`.
37
+ */
38
+ export async function requestUploadUrls(
39
+ baseUrl: string,
40
+ prefix: string,
41
+ count: number,
42
+ authorization?: string,
43
+ ): Promise<UploadUrlPair[]> {
44
+ const body = buildRequestIpc(UPLOAD_URL_PARAMS_SCHEMA, { count: BigInt(count) }, UPLOAD_URL_METHOD);
45
+ const headers: Record<string, string> = { "Content-Type": ARROW_CONTENT_TYPE };
46
+ if (authorization) headers.Authorization = authorization;
47
+
48
+ const resp = await fetch(`${baseUrl}${prefix}/${UPLOAD_URL_METHOD}/init`, {
49
+ method: "POST",
50
+ headers,
51
+ body: body as unknown as BodyInit,
52
+ });
53
+ if (resp.status === 404) {
54
+ throw new RpcError("NotSupported", "Server does not support upload URLs", "");
55
+ }
56
+ if (resp.status === 401) {
57
+ throw new RpcError("AuthenticationError", "Authentication required", "");
58
+ }
59
+ if (!resp.ok) {
60
+ throw new RpcError("HttpError", `__upload_url__/init failed: HTTP ${resp.status}`, "");
61
+ }
62
+
63
+ const respBody = new Uint8Array(await resp.arrayBuffer());
64
+ const reader = await RecordBatchReader.from(respBody);
65
+ await reader.open();
66
+
67
+ const pairs: UploadUrlPair[] = [];
68
+ for (const batch of reader.readAll()) {
69
+ if (batch.numRows === 0) continue;
70
+ for (let r = 0; r < batch.numRows; r++) {
71
+ const uploadUrl = batch.getChildAt(0)?.get(r) as string;
72
+ const downloadUrl = batch.getChildAt(1)?.get(r) as string;
73
+ const expiresRaw = batch.getChildAt(2)?.get(r);
74
+ // Timestamp(us) → either a Date, a number (ms), or a bigint (us)
75
+ let expiresAt: Date;
76
+ if (expiresRaw instanceof Date) {
77
+ expiresAt = expiresRaw;
78
+ } else if (typeof expiresRaw === "bigint") {
79
+ expiresAt = new Date(Number(expiresRaw / 1000n));
80
+ } else if (typeof expiresRaw === "number") {
81
+ expiresAt = new Date(expiresRaw);
82
+ } else {
83
+ expiresAt = new Date();
84
+ }
85
+ pairs.push({ uploadUrl, downloadUrl, expiresAt });
86
+ }
87
+ }
88
+
89
+ if (pairs.length === 0) {
90
+ throw new RpcError("ProtocolError", "Server returned no upload URLs", "");
91
+ }
92
+ return pairs;
93
+ }
94
+
95
+ /**
96
+ * Build the externalized pointer body to send in place of *originalBody*.
97
+ *
98
+ * The pointer is a zero-row IPC stream whose first batch carries:
99
+ * - same schema as the original request batch
100
+ * - merged custom_metadata: original RPC dispatch keys + `vgi_rpc.location`
101
+ *
102
+ * The server's request reader honours the dispatch keys for routing, then
103
+ * resolves the pointer to fetch the inline batch for parameter extraction.
104
+ */
105
+ async function buildPointerRequestBody(originalBody: Uint8Array, downloadUrl: string): Promise<Uint8Array> {
106
+ const reader = await RecordBatchReader.from(originalBody);
107
+ await reader.open();
108
+ const schema = reader.schema;
109
+ if (!schema) {
110
+ throw new RpcError("ProtocolError", "Original request body has no schema", "");
111
+ }
112
+ const batches = reader.readAll();
113
+ if (batches.length === 0) {
114
+ throw new RpcError("ProtocolError", "Original request body has no batches", "");
115
+ }
116
+ const original = batches[0];
117
+ const originalMeta = original.metadata ?? new Map<string, string>();
118
+
119
+ const pointer = makeExternalLocationBatch(schema, downloadUrl);
120
+ const merged = new Map<string, string>(pointer.metadata ?? new Map());
121
+ // Preserve the original RPC dispatch metadata so the server can route.
122
+ const method = originalMeta.get(RPC_METHOD_KEY);
123
+ const version = originalMeta.get(REQUEST_VERSION_KEY) ?? REQUEST_VERSION;
124
+ if (method) merged.set(RPC_METHOD_KEY, method);
125
+ merged.set(REQUEST_VERSION_KEY, version);
126
+ // Carry over any other keys (request id, state token for exchange, etc).
127
+ for (const [k, v] of originalMeta) {
128
+ if (!merged.has(k)) merged.set(k, v);
129
+ }
130
+
131
+ // Re-emit the pointer batch with merged metadata.
132
+ const { RecordBatch } = await import("@query-farm/apache-arrow");
133
+ const pointerWithMeta = new RecordBatch(schema as any, (pointer as any).data, merged);
134
+ return serializeIpcStream(schema, [pointerWithMeta]);
135
+ }
136
+
137
+ export interface ExternalizeOptions {
138
+ baseUrl: string;
139
+ prefix: string;
140
+ authorization?: string;
141
+ /** Optional per-URL validator; throw to reject. */
142
+ urlValidator?: ((url: string) => void) | null;
143
+ }
144
+
145
+ /**
146
+ * Upload *body* via a server-vended URL and return the pointer-batch body
147
+ * that should be sent in place of the original. Throws if the server does
148
+ * not advertise upload-URL support or the upload fails.
149
+ */
150
+ export async function externalizeRequestBody(body: Uint8Array, opts: ExternalizeOptions): Promise<Uint8Array> {
151
+ const pairs = await requestUploadUrls(opts.baseUrl, opts.prefix, 1, opts.authorization);
152
+ const pair = pairs[0];
153
+
154
+ if (opts.urlValidator) {
155
+ opts.urlValidator(pair.uploadUrl);
156
+ opts.urlValidator(pair.downloadUrl);
157
+ }
158
+
159
+ const putResp = await fetch(pair.uploadUrl, {
160
+ method: "PUT",
161
+ headers: { "Content-Type": ARROW_CONTENT_TYPE },
162
+ body: body as unknown as BodyInit,
163
+ });
164
+ if (!putResp.ok) {
165
+ throw new RpcError("ExternalUploadFailed", `PUT to upload URL failed: HTTP ${putResp.status}`, "");
166
+ }
167
+
168
+ return buildPointerRequestBody(body, pair.downloadUrl);
169
+ }