@query-farm/vgi-rpc 0.6.4 → 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 +66 -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 -3511
  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 +790 -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 +67 -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
@@ -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
+ }
@@ -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,9 +21,10 @@ 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
 
23
29
  export interface RpcClient {
24
30
  call(method: string, params?: Record<string, any>): Promise<Record<string, any> | null>;
@@ -35,13 +41,73 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
35
41
  const externalConfig = options?.externalLocation;
36
42
 
37
43
  let methodCache: Map<string, MethodInfo> | null = null;
44
+ /** Application protocol surface version discovered via __describe__. When
45
+ * non-empty, the client emits it on every request as
46
+ * `vgi_rpc.protocol_version` so a versioned server can validate at the
47
+ * dispatch boundary. */
48
+ let serverProtocolVersion = "";
38
49
  let compressFn: CompressFn | undefined;
39
50
  let decompressFn: DecompressFn | undefined;
40
51
  let compressionLoaded = false;
52
+ let capabilities: HttpServerCapabilities | null = null;
53
+
54
+ function updateCapabilitiesFromResponse(resp: Response): void {
55
+ const next = parseCapabilitiesFromHeaders(resp.headers);
56
+ // Only treat the snapshot as authoritative when the server actually
57
+ // emitted capability hints. Otherwise leave any prior cache in place.
58
+ if (next.maxRequestBytes != null || next.uploadUrlSupport) {
59
+ capabilities = next;
60
+ }
61
+ }
62
+
63
+ async function maybeExternalize(body: Uint8Array): Promise<Uint8Array> {
64
+ const caps = isCapabilitySnapshotFresh(capabilities) ? capabilities : null;
65
+ if (!caps) return body;
66
+ if (!caps.uploadUrlSupport) return body;
67
+ if (caps.maxRequestBytes == null || body.byteLength <= caps.maxRequestBytes) return body;
68
+ return externalizeRequestBody(body, {
69
+ baseUrl,
70
+ prefix,
71
+ authorization,
72
+ urlValidator: externalConfig?.urlValidator ?? null,
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Send a POST request, transparently retrying with externalization if
78
+ * the server returns 413 (Payload Too Large) and advertises upload-URL
79
+ * support. Mirrors Python's 413 fallback in `_HttpProxy._post_with_externalization`.
80
+ */
81
+ async function postWithExternalization(url: string, body: Uint8Array): Promise<Response> {
82
+ const sendBody = await maybeExternalize(body);
83
+ let resp = await fetch(url, {
84
+ method: "POST",
85
+ headers: buildHeaders(),
86
+ body: (await prepareBody(sendBody)) as unknown as BodyInit,
87
+ });
88
+ updateCapabilitiesFromResponse(resp);
89
+
90
+ if (resp.status === 413 && capabilities?.uploadUrlSupport && body.byteLength > 0) {
91
+ // Refresh-and-retry: caps tell us we can externalize.
92
+ const externalized = await externalizeRequestBody(body, {
93
+ baseUrl,
94
+ prefix,
95
+ authorization,
96
+ urlValidator: externalConfig?.urlValidator ?? null,
97
+ });
98
+ resp = await fetch(url, {
99
+ method: "POST",
100
+ headers: buildHeaders(),
101
+ body: (await prepareBody(externalized)) as unknown as BodyInit,
102
+ });
103
+ updateCapabilitiesFromResponse(resp);
104
+ }
105
+
106
+ return resp;
107
+ }
41
108
 
42
109
  async function ensureCompression(): Promise<void> {
43
110
  if (compressionLoaded || compressionLevel == null) return;
44
- compressionLoaded = true;
45
111
  try {
46
112
  const mod = await import("../util/zstd.js");
47
113
  compressFn = mod.zstdCompress;
@@ -49,14 +115,17 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
49
115
  } catch {
50
116
  // zstd not available in this runtime
51
117
  }
118
+ compressionLoaded = true;
52
119
  }
53
120
 
54
121
  function buildHeaders(): Record<string, string> {
55
122
  const headers: Record<string, string> = {
56
123
  "Content-Type": ARROW_CONTENT_TYPE,
57
124
  };
58
- if (compressionLevel != null) {
125
+ if (compressionLevel != null && compressFn) {
59
126
  headers["Content-Encoding"] = "zstd";
127
+ }
128
+ if (compressionLevel != null && decompressFn) {
60
129
  headers["Accept-Encoding"] = "zstd";
61
130
  }
62
131
  if (authorization) {
@@ -65,9 +134,9 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
65
134
  return headers;
66
135
  }
67
136
 
68
- function prepareBody(content: Uint8Array): Uint8Array {
137
+ async function prepareBody(content: Uint8Array): Promise<Uint8Array> {
69
138
  if (compressionLevel != null && compressFn) {
70
- return compressFn(content, compressionLevel);
139
+ return await compressFn(content, compressionLevel);
71
140
  }
72
141
  return content;
73
142
  }
@@ -81,15 +150,23 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
81
150
  async function readResponse(resp: Response): Promise<Uint8Array<ArrayBuffer>> {
82
151
  let body = new Uint8Array(await resp.arrayBuffer());
83
152
  if (resp.headers.get("Content-Encoding") === "zstd" && decompressFn) {
84
- body = new Uint8Array(decompressFn(body));
153
+ body = new Uint8Array(await decompressFn(body));
85
154
  }
86
155
  return body;
87
156
  }
88
157
 
89
158
  async function ensureMethodCache(): Promise<Map<string, MethodInfo>> {
90
159
  if (methodCache) return methodCache;
91
- const desc = await httpIntrospect(baseUrl, { prefix, authorization });
160
+ await ensureCompression();
161
+ const desc = await httpIntrospect(baseUrl, {
162
+ prefix,
163
+ authorization,
164
+ compressionLevel,
165
+ compressFn,
166
+ decompressFn,
167
+ });
92
168
  methodCache = new Map(desc.methods.map((m) => [m.name, m]));
169
+ serverProtocolVersion = desc.protocolVersion;
93
170
  return methodCache;
94
171
  }
95
172
 
@@ -105,12 +182,8 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
105
182
  // Apply defaults
106
183
  const fullParams = { ...(info.defaults ?? {}), ...(params ?? {}) };
107
184
 
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
- });
185
+ const body = buildRequestIpc(info.paramsSchema, fullParams, method, { protocolVersion: serverProtocolVersion });
186
+ const resp = await postWithExternalization(`${baseUrl}${prefix}/${method}`, body);
114
187
  checkAuth(resp);
115
188
 
116
189
  const responseBody = await readResponse(resp);
@@ -121,8 +194,8 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
121
194
  for (let batch of batches) {
122
195
  if (batch.numRows === 0) {
123
196
  // Check for external location pointer batch
124
- if (isExternalLocationBatch(batch)) {
125
- batch = await resolveExternalLocation(batch, externalConfig);
197
+ if (isExternalLocationBatch(batch as any)) {
198
+ batch = (await resolveExternalLocation(batch as any, externalConfig)) as any;
126
199
  } else {
127
200
  dispatchLogOrError(batch, onLog);
128
201
  continue;
@@ -159,12 +232,8 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
159
232
  // Apply defaults
160
233
  const fullParams = { ...(info.defaults ?? {}), ...(params ?? {}) };
161
234
 
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
- });
235
+ const body = buildRequestIpc(info.paramsSchema, fullParams, method, { protocolVersion: serverProtocolVersion });
236
+ const resp = await postWithExternalization(`${baseUrl}${prefix}/${method}/init`, body);
168
237
  checkAuth(resp);
169
238
 
170
239
  const responseBody = await readResponse(resp);
@@ -185,7 +254,7 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
185
254
  // First stream: header
186
255
  const headerStream = await reader.readStream();
187
256
  if (headerStream) {
188
- for (const batch of headerStream.batches) {
257
+ for (const batch of headerStream.batches as any[]) {
189
258
  if (batch.numRows === 0) {
190
259
  dispatchLogOrError(batch, onLog);
191
260
  continue;
@@ -200,11 +269,11 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
200
269
  // Second stream: data/state
201
270
  const dataStream = await reader.readStream();
202
271
  if (dataStream) {
203
- streamSchema = dataStream.schema;
272
+ streamSchema = dataStream.schema as any;
204
273
  }
205
274
  const headerErrorBatches: RecordBatch[] = [];
206
275
  if (dataStream) {
207
- for (const batch of dataStream.batches) {
276
+ for (const batch of dataStream.batches as any[]) {
208
277
  if (batch.numRows === 0) {
209
278
  // Check for state token
210
279
  const token = batch.metadata?.get(STATE_KEY);
@@ -310,11 +379,19 @@ export function httpConnect(baseUrl: string, options?: HttpConnectOptions): RpcC
310
379
  decompressFn,
311
380
  authorization,
312
381
  externalConfig,
382
+ postFn: postWithExternalization,
313
383
  });
314
384
  },
315
385
 
316
386
  async describe(): Promise<ServiceDescription> {
317
- return httpIntrospect(baseUrl, { prefix });
387
+ await ensureCompression();
388
+ return httpIntrospect(baseUrl, {
389
+ prefix,
390
+ authorization,
391
+ compressionLevel,
392
+ compressFn,
393
+ decompressFn,
394
+ });
318
395
  },
319
396
 
320
397
  close(): void {
@@ -1,8 +1,9 @@
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";
@@ -23,14 +24,29 @@ export interface MethodInfo {
23
24
 
24
25
  export interface ServiceDescription {
25
26
  protocolName: string;
27
+ /** Application protocol surface version surfaced by the server's
28
+ * __describe__ response. Empty string when the server did not declare
29
+ * a `protocolVersion`. */
30
+ protocolVersion: string;
26
31
  methods: MethodInfo[];
27
32
  }
28
33
 
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!;
34
+ /**
35
+ * Deserialize a schema from IPC bytes (schema message + EOS).
36
+ *
37
+ * Must dispatch via `#vgi-rpc-arrow` so the resulting type instances are
38
+ * the same impl (apache-arrow / flechette) as the rest of the active
39
+ * backend. Using apache-arrow's `RecordBatchReader` directly here used to
40
+ * silently mix impls: in browser builds the backend is flechette, and a
41
+ * flechette builder receiving an apache-arrow `Binary` type defaults to
42
+ * the wrong offsets buffer (Uint8Array instead of Int32Array) and emits
43
+ * a 0-byte value where a populated binary column was expected. The
44
+ * downstream symptom is "Tried reading schema message, was null or
45
+ * length 0" from the server when it tries to open the (empty) binary
46
+ * column as a nested IPC stream. See test/client/ipc-cross-impl.test.ts.
47
+ */
48
+ function deserializeSchema(bytes: Uint8Array): Schema {
49
+ return deserializeSchemaImpl(bytes) as unknown as Schema;
34
50
  }
35
51
 
36
52
  /**
@@ -58,45 +74,30 @@ export async function parseDescribeResponse(
58
74
  // Extract metadata from batch
59
75
  const meta = dataBatch.metadata;
60
76
  const protocolName = meta?.get(PROTOCOL_NAME_KEY) ?? "";
77
+ const protocolVersion = meta?.get(PROTOCOL_VERSION_KEY) ?? "";
61
78
 
79
+ // Slim DESCRIBE_VERSION 4 wire format (see dispatch/describe.ts):
80
+ // 0:name 1:method_type 2:has_return 3:params_schema_ipc
81
+ // 4:result_schema_ipc 5:has_header 6:header_schema_ipc 7:is_exchange
62
82
  const methods: MethodInfo[] = [];
63
83
  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
84
+ const name = dataBatch.getChildAt(0)!.get(i) as string;
85
+ const methodType = dataBatch.getChildAt(1)!.get(i) as string;
86
+ const _hasReturn = dataBatch.getChildAt(2)!.get(i) as boolean;
87
+ const paramsIpc = dataBatch.getChildAt(3)!.get(i) as Uint8Array;
88
+ const resultIpc = dataBatch.getChildAt(4)!.get(i) as Uint8Array;
89
+ const hasHeader = dataBatch.getChildAt(5)!.get(i) as boolean;
90
+ const headerIpc = dataBatch.getChildAt(6)?.get(i) as Uint8Array | null;
91
+ // is_exchange (index 7) currently unused on the client side.
74
92
 
75
93
  const paramsSchema = await deserializeSchema(paramsIpc);
76
94
  const resultSchema = await deserializeSchema(resultIpc);
77
95
 
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
96
  const info: MethodInfo = {
93
97
  name,
94
98
  type: methodType as "unary" | "stream",
95
99
  paramsSchema,
96
100
  resultSchema,
97
- doc: doc ?? undefined,
98
- paramTypes,
99
- defaults,
100
101
  };
101
102
 
102
103
  // For stream methods, result_schema_ipc actually holds the output schema
@@ -111,7 +112,7 @@ export async function parseDescribeResponse(
111
112
  methods.push(info);
112
113
  }
113
114
 
114
- return { protocolName, methods };
115
+ return { protocolName, protocolVersion, methods };
115
116
  }
116
117
 
117
118
  /**
@@ -119,7 +120,13 @@ export async function parseDescribeResponse(
119
120
  */
120
121
  export async function httpIntrospect(
121
122
  baseUrl: string,
122
- options?: { prefix?: string; authorization?: string },
123
+ options?: {
124
+ prefix?: string;
125
+ authorization?: string;
126
+ compressionLevel?: number;
127
+ compressFn?: (data: Uint8Array, level: number) => Promise<Uint8Array>;
128
+ decompressFn?: (data: Uint8Array) => Promise<Uint8Array>;
129
+ },
123
130
  ): Promise<ServiceDescription> {
124
131
  const prefix = options?.prefix ?? "";
125
132
  const emptySchema = new ArrowSchema([]);
@@ -130,16 +137,31 @@ export async function httpIntrospect(
130
137
  headers.Authorization = options.authorization;
131
138
  }
132
139
 
140
+ const level = options?.compressionLevel;
141
+ const compressFn = options?.compressFn;
142
+ const decompressFn = options?.decompressFn;
143
+ let sendBody: Uint8Array = body;
144
+ if (level != null && compressFn) {
145
+ headers["Content-Encoding"] = "zstd";
146
+ sendBody = await compressFn(body, level);
147
+ }
148
+ if (level != null && decompressFn) {
149
+ headers["Accept-Encoding"] = "zstd";
150
+ }
151
+
133
152
  const response = await fetch(`${baseUrl}${prefix}/${DESCRIBE_METHOD_NAME}`, {
134
153
  method: "POST",
135
154
  headers,
136
- body: body as unknown as BodyInit,
155
+ body: sendBody as unknown as BodyInit,
137
156
  });
138
157
  if (response.status === 401) {
139
158
  throw new RpcError("AuthenticationError", "Authentication required", "");
140
159
  }
141
160
 
142
- const responseBody = new Uint8Array(await response.arrayBuffer());
161
+ let responseBody = new Uint8Array(await response.arrayBuffer());
162
+ if (response.headers.get("Content-Encoding") === "zstd" && decompressFn) {
163
+ responseBody = new Uint8Array(await decompressFn(responseBody));
164
+ }
143
165
  const { batches } = await readResponseBatches(responseBody);
144
166
 
145
167
  return parseDescribeResponse(batches);