@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
@@ -0,0 +1,56 @@
1
+ // Backend-agnostic Arrow type predicates. typeId values match the Arrow Type
2
+ // enum and agree across arrow-js and flechette.
3
+
4
+ import type { VgiDataType } from "./types.js";
5
+
6
+ export const TypeId = {
7
+ Null: 1,
8
+ Int: 2,
9
+ Float: 3,
10
+ Binary: 4,
11
+ Utf8: 5,
12
+ Bool: 6,
13
+ Decimal: 7,
14
+ Date: 8,
15
+ Time: 9,
16
+ Timestamp: 10,
17
+ Interval: 11,
18
+ List: 12,
19
+ Struct: 13,
20
+ Union: 14,
21
+ FixedSizeBinary: 15,
22
+ FixedSizeList: 16,
23
+ Map: 17,
24
+ Duration: 18,
25
+ LargeBinary: 19,
26
+ LargeUtf8: 20,
27
+ Dictionary: -1,
28
+ } as const;
29
+
30
+ export const isNull = (t: VgiDataType): boolean => t.typeId === TypeId.Null;
31
+ export const isInt = (t: VgiDataType): boolean => t.typeId === TypeId.Int;
32
+ export const isFloat = (t: VgiDataType): boolean => t.typeId === TypeId.Float;
33
+ export const isBinary = (t: VgiDataType): boolean => t.typeId === TypeId.Binary || t.typeId === TypeId.LargeBinary;
34
+ export const isUtf8 = (t: VgiDataType): boolean => t.typeId === TypeId.Utf8 || t.typeId === TypeId.LargeUtf8;
35
+ export const isLargeUtf8 = (t: VgiDataType): boolean => t.typeId === TypeId.LargeUtf8;
36
+ export const isLargeBinary = (t: VgiDataType): boolean => t.typeId === TypeId.LargeBinary;
37
+ export const isBool = (t: VgiDataType): boolean => t.typeId === TypeId.Bool;
38
+ export const isDecimal = (t: VgiDataType): boolean => t.typeId === TypeId.Decimal;
39
+ export const isDate = (t: VgiDataType): boolean => t.typeId === TypeId.Date;
40
+ export const isTime = (t: VgiDataType): boolean => t.typeId === TypeId.Time;
41
+ export const isTimestamp = (t: VgiDataType): boolean => t.typeId === TypeId.Timestamp;
42
+ export const isDuration = (t: VgiDataType): boolean => t.typeId === TypeId.Duration;
43
+ export const isList = (t: VgiDataType): boolean => t.typeId === TypeId.List;
44
+ export const isStruct = (t: VgiDataType): boolean => t.typeId === TypeId.Struct;
45
+ export const isMap = (t: VgiDataType): boolean => t.typeId === TypeId.Map;
46
+ export const isFixedSizeBinary = (t: VgiDataType): boolean => t.typeId === TypeId.FixedSizeBinary;
47
+ export const isDictionary = (t: VgiDataType): boolean => t.typeId === TypeId.Dictionary;
48
+
49
+ export function isBatch(x: unknown): x is import("./types.js").VgiBatch {
50
+ return (
51
+ x != null &&
52
+ typeof (x as any).numRows === "number" &&
53
+ (x as any).schema != null &&
54
+ Array.isArray((x as any).schema.fields)
55
+ );
56
+ }
@@ -0,0 +1,73 @@
1
+ // Backend-agnostic Arrow type surface used inside vgi-rpc-typescript.
2
+ // Mirrors vgi-typescript's facade so structurally compatible values flow
3
+ // freely between the two packages.
4
+
5
+ export type VgiTypeId = number;
6
+
7
+ export interface VgiDataType {
8
+ readonly typeId: VgiTypeId;
9
+ }
10
+
11
+ export interface VgiField {
12
+ readonly name: string;
13
+ readonly type: VgiDataType;
14
+ readonly nullable: boolean;
15
+ readonly metadata: Map<string, string>;
16
+ }
17
+
18
+ export interface VgiSchema {
19
+ readonly fields: readonly VgiField[];
20
+ readonly metadata: Map<string, string>;
21
+ }
22
+
23
+ export interface VgiColumn {
24
+ readonly type: VgiDataType;
25
+ readonly length: number;
26
+ get(index: number): unknown;
27
+ [Symbol.iterator](): Iterator<unknown>;
28
+ }
29
+
30
+ export interface VgiBatch {
31
+ readonly schema: VgiSchema;
32
+ readonly numRows: number;
33
+ readonly metadata?: Map<string, string> | null;
34
+ getChild(name: string): VgiColumn | null;
35
+ getChildAt(index: number): VgiColumn | null;
36
+ }
37
+
38
+ export interface VgiBackendInfo {
39
+ readonly name: "arrow-js" | "flechette";
40
+ /**
41
+ * Whether this backend's `.get(0)` round-trip is unreliable for opaque
42
+ * column types (Map/Date/Time/Timestamp/Duration/Decimal/LargeUtf8/
43
+ * LargeBinary/FixedSizeBinary/Dictionary), requiring the request parser to
44
+ * pass the raw column data straight through instead of materializing a
45
+ * value. True for arrow-js; false for flechette (which extracts cleanly).
46
+ */
47
+ readonly opaquePassthrough: boolean;
48
+ }
49
+
50
+ export type VgiColumnData = unknown;
51
+
52
+ /**
53
+ * Incremental IPC stream encoder for the lockstep stdio transport.
54
+ *
55
+ * The stdio exchange protocol is lockstep — the client reads each response
56
+ * batch (and its framing bytes) before sending the next input — so we
57
+ * cannot buffer-then-emit at close. Each call returns the wire bytes to
58
+ * flush immediately. Only the stdio server uses this; HTTP serializes
59
+ * whole responses via {@link serializeBatches}.
60
+ *
61
+ * The arrow-js backend implements this over `RecordBatchStreamWriter`.
62
+ * The flechette backend has no incremental-writer surface and its
63
+ * factory throws — keeping arrow-js out of the flechette (workerd/
64
+ * browser) bundle, which is HTTP-only anyway.
65
+ */
66
+ export interface IncrementalEncoder {
67
+ /** Bytes for the schema preamble (continuation + schema message). */
68
+ start(): Uint8Array;
69
+ /** Bytes for one record batch message. */
70
+ writeBatch(batch: VgiBatch): Uint8Array;
71
+ /** Bytes for the end-of-stream marker. */
72
+ finish(): Uint8Array;
73
+ }
package/src/auth.ts CHANGED
@@ -5,9 +5,14 @@ import { RpcError } from "./errors.js";
5
5
 
