@query-farm/vgi-rpc 0.1.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 (92) hide show
  1. package/LICENSE.md +191 -0
  2. package/README.md +332 -0
  3. package/dist/client/connect.d.ts +10 -0
  4. package/dist/client/connect.d.ts.map +1 -0
  5. package/dist/client/index.d.ts +6 -0
  6. package/dist/client/index.d.ts.map +1 -0
  7. package/dist/client/introspect.d.ts +30 -0
  8. package/dist/client/introspect.d.ts.map +1 -0
  9. package/dist/client/ipc.d.ts +34 -0
  10. package/dist/client/ipc.d.ts.map +1 -0
  11. package/dist/client/pipe.d.ts +63 -0
  12. package/dist/client/pipe.d.ts.map +1 -0
  13. package/dist/client/stream.d.ts +52 -0
  14. package/dist/client/stream.d.ts.map +1 -0
  15. package/dist/client/types.d.ts +25 -0
  16. package/dist/client/types.d.ts.map +1 -0
  17. package/dist/constants.d.ts +15 -0
  18. package/dist/constants.d.ts.map +1 -0
  19. package/dist/dispatch/describe.d.ts +14 -0
  20. package/dist/dispatch/describe.d.ts.map +1 -0
  21. package/dist/dispatch/stream.d.ts +20 -0
  22. package/dist/dispatch/stream.d.ts.map +1 -0
  23. package/dist/dispatch/unary.d.ts +9 -0
  24. package/dist/dispatch/unary.d.ts.map +1 -0
  25. package/dist/errors.d.ts +12 -0
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/http/common.d.ts +16 -0
  28. package/dist/http/common.d.ts.map +1 -0
  29. package/dist/http/dispatch.d.ts +18 -0
  30. package/dist/http/dispatch.d.ts.map +1 -0
  31. package/dist/http/handler.d.ts +16 -0
  32. package/dist/http/handler.d.ts.map +1 -0
  33. package/dist/http/index.d.ts +4 -0
  34. package/dist/http/index.d.ts.map +1 -0
  35. package/dist/http/token.d.ts +24 -0
  36. package/dist/http/token.d.ts.map +1 -0
  37. package/dist/http/types.d.ts +30 -0
  38. package/dist/http/types.d.ts.map +1 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +2493 -0
  42. package/dist/index.js.map +34 -0
  43. package/dist/protocol.d.ts +62 -0
  44. package/dist/protocol.d.ts.map +1 -0
  45. package/dist/schema.d.ts +38 -0
  46. package/dist/schema.d.ts.map +1 -0
  47. package/dist/server.d.ts +19 -0
  48. package/dist/server.d.ts.map +1 -0
  49. package/dist/types.d.ts +71 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/util/schema.d.ts +20 -0
  52. package/dist/util/schema.d.ts.map +1 -0
  53. package/dist/util/zstd.d.ts +5 -0
  54. package/dist/util/zstd.d.ts.map +1 -0
  55. package/dist/wire/reader.d.ts +40 -0
  56. package/dist/wire/reader.d.ts.map +1 -0
  57. package/dist/wire/request.d.ts +15 -0
  58. package/dist/wire/request.d.ts.map +1 -0
  59. package/dist/wire/response.d.ts +25 -0
  60. package/dist/wire/response.d.ts.map +1 -0
  61. package/dist/wire/writer.d.ts +59 -0
  62. package/dist/wire/writer.d.ts.map +1 -0
  63. package/package.json +32 -0
  64. package/src/client/connect.ts +310 -0
  65. package/src/client/index.ts +14 -0
  66. package/src/client/introspect.ts +138 -0
  67. package/src/client/ipc.ts +225 -0
  68. package/src/client/pipe.ts +661 -0
  69. package/src/client/stream.ts +297 -0
  70. package/src/client/types.ts +31 -0
  71. package/src/constants.ts +22 -0
  72. package/src/dispatch/describe.ts +155 -0
  73. package/src/dispatch/stream.ts +151 -0
  74. package/src/dispatch/unary.ts +35 -0
  75. package/src/errors.ts +22 -0
  76. package/src/http/common.ts +89 -0
  77. package/src/http/dispatch.ts +340 -0
  78. package/src/http/handler.ts +247 -0
  79. package/src/http/index.ts +6 -0
  80. package/src/http/token.ts +149 -0
  81. package/src/http/types.ts +49 -0
  82. package/src/index.ts +52 -0
  83. package/src/protocol.ts +144 -0
  84. package/src/schema.ts +114 -0
  85. package/src/server.ts +159 -0
  86. package/src/types.ts +162 -0
  87. package/src/util/schema.ts +31 -0
  88. package/src/util/zstd.ts +49 -0
  89. package/src/wire/reader.ts +113 -0
  90. package/src/wire/request.ts +98 -0
  91. package/src/wire/response.ts +181 -0
  92. package/src/wire/writer.ts +137 -0