6
6
  /** Authentication context available to RPC handlers. */
7
7
  export class AuthContext {
8
+ /** Authentication domain/realm that vouched for the principal; empty string
9
+ * when anonymous. */
8
10
  readonly domain: string;
11
+ /** True when the request carried valid credentials. */
9
12
  readonly authenticated: boolean;
13
+ /** Authenticated principal identifier, or `null` when anonymous. */
10
14
  readonly principal: string | null;
15
+ /** Arbitrary verified claims about the principal (e.g. decoded JWT claims). */
11
16
  readonly claims: Record<string, any>;
12
17
 
13
18
  constructor(domain: string, authenticated: boolean, principal: string | null, claims: Record<string, any> = {}) {
@@ -0,0 +1,84 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * HTTP server capability discovery.
6
+ *
7
+ * Mirrors Python's `http_capabilities()`: probes `OPTIONS {prefix}/health`
8
+ * and reads three response headers:
9
+ * - `VGI-Max-Request-Bytes` — server-enforced inline request cap
10
+ * - `VGI-Upload-URL-Support` — "true" when the server vends upload URLs
11
+ * - `VGI-Max-Upload-Bytes` — cap on out-of-band upload size
12
+ *
13
+ * Honours `Cache-Control: max-age=N` for refresh scheduling.
14
+ */
15
+
16
+ export interface HttpServerCapabilities {
17
+ /** Server's advertised max inline request body size (bytes). */
18
+ maxRequestBytes: number | null;
19
+ /** Whether the server vends upload URLs via `__upload_url__/init`. */
20
+ uploadUrlSupport: boolean;
21
+ /** Cap on the size of an externalized upload (bytes). */
22
+ maxUploadBytes: number | null;
23
+ /** Monotonic-time-ish epoch (ms) at which this snapshot should be re-probed. */
24
+ cacheExpiresAt: number | null;
25
+ }
26
+
27
+ const MAX_REQUEST_BYTES_HEADER = "VGI-Max-Request-Bytes";
28
+ const UPLOAD_URL_HEADER = "VGI-Upload-URL-Support";
29
+ const MAX_UPLOAD_BYTES_HEADER = "VGI-Max-Upload-Bytes";
30
+
31
+ function parseHeaderInt(headers: Headers, name: string): number | null {
32
+ const raw = headers.get(name) ?? headers.get(name.toLowerCase());
33
+ if (raw == null) return null;
34
+ const parsed = Number.parseInt(raw, 10);
35
+ return Number.isFinite(parsed) ? parsed : null;
36
+ }
37
+
38
+ export function parseCapabilitiesFromHeaders(headers: Headers): HttpServerCapabilities {
39
+ const uploadRaw = headers.get(UPLOAD_URL_HEADER) ?? headers.get(UPLOAD_URL_HEADER.toLowerCase());
40
+ const uploadUrlSupport = uploadRaw === "true";
41
+
42
+ let cacheExpiresAt: number | null = null;
43
+ const cc = headers.get("Cache-Control") ?? headers.get("cache-control");
44
+ if (cc) {
45
+ for (const token of cc.split(",")) {
46
+ const t = token.trim().toLowerCase();
47
+ if (t.startsWith("max-age=")) {
48
+ const seconds = Number.parseFloat(t.slice("max-age=".length));
49
+ if (Number.isFinite(seconds)) {
50
+ cacheExpiresAt = Date.now() + seconds * 1000;
51
+ }
52
+ break;
53
+ }
54
+ }
55
+ }
56
+
57
+ return {
58
+ maxRequestBytes: parseHeaderInt(headers, MAX_REQUEST_BYTES_HEADER),
59
+ uploadUrlSupport,
60
+ maxUploadBytes: parseHeaderInt(headers, MAX_UPLOAD_BYTES_HEADER),
61
+ cacheExpiresAt,
62
+ };
63
+ }
64
+
65
+ export async function discoverHttpCapabilities(
66
+ baseUrl: string,
67
+ prefix: string,
68
+ authorization?: string,
69
+ ): Promise<HttpServerCapabilities> {
70
+ const headers: Record<string, string> = {};
71
+ if (authorization) headers.Authorization = authorization;
72
+ const resp = await fetch(`${baseUrl}${prefix}/health`, {
73
+ method: "OPTIONS",
74
+ headers,
75
+ });
76
+ // Capability headers are advertised on every response; we don't require 200.
77
+ return parseCapabilitiesFromHeaders(resp.headers);
78
+ }
79
+
80
+ export function isCapabilitySnapshotFresh(snapshot: HttpServerCapabilities | null): boolean {
81
+ if (!snapshot) return false;
82
+ if (snapshot.cacheExpiresAt == null) return true;
83
+ return Date.now() < snapshot.cacheExpiresAt;
84
+ }
@@ -6,6 +6,11 @@ import { LOG_LEVEL_KEY, STATE_KEY } from "../constants.js";
6
6
  import { RpcError } from "../errors.js";
7
7
  import { isExternalLocationBatch, resolveExternalLocation } from "../external.js";
8
8
  import { ARROW_CONTENT_TYPE } from "../http/common.js";
9
+ import {
10
+ type HttpServerCapabilities,
11
+ isCapabilitySnapshotFresh,
12
+ parseCapabilitiesFromHeaders,
13
+ } from "./capabilities.js";
9
14
  import { httpIntrospect, type MethodInfo, type ServiceDescription } from "./introspect.js";
10
15
  import {
11
16
  buildRequestIpc,
@@ -16,17 +21,28 @@ import {
16
21
  } from "./ipc.js";
17
22
  import { HttpStreamSession } from "./stream.js";
18
23
  import type { HttpConnectOptions, StreamSession } from "./types.js";
24
+ import { externalizeRequestBody } from "./uploadUrl.js";
19
25
 
20
- type CompressFn = (data: Uint8Array, level: number) => Uint8Array;
21
- type DecompressFn = (data: Uint8Array) => Uint8Array;
26
+ type CompressFn = (data: Uint8Array, level: number) => Promise<Uint8Array>;
27
+ type DecompressFn = (data: Uint8Array) => Promise<Uint8Array>;
22
28
 
29
+ /** A connected RPC client, returned by {@link httpConnect}, {@link pipeConnect}, and {@link subprocessConnect}. */
23
30
  export interface RpcClient {
31
+ /** Invoke a unary method. Returns the single result row, or `null` for void methods. Parameter defaults from `__describe__` are applied automatically. */
24
32
  call(method: string, params?: Record<string, any>): Promise<Record<string, any> | null>;
33
+ /** Open a streaming method, returning a {@link StreamSession} for exchange or producer iteration. */
25
34
  stream(method: string, params?: Record<string, any>): Promise<StreamSession>;
35
+ /** Fetch the server's method/protocol description (cached after the first call). */
26
36
  describe(): Promise<ServiceDescription>;
37
+ /** Release transport resources; for subprocess clients this also terminates the child process. */
27
38
  close(): void;
28
39
  }
29
40
 
41
+ /**
42
+ * Connect to a vgi-rpc server over HTTP. The returned client lazily introspects
43
+ * the server (caching `__describe__`) on the first call and transparently handles
44
+ * zstd compression, authorization, and 413 request externalization.
45
+ */
30
46
  export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcClient {
31
47
  const prefix = (options?.prefix ?? "").replace(/\/+$/, "");
32
48
  const onLog = options?.onLog;
@@ -35,13 +51,73 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
35
51
  const externalConfig = options?.externalLocation;
36
52
 
37
53
  let methodCache: Map<string, MethodInfo> | null = null;
54
+ /** Application protocol surface version discovered via __describe__. When
55
+ * non-empty, the client emits it on every request as
56
+ * `vgi_rpc.protocol_version` so a versioned server can validate at the
57
+ * dispatch boundary. */
58
+ let serverProtocolVersion = "";
38
59
  let compressFn: CompressFn | undefined;
39
60
  let decompressFn: DecompressFn | undefined;
40
61
  let compressionLoaded = false;
62
+ let capabilities: HttpServerCapabilities | null = null;
63
+
64
+ function updateCapabilitiesFromResponse(resp: Response): void {
65
+ const next = parseCapabilitiesFromHeaders(resp.headers);
66
+ // Only treat the snapshot as authoritative when the server actually
67
+ // emitted capability hints. Otherwise leave any prior cache in place.
68
+ if (next.maxRequestBytes != null || next.uploadUrlSupport) {
69
+ capabilities = next;
70
+ }
71
+ }
72
+
73
+ async function maybeExternalize(body: Uint8Array): Promise<Uint8Array> {
74
+ const caps = isCapabilitySnapshotFresh(capabilities) ? capabilities : null;
75
+ if (!caps) return body;
76
+ if (!caps.uploadUrlSupport) return body;
77
+ if (caps.maxRequestBytes == null || body.byteLength <= caps.maxRequestBytes) return body;
78
+ return externalizeRequestBody(body, {
79
+ baseUrl,
80
+ prefix,
81
+ authorization,
82
+ urlValidator: externalConfig?.urlValidator ?? null,
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Send a POST request, transparently retrying with externalization if
88
+ * the server returns 413 (Payload Too Large) and advertises upload-URL
89
+ * support. Mirrors Python's 413 fallback in `_HttpProxy._post_with_externalization`.
90
+ */
91
+ async function postWithExternalization(url: string, body: Uint8Array): Promise<Response> {
92
+ const sendBody = await maybeExternalize(body);
93
+ let resp = await fetch(url, {
94
+ method: "POST",
95
+ headers: buildHeaders(),
96
+ body: (await prepareBody(sendBody)) as unknown as BodyInit,
97
+ });
98
+ updateCapabilitiesFromResponse(resp);
99
+
100
+ if (resp.status === 413 && capabilities?.uploadUrlSupport && body.byteLength > 0) {
101
+ // Refresh-and-retry: caps tell us we can externalize.
102
+ const externalized = await externalizeRequestBody(body, {
103
+ baseUrl,
104
+ prefix,
105
+ authorization,
106
+ urlValidator: externalConfig?.urlValidator ?? null,
107
+ });
108
+ resp = await fetch(url, {
109
+ method: "POST",
110
+ headers: buildHeaders(),
111
+ body: (await prepareBody(externalized)) as unknown as BodyInit,
112
+ });
113
+ updateCapabilitiesFromResponse(resp);
114
+ }
115
+
116
+ return resp;
117
+ }
41
118
 
42
119
  async function ensureCompression(): Promise<void> {
43
120
  if (compressionLoaded || compressionLevel == null) return;
44
- compressionLoaded = true;
45
121
  try {
46
122
  const mod = await import("../util/zstd.js");
47
123
  compressFn = mod.zstdCompress;
@@ -49,14 +125,17 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
49
125
  } catch {
50
126
  // zstd not available in this runtime
51
127
  }
128
+ compressionLoaded = true;
52
129
  }
53
130
 
54
131
  function buildHeaders(): Record<string, string> {
55
132
  const headers: Record<string, string> = {
56
133
  "Content-Type": ARROW_CONTENT_TYPE,
57
134
  };
58
- if (compressionLevel != null) {
135
+ if (compressionLevel != null && compressFn) {
59
136
  headers["Content-Encoding"] = "zstd";
137
+ }
138
+ if (compressionLevel != null && decompressFn) {
60
139
  headers["Accept-Encoding"] = "zstd";
61
140
  }
62
141
  if (authorization) {
@@ -65,9 +144,9 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
65
144
  return headers;
66
145
  }
67
146
 
68
- function prepareBody(content: Uint8Array): Uint8Array {
147
+ async function prepareBody(content: Uint8Array): Promise<Uint8Array> {
69
148
  if (compressionLevel != null && compressFn) {
70
- return compressFn(content, compressionLevel);
149
+ return await compressFn(content, compressionLevel);
71
150
  }
72
151
  return content;
73
152
  }
@@ -81,15 +160,23 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
81
160
  async function readResponse(resp: Response): Promise<Uint8Array<ArrayBuffer>> {
82
161
  let body = new Uint8Array(await resp.arrayBuffer());
83
162
  if (resp.headers.get("Content-Encoding") === "zstd" && decompressFn) {
84
- body = new Uint8Array(decompressFn(body));
163
+ body = new Uint8Array(await decompressFn(body));
85
164
  }
86
165
  return body;
87
166
  }
88
167
 
89
168
  async function ensureMethodCache(): Promise<Map<string, MethodInfo>> {
90
169
  if (methodCache) return methodCache;
91
- const desc = await httpIntrospect(baseUrl, { prefix, authorization });
170
+ await ensureCompression();
171
+ const desc = await httpIntrospect(baseUrl, {
172
+ prefix,
173
+ authorization,
174
+ compressionLevel,
175
+ compressFn,
176
+ decompressFn,
177
+ });
92
178
  methodCache = new Map(desc.methods.map((m) => [m.name, m]));
179
+ serverProtocolVersion = desc.protocolVersion;
93
180
  return methodCache;
94
181
  }
95
182
 
@@ -105,12 +192,8 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
105
192
  // Apply defaults
106
193
  const fullParams = { ...(info.defaults ?? {}), ...(params ?? {}) };
107
194
 
108
- const body = buildRequestIpc(info.paramsSchema, fullParams, method);
109
- const resp = await fetch(`${baseUrl}${prefix}/${method}`, {
110
- method: "POST",
111
- headers: buildHeaders(),
112
- body: prepareBody(body) as unknown as BodyInit,
113
- });
195
+ const body = buildRequestIpc(info.paramsSchema, fullParams, method, { protocolVersion: serverProtocolVersion });
196
+ const resp = await postWithExternalization(`${baseUrl}${prefix}/${method}`, body);
114
197
  checkAuth(resp);
115
198
 
116
199
  const responseBody = await readResponse(resp);
@@ -121,8 +204,8 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
121
204
  for (let batch of batches) {
122
205
  if (batch.numRows === 0) {
123
206
  // Check for external location pointer batch
124
- if (isExternalLocationBatch(batch)) {
125
- batch = await resolveExternalLocation(batch, externalConfig);
207
+ if (isExternalLocationBatch(batch as any)) {
208
+ batch = (await resolveExternalLocation(batch as any, externalConfig)) as any;
126
209
  } else {
127
210
  dispatchLogOrError(batch, onLog);
128
211
  continue;
@@ -159,12 +242,8 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
159
242
  // Apply defaults
160
243
  const fullParams = { ...(info.defaults ?? {}), ...(params ?? {}) };
161
244
 
162
- const body = buildRequestIpc(info.paramsSchema, fullParams, method);
163
- const resp = await fetch(`${baseUrl}${prefix}/${method}/init`, {
164
- method: "POST",
165
- headers: buildHeaders(),
166
- body: prepareBody(body) as unknown as BodyInit,
167
- });
245
+ const body = buildRequestIpc(info.paramsSchema, fullParams, method, { protocolVersion: serverProtocolVersion });
246
+ const resp = await postWithExternalization(`${baseUrl}${prefix}/${method}/init`, body);
168
247
  checkAuth(resp);
169
248
 
170
249
  const responseBody = await readResponse(resp);
@@ -185,7 +264,7 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
185
264
  // First stream: header
186
265
  const headerStream = await reader.readStream();
187
266
  if (headerStream) {
188
- for (const batch of headerStream.batches) {
267
+ for (const batch of headerStream.batches as any[]) {
189
268
  if (batch.numRows === 0) {
190
269
  dispatchLogOrError(batch, onLog);
191
270
  continue;
@@ -200,11 +279,11 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
200
279
  // Second stream: data/state
201
280
  const dataStream = await reader.readStream();
202
281
  if (dataStream) {
203
- streamSchema = dataStream.schema;
282
+ streamSchema = dataStream.schema as any;
204
283
  }
205
284
  const headerErrorBatches: RecordBatch[] = [];
206
285
  if (dataStream) {
207
- for (const batch of dataStream.batches) {
286
+ for (const batch of dataStream.batches as any[]) {
208
287
  if (batch.numRows === 0) {
209
288
  // Check for state token
210
289
  const token = batch.metadata?.get(STATE_KEY);
@@ -310,11 +389,19 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
310
389
  decompressFn,
311
390
  authorization,
312
391
  externalConfig,
392
+ postFn: postWithExternalization,
313
393
  });
314
394
  },
315
395
 
316
396
  async describe(): Promise<ServiceDescription> {
317
- return httpIntrospect(baseUrl, { prefix });
397
+ await ensureCompression();
398
+ return httpIntrospect(baseUrl, {
399
+ prefix,
400
+ authorization,
401
+ compressionLevel,
402
+ compressFn,
403
+ decompressFn,
404
+ });
318
405
  },
319
406
 
320
407
  close(): void {
@@ -1,36 +1,66 @@
1
1
  // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import { Schema as ArrowSchema, type RecordBatch, RecordBatchReader, type Schema } from "@query-farm/apache-arrow";
5
- import { DESCRIBE_METHOD_NAME, PROTOCOL_NAME_KEY } from "../constants.js";
4
+ import { Schema as ArrowSchema, type RecordBatch, type Schema } from "@query-farm/apache-arrow";
5
+ import { deserializeSchema as deserializeSchemaImpl } from "#vgi-rpc-arrow";
6
+ import { DESCRIBE_METHOD_NAME, PROTOCOL_NAME_KEY, PROTOCOL_VERSION_KEY } from "../constants.js";
6
7
  import { RpcError } from "../errors.js";
7
8
  import { ARROW_CONTENT_TYPE } from "../http/common.js";
8
9
  import { buildRequestIpc, dispatchLogOrError, readResponseBatches } from "./ipc.js";
9
10
  import type { LogMessage } from "./types.js";
10
11
 
12
+ /** Describes a single RPC method as reported by the server's `__describe__` response. */
11
13
  export interface MethodInfo {
14
+ /** The method name as invoked by {@link RpcClient.call} / {@link RpcClient.stream}. */
12
15
  name: string;
16
+ /** Whether the method is a single request/response (`unary`) or a streaming method (`stream`). */
13
17
  type: "unary" | "stream";
18
+ /** Arrow schema of the call parameters. */
14
19
  paramsSchema: Schema;
20
+ /** Arrow schema of a unary result; for stream methods this holds the per-batch output schema. */
15
21
  resultSchema: Schema;
22
+ /** Arrow schema of the per-batch input rows for exchange streams, when available. */
16
23
  inputSchema?: Schema;
24
+ /** Arrow schema of the per-batch output rows for stream methods, when available. */
17
25
  outputSchema?: Schema;
26
+ /** Arrow schema of the stream's one-time header row, when the method declares one. */
18
27
  headerSchema?: Schema;
28
+ /** Human-readable documentation for the method, if the server provides it. */
19
29
  doc?: string;
30
+ /** Per-parameter human-readable type names, if the server provides them. */
20
31
  paramTypes?: Record<string, string>;
32
+ /** Default values applied to omitted parameters before a call is sent. */
21
33
  defaults?: Record<string, any>;
22
34
  }
23
35
 
36
+ /** The full set of methods and protocol metadata reported by a server's `__describe__`. */
24
37
  export interface ServiceDescription {
38
+ /** The server's declared protocol/service name. */
25
39
  protocolName: string;
40
+ /** Application protocol surface version surfaced by the server's
41
+ * __describe__ response. Empty string when the server did not declare
42
+ * a `protocolVersion`. */
43
+ protocolVersion: string;
44
+ /** Every method the server exposes (excluding the built-in `__describe__`). */
26
45
  methods: MethodInfo[];
27
46
  }
28
47
 
29
- /** Deserialize a schema from IPC bytes (schema message + EOS). */
30
- async function deserializeSchema(bytes: Uint8Array): Promise<Schema> {
31
- const reader = await RecordBatchReader.from(bytes);
32
- await reader.open();
33
- return reader.schema!;
48
+ /**
49
+ * Deserialize a schema from IPC bytes (schema message + EOS).
50
+ *
51
+ * Must dispatch via `#vgi-rpc-arrow` so the resulting type instances are
52
+ * the same impl (apache-arrow / flechette) as the rest of the active
53
+ * backend. Using apache-arrow's `RecordBatchReader` directly here used to
54
+ * silently mix impls: in browser builds the backend is flechette, and a
55
+ * flechette builder receiving an apache-arrow `Binary` type defaults to
56
+ * the wrong offsets buffer (Uint8Array instead of Int32Array) and emits
57
+ * a 0-byte value where a populated binary column was expected. The
58
+ * downstream symptom is "Tried reading schema message, was null or
59
+ * length 0" from the server when it tries to open the (empty) binary
60
+ * column as a nested IPC stream. See test/client/ipc-cross-impl.test.ts.
61
+ */
62
+ function deserializeSchema(bytes: Uint8Array): Schema {
63
+ return deserializeSchemaImpl(bytes) as unknown as Schema;
34
64
  }
35
65
 
36
66
  /**
@@ -58,45 +88,30 @@ export async function parseDescribeResponse(
58
88
  // Extract metadata from batch
59
89
  const meta = dataBatch.metadata;
60
90
  const protocolName = meta?.get(PROTOCOL_NAME_KEY) ?? "";
91
+ const protocolVersion = meta?.get(PROTOCOL_VERSION_KEY) ?? "";
61
92
 
93
+ // Slim DESCRIBE_VERSION 4 wire format (see dispatch/describe.ts):
94
+ // 0:name 1:method_type 2:has_return 3:params_schema_ipc
95
+ // 4:result_schema_ipc 5:has_header 6:header_schema_ipc 7:is_exchange
62
96
  const methods: MethodInfo[] = [];
63
97
  for (let i = 0; i < dataBatch.numRows; i++) {
64
- const name = dataBatch.getChildAt(0)!.get(i) as string; // name
65
- const methodType = dataBatch.getChildAt(1)!.get(i) as string; // method_type
66
- const doc = dataBatch.getChildAt(2)?.get(i) as string | null; // doc
67
- const _hasReturn = dataBatch.getChildAt(3)!.get(i) as boolean; // has_return
68
- const paramsIpc = dataBatch.getChildAt(4)!.get(i) as Uint8Array; // params_schema_ipc
69
- const resultIpc = dataBatch.getChildAt(5)!.get(i) as Uint8Array; // result_schema_ipc
70
- const paramTypesJson = dataBatch.getChildAt(6)?.get(i) as string | null; // param_types_json
71
- const paramDefaultsJson = dataBatch.getChildAt(7)?.get(i) as string | null; // param_defaults_json
72
- const hasHeader = dataBatch.getChildAt(8)!.get(i) as boolean; // has_header
73
- const headerIpc = dataBatch.getChildAt(9)?.get(i) as Uint8Array | null; // header_schema_ipc
98
+ const name = dataBatch.getChildAt(0)!.get(i) as string;
99
+ const methodType = dataBatch.getChildAt(1)!.get(i) as string;
100
+ const _hasReturn = dataBatch.getChildAt(2)!.get(i) as boolean;
101
+ const paramsIpc = dataBatch.getChildAt(3)!.get(i) as Uint8Array;
102
+ const resultIpc = dataBatch.getChildAt(4)!.get(i) as Uint8Array;
103
+ const hasHeader = dataBatch.getChildAt(5)!.get(i) as boolean;
104
+ const headerIpc = dataBatch.getChildAt(6)?.get(i) as Uint8Array | null;
105
+ // is_exchange (index 7) currently unused on the client side.
74
106
 
75
107
  const paramsSchema = await deserializeSchema(paramsIpc);
76
108
  const resultSchema = await deserializeSchema(resultIpc);
77
109
 
78
- let paramTypes: Record<string, string> | undefined;
79
- if (paramTypesJson) {
80
- try {
81
- paramTypes = JSON.parse(paramTypesJson);
82
- } catch {}
83
- }
84
-
85
- let defaults: Record<string, any> | undefined;
86
- if (paramDefaultsJson) {
87
- try {
88
- defaults = JSON.parse(paramDefaultsJson);
89
- } catch {}
90
- }
91
-
92
110
  const info: MethodInfo = {
93
111
  name,
94
112
  type: methodType as "unary" | "stream",
95
113
  paramsSchema,
96
114
  resultSchema,
97
- doc: doc ?? undefined,
98
- paramTypes,
99
- defaults,
100
115
  };
101
116
 
102
117
  // For stream methods, result_schema_ipc actually holds the output schema
@@ -111,7 +126,7 @@ export async function parseDescribeResponse(
111
126
  methods.push(info);
112
127
  }
113
128
 
114
- return { protocolName, methods };
129
+ return { protocolName, protocolVersion, methods };
115
130
  }
116
131
 
117
132
  /**
@@ -119,7 +134,13 @@ export async function parseDescribeResponse(
119
134
  */
120
135
  export async function httpIntrospect(
121
136
  baseUrl: string,
122
- options?: { prefix?: string; authorization?: string },
137
+ options?: {
138
+ prefix?: string;
139
+ authorization?: string;
140
+ compressionLevel?: number;
141
+ compressFn?: (data: Uint8Array, level: number) => Promise<Uint8Array>;
142
+ decompressFn?: (data: Uint8Array) => Promise<Uint8Array>;
143
+ },
123
144
  ): Promise<ServiceDescription> {
124
145
  const prefix = options?.prefix ?? "";
125
146
  const emptySchema = new ArrowSchema([]);
@@ -130,16 +151,31 @@ export async function httpIntrospect(
130
151
  headers.Authorization = options.authorization;
131
152
  }
132
153
 
154
+ const level = options?.compressionLevel;
155
+ const compressFn = options?.compressFn;
156
+ const decompressFn = options?.decompressFn;
157
+ let sendBody: Uint8Array = body;
158
+ if (level != null && compressFn) {
159
+ headers["Content-Encoding"] = "zstd";
160
+ sendBody = await compressFn(body, level);
161
+ }
162
+ if (level != null && decompressFn) {
163
+ headers["Accept-Encoding"] = "zstd";
164
+ }
165
+
133
166
  const response = await fetch(`${baseUrl}${prefix}/${DESCRIBE_METHOD_NAME}`, {
134
167
  method: "POST",
135
168
  headers,
136
- body: body as unknown as BodyInit,
169
+ body: sendBody as unknown as BodyInit,
137
170
  });
138
171
  if (response.status === 401) {
139
172
  throw new RpcError("AuthenticationError", "Authentication required", "");
140
173
  }
141
174
 
142
- const responseBody = new Uint8Array(await response.arrayBuffer());
175
+ let responseBody = new Uint8Array(await response.arrayBuffer());
176
+ if (response.headers.get("Content-Encoding") === "zstd" && decompressFn) {
177
+ responseBody = new Uint8Array(await decompressFn(responseBody));
178
+ }
143
179
  const { batches } = await readResponseBatches(responseBody);
144
180
 
145
181
  return parseDescribeResponse(batches);