package/src/errors.ts ADDED
@@ -0,0 +1,22 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /** Error thrown when the server encounters an RPC protocol error. */
5
+ export class RpcError extends Error {
6
+ constructor(
7
+ public readonly errorType: string,
8
+ public readonly errorMessage: string,
9
+ public readonly remoteTraceback: string,
10
+ ) {
11
+ super(`${errorType}: ${errorMessage}`);
12
+ this.name = "RpcError";
13
+ }
14
+ }
15
+
16
+ /** Error thrown when the client sends an unsupported request version. */
17
+ export class VersionError extends Error {
18
+ constructor(message: string) {
19
+ super(message);
20
+ this.name = "VersionError";
21
+ }
22
+ }
@@ -0,0 +1,89 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import {
5
+ RecordBatchStreamWriter,
6
+ RecordBatchReader,
7
+ RecordBatch,
8
+ Schema,
9
+ Struct,
10
+ makeData,
11
+ } from "apache-arrow";
12
+
13
+ export const ARROW_CONTENT_TYPE = "application/vnd.apache.arrow.stream";
14
+
15
+ export class HttpRpcError extends Error {
16
+ constructor(
17
+ message: string,
18
+ public readonly statusCode: number,
19
+ ) {
20
+ super(message);
21
+ this.name = "HttpRpcError";
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Rebuild a batch's data to match the given schema's field types.
27
+ *
28
+ * Batches deserialized from IPC streams (e.g., from PyArrow) may use generic
29
+ * types (Float) instead of specific ones (Float64). Arrow-JS's
30
+ * RecordBatchStreamWriter silently drops batches whose child Data types don't
31
+ * match the writer's schema. Cloning each child Data with the schema's field
32
+ * type fixes the type metadata while preserving the underlying buffers.
33
+ */
34
+ function conformBatchToSchema(
35
+ batch: RecordBatch,
36
+ schema: Schema,
37
+ ): RecordBatch {
38
+ if (batch.numRows === 0) return batch;
39
+ const children = schema.fields.map((f, i) =>
40
+ batch.data.children[i].clone(f.type),
41
+ );
42
+ const structType = new Struct(schema.fields);
43
+ const data = makeData({
44
+ type: structType,
45
+ length: batch.numRows,
46
+ children,
47
+ nullCount: batch.data.nullCount,
48
+ nullBitmap: batch.data.nullBitmap,
49
+ });
50
+ return new RecordBatch(schema, data, batch.metadata);
51
+ }
52
+
53
+ /** Serialize a schema + batches into a complete IPC stream as Uint8Array. */
54
+ export function serializeIpcStream(
55
+ schema: Schema,
56
+ batches: RecordBatch[],
57
+ ): Uint8Array {
58
+ const writer = new RecordBatchStreamWriter();
59
+ writer.reset(undefined, schema);
60
+ for (const batch of batches) {
61
+ writer.write(conformBatchToSchema(batch, schema));
62
+ }
63
+ writer.close();
64
+ return writer.toUint8Array(true);
65
+ }
66
+
67
+ /** Create a Response with Arrow IPC content type. Casts Uint8Array for TS lib compat. */
68
+ export function arrowResponse(body: Uint8Array, status = 200, extraHeaders?: Headers): Response {
69
+ const headers = extraHeaders ?? new Headers();
70
+ headers.set("Content-Type", ARROW_CONTENT_TYPE);
71
+ return new Response(body as unknown as BodyInit, { status, headers });
72
+ }
73
+
74
+ /** Read schema + first batch from an IPC stream body. */
75
+ export async function readRequestFromBody(
76
+ body: Uint8Array,
77
+ ): Promise<{ schema: Schema; batch: RecordBatch }> {
78
+ const reader = await RecordBatchReader.from(body);
79
+ await reader.open();
80
+ const schema = reader.schema;
81
+ if (!schema) {
82
+ throw new HttpRpcError("Empty IPC stream: no schema", 400);
83
+ }
84
+ const batches = reader.readAll();
85
+ if (batches.length === 0) {
86
+ throw new HttpRpcError("IPC stream contains no batches", 400);
87
+ }
88
+ return { schema, batch: batches[0] };
89
+ }
@@ -0,0 +1,340 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Schema, RecordBatch } from "apache-arrow";
5
+ import type { MethodDefinition } from "../types.js";
6
+ import { OutputCollector } from "../types.js";
7
+ import { parseRequest } from "../wire/request.js";
8
+ import {
9
+ buildResultBatch,
10
+ buildErrorBatch,
11
+ buildEmptyBatch,
12
+ } from "../wire/response.js";
13
+ import { buildDescribeBatch, DESCRIBE_SCHEMA } from "../dispatch/describe.js";
14
+ import { STATE_KEY } from "../constants.js";
15
+ import { serializeSchema } from "../util/schema.js";
16
+ import {
17
+ HttpRpcError,
18
+ serializeIpcStream,
19
+ readRequestFromBody,
20
+ arrowResponse,
21
+ } from "./common.js";
22
+ import { packStateToken, unpackStateToken } from "./token.js";
23
+ import type { StateSerializer } from "./types.js";
24
+
25
+ const EMPTY_SCHEMA = new Schema([]);
26
+
27
+ export interface DispatchContext {
28
+ signingKey: Uint8Array;
29
+ tokenTtl: number;
30
+ serverId: string;
31
+ maxStreamResponseBytes?: number;
32
+ stateSerializer: StateSerializer;
33
+ }
34
+
35
+ /** Dispatch a __describe__ request. */
36
+ export function httpDispatchDescribe(
37
+ protocolName: string,
38
+ methods: Map<string, MethodDefinition>,
39
+ serverId: string,
40
+ ): Response {
41
+ const { batch } = buildDescribeBatch(protocolName, methods, serverId);
42
+ const body = serializeIpcStream(DESCRIBE_SCHEMA, [batch]);
43
+ return arrowResponse(body);
44
+ }
45
+
46
+ /** Dispatch a unary HTTP request. */
47
+ export async function httpDispatchUnary(
48
+ method: MethodDefinition,
49
+ body: Uint8Array,
50
+ ctx: DispatchContext,
51
+ ): Promise<Response> {
52
+ const schema = method.resultSchema;
53
+ const { schema: reqSchema, batch: reqBatch } = await readRequestFromBody(body);
54
+ const parsed = parseRequest(reqSchema, reqBatch);
55
+
56
+ if (parsed.methodName !== method.name) {
57
+ throw new HttpRpcError(
58
+ `Method name in request '${parsed.methodName}' does not match URL '${method.name}'`,
59
+ 400,
60
+ );
61
+ }
62
+
63
+ const out = new OutputCollector(schema, true, ctx.serverId, parsed.requestId);
64
+
65
+ try {
66
+ const result = await method.handler!(parsed.params, out);
67
+ const resultBatch = buildResultBatch(schema, result, ctx.serverId, parsed.requestId);
68
+ const batches = [...out.batches.map((b) => b.batch), resultBatch];
69
+ return arrowResponse(serializeIpcStream(schema, batches));
70
+ } catch (error: any) {
71
+ const errBatch = buildErrorBatch(schema, error, ctx.serverId, parsed.requestId);
72
+ return arrowResponse(serializeIpcStream(schema, [errBatch]), 500);
73
+ }
74
+ }
75
+
76
+ /** Dispatch a stream init HTTP request (producer or exchange). */
77
+ export async function httpDispatchStreamInit(
78
+ method: MethodDefinition,
79
+ body: Uint8Array,
80
+ ctx: DispatchContext,
81
+ ): Promise<Response> {
82
+ const isProducer =
83
+ !method.inputSchema || method.inputSchema.fields.length === 0;
84
+ const outputSchema = method.outputSchema!;
85
+ const inputSchema = method.inputSchema ?? EMPTY_SCHEMA;
86
+
87
+ const { schema: reqSchema, batch: reqBatch } = await readRequestFromBody(body);
88
+ const parsed = parseRequest(reqSchema, reqBatch);
89
+
90
+ if (parsed.methodName !== method.name) {
91
+ throw new HttpRpcError(
92
+ `Method name in request '${parsed.methodName}' does not match URL '${method.name}'`,
93
+ 400,
94
+ );
95
+ }
96
+
97
+ // Init state
98
+ let state: any;
99
+ try {
100
+ if (isProducer) {
101
+ state = await method.producerInit!(parsed.params);
102
+ } else {
103
+ state = await method.exchangeInit!(parsed.params);
104
+ }
105
+ } catch (error: any) {
106
+ const errSchema = method.headerSchema ?? EMPTY_SCHEMA;
107
+ const errBatch = buildErrorBatch(errSchema, error, ctx.serverId, parsed.requestId);
108
+ return arrowResponse(serializeIpcStream(errSchema, [errBatch]), 500);
109
+ }
110
+
111
+ // Support dynamic output schemas (same as pipe transport)
112
+ const resolvedOutputSchema = state?.__outputSchema ?? outputSchema;
113
+ const effectiveProducer = state?.__isProducer ?? isProducer;
114
+
115
+ // Build header IPC stream if method has a header schema
116
+ let headerBytes: Uint8Array | null = null;
117
+ if (method.headerSchema && method.headerInit) {
118
+ try {
119
+ const headerOut = new OutputCollector(
120
+ method.headerSchema,
121
+ true,
122
+ ctx.serverId,
123
+ parsed.requestId,
124
+ );
125
+ const headerValues = method.headerInit(parsed.params, state, headerOut);
126
+ const headerBatch = buildResultBatch(
127
+ method.headerSchema,
128
+ headerValues,
129
+ ctx.serverId,
130
+ parsed.requestId,
131
+ );
132
+ const headerBatches = [
133
+ ...headerOut.batches.map((b) => b.batch),
134
+ headerBatch,
135
+ ];
136
+ headerBytes = serializeIpcStream(method.headerSchema, headerBatches);
137
+ } catch (error: any) {
138
+ const errBatch = buildErrorBatch(
139
+ method.headerSchema,
140
+ error,
141
+ ctx.serverId,
142
+ parsed.requestId,
143
+ );
144
+ return arrowResponse(serializeIpcStream(method.headerSchema, [errBatch]), 500);
145
+ }
146
+ }
147
+
148
+ if (effectiveProducer) {
149
+ return produceStreamResponse(
150
+ method,
151
+ state,
152
+ resolvedOutputSchema,
153
+ inputSchema,
154
+ ctx,
155
+ parsed.requestId,
156
+ headerBytes,
157
+ );
158
+ } else {
159
+ // Exchange: serialize state into signed token, return zero-row batch with token
160
+ const stateBytes = ctx.stateSerializer.serialize(state);
161
+ const schemaBytes = serializeSchema(resolvedOutputSchema);
162
+ const inputSchemaBytes = serializeSchema(inputSchema);
163
+ const token = packStateToken(
164
+ stateBytes,
165
+ schemaBytes,
166
+ inputSchemaBytes,
167
+ ctx.signingKey,
168
+ );
169
+
170
+ const tokenMeta = new Map<string, string>();
171
+ tokenMeta.set(STATE_KEY, token);
172
+ const tokenBatch = buildEmptyBatch(resolvedOutputSchema, tokenMeta);
173
+ const tokenStreamBytes = serializeIpcStream(resolvedOutputSchema, [tokenBatch]);
174
+
175
+ let responseBody: Uint8Array;
176
+ if (headerBytes) {
177
+ responseBody = concatBytes(headerBytes, tokenStreamBytes);
178
+ } else {
179
+ responseBody = tokenStreamBytes;
180
+ }
181
+
182
+ return arrowResponse(responseBody);
183
+ }
184
+ }
185
+
186
+ /** Dispatch a stream exchange HTTP request (producer continuation or exchange round). */
187
+ export async function httpDispatchStreamExchange(
188
+ method: MethodDefinition,
189
+ body: Uint8Array,
190
+ ctx: DispatchContext,
191
+ ): Promise<Response> {
192
+ const isProducer =
193
+ !method.inputSchema || method.inputSchema.fields.length === 0;
194
+
195
+ const { batch: reqBatch } = await readRequestFromBody(body);
196
+
197
+ // Get state token from batch metadata
198
+ const tokenBase64 = reqBatch.metadata?.get(STATE_KEY);
199
+ if (!tokenBase64) {
200
+ throw new HttpRpcError("Missing state token in exchange request", 400);
201
+ }
202
+
203
+ let unpacked;
204
+ try {
205
+ unpacked = unpackStateToken(tokenBase64, ctx.signingKey, ctx.tokenTtl);
206
+ } catch (error: any) {
207
+ throw new HttpRpcError(`Invalid state token: ${error.message}`, 400);
208
+ }
209
+
210
+ const state = ctx.stateSerializer.deserialize(unpacked.stateBytes);
211
+
212
+ // Support dynamic output schemas (same as pipe transport)
213
+ const outputSchema = state?.__outputSchema ?? method.outputSchema!;
214
+ const inputSchema = method.inputSchema ?? EMPTY_SCHEMA;
215
+ const effectiveProducer = state?.__isProducer ?? isProducer;
216
+
217
+ if (effectiveProducer) {
218
+ return produceStreamResponse(
219
+ method,
220
+ state,
221
+ outputSchema,
222
+ inputSchema,
223
+ ctx,
224
+ null,
225
+ null,
226
+ );
227
+ } else {
228
+ const out = new OutputCollector(outputSchema, false, ctx.serverId, null);
229
+
230
+ try {
231
+ await method.exchangeFn!(state, reqBatch, out);
232
+ } catch (error: any) {
233
+ const errBatch = buildErrorBatch(outputSchema, error, ctx.serverId, null);
234
+ return arrowResponse(serializeIpcStream(outputSchema, [errBatch]), 500);
235
+ }
236
+
237
+ // Repack updated state into new token
238
+ const stateBytes = ctx.stateSerializer.serialize(state);
239
+ const schemaBytes = serializeSchema(outputSchema);
240
+ const inputSchemaBytes = serializeSchema(inputSchema);
241
+ const token = packStateToken(
242
+ stateBytes,
243
+ schemaBytes,
244
+ inputSchemaBytes,
245
+ ctx.signingKey,
246
+ );
247
+
248
+ // Merge token into the data batch's metadata (matching Python behavior).
249
+ // The Python client expects the token on the data batch itself, not a
250
+ // separate zero-row batch.
251
+ const batches: RecordBatch[] = [];
252
+ for (const emitted of out.batches) {
253
+ const batch = emitted.batch;
254
+ if (batch.numRows > 0) {
255
+ // This is the data batch — merge token into its metadata
256
+ const mergedMeta = new Map<string, string>(batch.metadata ?? []);
257
+ mergedMeta.set(STATE_KEY, token);
258
+ batches.push(new RecordBatch(batch.schema, batch.data, mergedMeta));
259
+ } else {
260
+ batches.push(batch);
261
+ }
262
+ }
263
+
264
+ return arrowResponse(serializeIpcStream(outputSchema, batches));
265
+ }
266
+ }
267
+
268
+ /** Run the producer loop and build the response. */
269
+ async function produceStreamResponse(
270
+ method: MethodDefinition,
271
+ state: any,
272
+ outputSchema: Schema,
273
+ inputSchema: Schema,
274
+ ctx: DispatchContext,
275
+ requestId: string | null,
276
+ headerBytes: Uint8Array | null,
277
+ ): Promise<Response> {
278
+ const allBatches: RecordBatch[] = [];
279
+ const maxBytes = ctx.maxStreamResponseBytes;
280
+ let estimatedBytes = 0;
281
+
282
+ while (true) {
283
+ const out = new OutputCollector(outputSchema, true, ctx.serverId, requestId);
284
+
285
+ try {
286
+ await method.producerFn!(state, out);
287
+ } catch (error: any) {
288
+ allBatches.push(buildErrorBatch(outputSchema, error, ctx.serverId, requestId));
289
+ break;
290
+ }
291
+
292
+ for (const emitted of out.batches) {
293
+ allBatches.push(emitted.batch);
294
+ if (maxBytes != null) {
295
+ estimatedBytes += emitted.batch.data.byteLength;
296
+ }
297
+ }
298
+
299
+ if (out.finished) {
300
+ break;
301
+ }
302
+
303
+ // Check byte budget — if exceeded, emit continuation token
304
+ if (maxBytes != null && estimatedBytes >= maxBytes) {
305
+ const stateBytes = ctx.stateSerializer.serialize(state);
306
+ const schemaBytes = serializeSchema(outputSchema);
307
+ const inputSchemaBytes = serializeSchema(inputSchema);
308
+ const token = packStateToken(
309
+ stateBytes,
310
+ schemaBytes,
311
+ inputSchemaBytes,
312
+ ctx.signingKey,
313
+ );
314
+ const tokenMeta = new Map<string, string>();
315
+ tokenMeta.set(STATE_KEY, token);
316
+ allBatches.push(buildEmptyBatch(outputSchema, tokenMeta));
317
+ break;
318
+ }
319
+ }
320
+
321
+ const dataBytes = serializeIpcStream(outputSchema, allBatches);
322
+ let responseBody: Uint8Array;
323
+ if (headerBytes) {
324
+ responseBody = concatBytes(headerBytes, dataBytes);
325
+ } else {
326
+ responseBody = dataBytes;
327
+ }
328
+ return arrowResponse(responseBody);
329
+ }
330
+
331
+ function concatBytes(...arrays: Uint8Array[]): Uint8Array {
332
+ const totalLen = arrays.reduce((sum, a) => sum + a.length, 0);
333
+ const result = new Uint8Array(totalLen);
334
+ let offset = 0;
335
+ for (const arr of arrays) {
336
+ result.set(arr, offset);
337
+ offset += arr.length;
338
+ }
339
+ return result;
340
+ }
@@ -0,0 +1,247 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { Schema } from "apache-arrow";
5
+ import { randomBytes } from "node:crypto";
6
+ import type { Protocol } from "../protocol.js";
7
+ import { MethodType } from "../types.js";
8
+ import { DESCRIBE_METHOD_NAME } from "../constants.js";
9
+ import { buildErrorBatch } from "../wire/response.js";
10
+ import { jsonStateSerializer, type HttpHandlerOptions } from "./types.js";
11
+ import {
12
+ ARROW_CONTENT_TYPE,
13
+ HttpRpcError,
14
+ serializeIpcStream,
15
+ arrowResponse,
16
+ } from "./common.js";
17
+ import {
18
+ httpDispatchDescribe,
19
+ httpDispatchUnary,
20
+ httpDispatchStreamInit,
21
+ httpDispatchStreamExchange,
22
+ } from "./dispatch.js";
23
+ import { zstdCompress, zstdDecompress } from "../util/zstd.js";
24
+
25
+ const EMPTY_SCHEMA = new Schema([]);
26
+
27
+ /**
28
+ * Create a fetch-compatible HTTP handler for a vgi-rpc Protocol.
29
+ *
30
+ * Compatible with Bun.serve(), Deno.serve(), Cloudflare Workers, and any
31
+ * Web API runtime that uses the standard Request/Response types.
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * const handler = createHttpHandler(protocol);
36
+ * Bun.serve({ port: 8080, fetch: handler });
37
+ * ```
38
+ */
39
+ export function createHttpHandler(
40
+ protocol: Protocol,
41
+ options?: HttpHandlerOptions,
42
+ ): (request: Request) => Response | Promise<Response> {
43
+ const prefix = (options?.prefix ?? "/vgi").replace(/\/+$/, "");
44
+ const signingKey = options?.signingKey ?? randomBytes(32);
45
+ const tokenTtl = options?.tokenTtl ?? 3600;
46
+ const corsOrigins = options?.corsOrigins;
47
+ const maxRequestBytes = options?.maxRequestBytes;
48
+ const maxStreamResponseBytes = options?.maxStreamResponseBytes;
49
+ const serverId =
50
+ options?.serverId ?? crypto.randomUUID().replace(/-/g, "").slice(0, 12);
51
+
52
+ const methods = protocol.getMethods();
53
+
54
+ const compressionLevel = options?.compressionLevel;
55
+ const stateSerializer = options?.stateSerializer ?? jsonStateSerializer;
56
+
57
+ const ctx = {
58
+ signingKey,
59
+ tokenTtl,
60
+ serverId,
61
+ maxStreamResponseBytes,
62
+ stateSerializer,
63
+ };
64
+
65
+ function addCorsHeaders(headers: Headers): void {
66
+ if (corsOrigins) {
67
+ headers.set("Access-Control-Allow-Origin", corsOrigins);
68
+ headers.set("Access-Control-Allow-Methods", "POST, OPTIONS");
69
+ headers.set("Access-Control-Allow-Headers", "Content-Type");
70
+ }
71
+ }
72
+
73
+ async function compressIfAccepted(
74
+ response: Response,
75
+ clientAcceptsZstd: boolean,
76
+ ): Promise<Response> {
77
+ if (compressionLevel == null || !clientAcceptsZstd) return response;
78
+ const responseBody = new Uint8Array(await response.arrayBuffer());
79
+ const compressed = zstdCompress(responseBody, compressionLevel);
80
+ const headers = new Headers(response.headers);
81
+ headers.set("Content-Encoding", "zstd");
82
+ return new Response(compressed as unknown as BodyInit, {
83
+ status: response.status,
84
+ headers,
85
+ });
86
+ }
87
+
88
+ function makeErrorResponse(
89
+ error: Error,
90
+ statusCode: number,
91
+ schema: Schema = EMPTY_SCHEMA,
92
+ ): Response {
93
+ const errBatch = buildErrorBatch(schema, error, serverId, null);
94
+ const body = serializeIpcStream(schema, [errBatch]);
95
+ const resp = arrowResponse(body, statusCode);
96
+ addCorsHeaders(resp.headers);
97
+ return resp;
98
+ }
99
+
100
+ return async function handler(request: Request): Promise<Response> {
101
+ const url = new URL(request.url);
102
+ const path = url.pathname;
103
+
104
+ // CORS preflight
105
+ if (request.method === "OPTIONS") {
106
+ if (path === `${prefix}/__capabilities__`) {
107
+ const headers = new Headers();
108
+ addCorsHeaders(headers);
109
+ if (maxRequestBytes != null) {
110
+ headers.set("VGI-Max-Request-Bytes", String(maxRequestBytes));
111
+ }
112
+ return new Response(null, { status: 204, headers });
113
+ }
114
+
115
+ if (corsOrigins) {
116
+ const headers = new Headers();
117
+ addCorsHeaders(headers);
118
+ return new Response(null, { status: 204, headers });
119
+ }
120
+
121
+ return new Response(null, { status: 405 });
122
+ }
123
+
124
+ if (request.method !== "POST") {
125
+ return new Response("Method Not Allowed", { status: 405 });
126
+ }
127
+
128
+ // Validate Content-Type
129
+ const contentType = request.headers.get("Content-Type");
130
+ if (!contentType || !contentType.includes(ARROW_CONTENT_TYPE)) {
131
+ return new Response(
132
+ `Unsupported Media Type: expected ${ARROW_CONTENT_TYPE}`,
133
+ { status: 415 },
134
+ );
135
+ }
136
+
137
+ // Check request body size
138
+ if (maxRequestBytes != null) {
139
+ const contentLength = request.headers.get("Content-Length");
140
+ if (contentLength && parseInt(contentLength) > maxRequestBytes) {
141
+ return new Response("Request body too large", { status: 413 });
142
+ }
143
+ }
144
+
145
+ const clientAcceptsZstd = (
146
+ request.headers.get("Accept-Encoding") ?? ""
147
+ ).includes("zstd");
148
+
149
+ // Read body, decompressing if needed
150
+ let body = new Uint8Array(await request.arrayBuffer());
151
+ const contentEncoding = request.headers.get("Content-Encoding");
152
+ if (contentEncoding === "zstd") {
153
+ body = zstdDecompress(body);
154
+ }
155
+
156
+ // Route: {prefix}/__describe__
157
+ if (path === `${prefix}/${DESCRIBE_METHOD_NAME}`) {
158
+ try {
159
+ const response = httpDispatchDescribe(protocol.name, methods, serverId);
160
+ addCorsHeaders(response.headers);
161
+ return compressIfAccepted(response, clientAcceptsZstd);
162
+ } catch (error: any) {
163
+ return compressIfAccepted(
164
+ makeErrorResponse(error, 500),
165
+ clientAcceptsZstd,
166
+ );
167
+ }
168
+ }
169
+
170
+ // Parse method name and sub-path from URL
171
+ if (!path.startsWith(prefix + "/")) {
172
+ return new Response("Not Found", { status: 404 });
173
+ }
174
+
175
+ const subPath = path.slice(prefix.length + 1);
176
+ let methodName: string;
177
+ let action: "call" | "init" | "exchange";
178
+
179
+ if (subPath.endsWith("/init")) {
180
+ methodName = subPath.slice(0, -5);
181
+ action = "init";
182
+ } else if (subPath.endsWith("/exchange")) {
183
+ methodName = subPath.slice(0, -9);
184
+ action = "exchange";
185
+ } else {
186
+ methodName = subPath;
187
+ action = "call";
188
+ }
189
+
190
+ // Look up method
191
+ const method = methods.get(methodName);
192
+ if (!method) {
193
+ const available = [...methods.keys()].sort();
194
+ const err = new Error(
195
+ `Unknown method: '${methodName}'. Available methods: [${available.join(", ")}]`,
196
+ );
197
+ return compressIfAccepted(
198
+ makeErrorResponse(err, 404),
199
+ clientAcceptsZstd,
200
+ );
201
+ }
202
+
203
+ try {
204
+ let response: Response;
205
+
206
+ if (action === "call") {
207
+ if (method.type !== MethodType.UNARY) {
208
+ throw new HttpRpcError(
209
+ `Method '${methodName}' is a stream method. Use /init and /exchange endpoints.`,
210
+ 400,
211
+ );
212
+ }
213
+ response = await httpDispatchUnary(method, body, ctx);
214
+ } else if (action === "init") {
215
+ if (method.type !== MethodType.STREAM) {
216
+ throw new HttpRpcError(
217
+ `Method '${methodName}' is a unary method. Use POST ${prefix}/${methodName} instead.`,
218
+ 400,
219
+ );
220
+ }
221
+ response = await httpDispatchStreamInit(method, body, ctx);
222
+ } else {
223
+ if (method.type !== MethodType.STREAM) {
224
+ throw new HttpRpcError(
225
+ `Method '${methodName}' is a unary method. Use POST ${prefix}/${methodName} instead.`,
226
+ 400,
227
+ );
228
+ }
229
+ response = await httpDispatchStreamExchange(method, body, ctx);
230
+ }
231
+
232
+ addCorsHeaders(response.headers);
233
+ return compressIfAccepted(response, clientAcceptsZstd);
234
+ } catch (error: any) {
235
+ if (error instanceof HttpRpcError) {
236
+ return compressIfAccepted(
237
+ makeErrorResponse(error, error.statusCode),
238
+ clientAcceptsZstd,
239
+ );
240
+ }
241
+ return compressIfAccepted(
242
+ makeErrorResponse(error, 500),
243
+ clientAcceptsZstd,
244
+ );
245
+ }
246
+ };
247
+ }
@@ -0,0 +1,6 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ export { createHttpHandler } from "./handler.js";
5
+ export type { HttpHandlerOptions, StateSerializer } from "./types.js";
6
+ export { ARROW_CONTENT_TYPE } from "./common.js";