@query-farm/vgi-rpc 0.7.5 → 0.9.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.
@@ -0,0 +1,382 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * AF_INET (raw TCP) worker runner for vgi-rpc TypeScript.
6
+ *
7
+ * Bind a TCP socket on `(host, port)`, accept connections, and dispatch
8
+ * each via the same per-connection IPC-stream loop as {@link serveUnix}.
9
+ * This is the network analog of the AF_UNIX runner — identical raw
10
+ * Arrow-IPC framing, only the listening socket differs. Implements the
11
+ * cross-language launcher contract:
12
+ *
13
+ * - Accept `[HOST:]PORT` (host defaults to `127.0.0.1`, port `0` lets the
14
+ * OS pick a free port) and `--idle-timeout SEC`.
15
+ * - Emit `TCP:<host>:<port>\n` to stdout once bind+listen succeed, where
16
+ * `<port>` is the *actual* bound port (resolved when `port=0`).
17
+ * - Self-terminate after `idleTimeout` seconds with zero connected
18
+ * clients; the timer starts ticking only after a `startupGrace` window
19
+ * so a slow first caller doesn't see the server vanish.
20
+ *
21
+ * SECURITY: raw TCP carries **no authentication or TLS** — it is the bare
22
+ * framing protocol on a socket. The default host is loopback-only
23
+ * (`127.0.0.1`). Binding a routable address (e.g. `0.0.0.0`) exposes the
24
+ * unauthenticated protocol on the network and must only be done on a
25
+ * trusted network; use the HTTP transport otherwise.
26
+ */
27
+
28
+ import { createServer, type Server, type Socket } from "node:net";
29
+ import { schema as makeSchema, serializeBatch } from "../arrow/index.js";
30
+ import { DESCRIBE_METHOD_NAME } from "../constants.js";
31
+ import { buildDescribeBatch } from "../dispatch/describe.js";
32
+ import { dispatchStream } from "../dispatch/stream.js";
33
+ import { dispatchUnary } from "../dispatch/unary.js";
34
+ import { RpcError, VersionError } from "../errors.js";
35
+ import type { ExternalLocationConfig } from "../external.js";
36
+ import type { Protocol } from "../protocol.js";
37
+ import {
38
+ type CallStatistics,
39
+ type DispatchHook,
40
+ type DispatchInfo,
41
+ MethodType,
42
+ type ServeStartHook,
43
+ TransportKind,
44
+ } from "../types.js";
45
+ import { IpcStreamReader } from "../wire/reader.js";
46
+ import { applyDefaults, parseRequest } from "../wire/request.js";
47
+ import { buildErrorBatch } from "../wire/response.js";
48
+ import { IpcStreamWriter } from "../wire/writer.js";
49
+
50
+ const EMPTY_SCHEMA = makeSchema([]);
51
+
52
+ /** Configuration for {@link serveTcp}. */
53
+ export interface ServeTcpOptions {
54
+ /** Interface to bind. Defaults to `127.0.0.1` (loopback only). Binding a
55
+ * routable address exposes the unauthenticated framing on the network. */
56
+ host?: string;
57
+ /** TCP port to bind. Defaults to `0`, which lets the OS pick a free port
58
+ * (reported via the `TCP:<host>:<port>` line and {@link onBound}). */
59
+ port?: number;
60
+ /** Self-terminate after this many seconds with zero connected clients.
61
+ * Default: 300. `0` disables the timer (server runs until killed). */
62
+ idleTimeout?: number;
63
+ /** Grace period after `listen()` succeeds before the idle timer starts
64
+ * ticking. Default: 5 — gives the first launcher caller a chance to
65
+ * connect after the `TCP:<host>:<port>` announcement. */
66
+ startupGraceSeconds?: number;
67
+ /** Optional logical-service / protocol-contract version label. */
68
+ protocolVersion?: string;
69
+ /** Custom server identifier. */
70
+ serverId?: string;
71
+ /** Enable __describe__ method. Default: true. */
72
+ enableDescribe?: boolean;
73
+ /** Optional dispatch hook for observability. */
74
+ dispatchHook?: DispatchHook;
75
+ /** Optional external-storage config for large-batch externalisation. */
76
+ externalLocation?: ExternalLocationConfig;
77
+ /** Lifecycle hook fired once before the first dispatched request. */
78
+ onServeStart?: ServeStartHook;
79
+ /** Maximum listen backlog. Default: 128. */
80
+ backlog?: number;
81
+ /** Called *after* `listen()` returns successfully but *before*
82
+ * `TCP:<host>:<port>` is printed. Invoked with the bound host and the
83
+ * *actual* bound port (resolved when `port=0`). */
84
+ onBound?: (host: string, port: number) => void;
85
+ /** Override the stream used for the `TCP:<host>:<port>` line. Defaults to
86
+ * `process.stdout`. */
87
+ announcementSink?: NodeJS.WritableStream;
88
+ }
89
+
90
+ /** Handle returned by {@link serveTcp} for callers that want to stop the server. */
91
+ export interface ServeTcpHandle {
92
+ /** Host the server is listening on. */
93
+ readonly host: string;
94
+ /** Actual bound TCP port (resolved from `0` when the OS auto-selects). */
95
+ readonly port: number;
96
+ /** Shut down the listener. */
97
+ stop(): Promise<void>;
98
+ /** Promise that resolves when the server has stopped (idle timeout, stop(),
99
+ * or a fatal error). Mirrors Python's blocking `serve()` return. */
100
+ readonly done: Promise<void>;
101
+ }
102
+
103
+ /**
104
+ * Bind a TCP socket and serve `protocol` over per-connection IPC streams.
105
+ *
106
+ * The network analog of {@link serveUnix}: same raw Arrow-IPC framing, only
107
+ * the listening socket differs. Nagle's algorithm is disabled
108
+ * (`setNoDelay(true)`) on each connection so the lockstep request/response
109
+ * framing is not delayed waiting to coalesce writes.
110
+ *
111
+ * SECURITY: no authentication or TLS — trusted networks only; the default
112
+ * host is loopback (`127.0.0.1`). Use the HTTP transport for untrusted
113
+ * networks.
114
+ */
115
+ export async function serveTcp(protocol: Protocol, options: ServeTcpOptions = {}): Promise<ServeTcpHandle> {
116
+ const host = options.host ?? "127.0.0.1";
117
+ const requestedPort = options.port ?? 0;
118
+ const idleTimeoutS = options.idleTimeout ?? 300;
119
+ const startupGraceS = options.startupGraceSeconds ?? 5;
120
+ const protocolVersion = options.protocolVersion ?? "";
121
+ const serverId = options.serverId ?? crypto.randomUUID().replace(/-/g, "").slice(0, 12);
122
+ const enableDescribe = options.enableDescribe ?? true;
123
+ const dispatchHook = options.dispatchHook ?? null;
124
+ const externalConfig = options.externalLocation;
125
+ const onServeStart = options.onServeStart ?? null;
126
+ const backlog = options.backlog ?? 128;
127
+ const announcementSink = options.announcementSink ?? process.stdout;
128
+
129
+ // Cache for __describe__ — Web-Crypto digest is async, so memoise.
130
+ let describePromise: Promise<{ batch: import("../arrow/index.js").VgiBatch; protocolHash: string }> | null = null;
131
+ function describeInfo(): Promise<{ batch: import("../arrow/index.js").VgiBatch; protocolHash: string }> {
132
+ if (!describePromise) {
133
+ describePromise = buildDescribeBatch(protocol.name, protocol.getMethods(), serverId).then(
134
+ ({ batch, metadata }) => ({
135
+ batch,
136
+ protocolHash: metadata.get("vgi_rpc.protocol_hash") ?? "",
137
+ }),
138
+ );
139
+ }
140
+ return describePromise;
141
+ }
142
+
143
+ // Lifecycle: only commit `serveStartFired` after the hook returns successfully.
144
+ let serveStartFired = false;
145
+ async function notifyTransport(): Promise<void> {
146
+ if (serveStartFired) return;
147
+ if (onServeStart) {
148
+ await onServeStart(TransportKind.TCP);
149
+ }
150
+ serveStartFired = true;
151
+ }
152
+
153
+ const server: Server = createServer({ allowHalfOpen: false });
154
+
155
+ let activeConnections = 0;
156
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
157
+ let resolveDone: () => void = () => {};
158
+ let rejectDone: (err: unknown) => void = () => {};
159
+ const done = new Promise<void>((resolve, reject) => {
160
+ resolveDone = resolve;
161
+ rejectDone = reject;
162
+ });
163
+ let stopped = false;
164
+
165
+ function armIdleTimer(): void {
166
+ if (idleTimeoutS <= 0) return;
167
+ if (idleTimer) clearTimeout(idleTimer);
168
+ idleTimer = setTimeout(() => {
169
+ if (activeConnections === 0 && !stopped) {
170
+ void shutdown();
171
+ }
172
+ }, idleTimeoutS * 1000);
173
+ }
174
+
175
+ function disarmIdleTimer(): void {
176
+ if (idleTimer) {
177
+ clearTimeout(idleTimer);
178
+ idleTimer = null;
179
+ }
180
+ }
181
+
182
+ async function shutdown(): Promise<void> {
183
+ if (stopped) return;
184
+ stopped = true;
185
+ disarmIdleTimer();
186
+ await new Promise<void>((resolve) => {
187
+ server.close(() => resolve());
188
+ });
189
+ resolveDone();
190
+ }
191
+
192
+ server.on("connection", (socket) => {
193
+ // Disable Nagle so the lockstep framing is not delayed coalescing writes.
194
+ try {
195
+ socket.setNoDelay(true);
196
+ } catch {
197
+ // best-effort
198
+ }
199
+ activeConnections += 1;
200
+ disarmIdleTimer();
201
+ handleConnection(socket)
202
+ .catch((err) => {
203
+ // Per-connection errors must not take down the server — log to stderr
204
+ // and let the next connection proceed.
205
+ process.stderr.write(`vgi-rpc/tcp: connection failed: ${(err as Error)?.message ?? err}\n`);
206
+ })
207
+ .finally(() => {
208
+ activeConnections -= 1;
209
+ socket.destroy();
210
+ if (activeConnections === 0 && !stopped) {
211
+ armIdleTimer();
212
+ }
213
+ });
214
+ });
215
+
216
+ server.on("error", (err) => {
217
+ if (stopped) return;
218
+ rejectDone(err);
219
+ });
220
+
221
+ async function handleConnection(socket: Socket): Promise<void> {
222
+ // The reader takes any Node Readable; sockets are duplex Readables.
223
+ const reader = await IpcStreamReader.create(socket);
224
+ // Build the writer over the Socket itself, not its raw fd, so we go
225
+ // through `socket.write` + `'drain'` and yield the event loop while the
226
+ // kernel send buffer drains (see serve-unix.ts for the rationale).
227
+ const writer = new IpcStreamWriter(socket);
228
+
229
+ try {
230
+ // Fire on_serve_start lazily — first request retries on hook failure.
231
+ await notifyTransport();
232
+
233
+ while (true) {
234
+ try {
235
+ await serveOnce(reader, writer);
236
+ } catch (e: unknown) {
237
+ const err = e as { code?: string; message?: string };
238
+ // EOF/closed client → end this connection cleanly.
239
+ if (
240
+ err?.message?.includes("closed") ||
241
+ err?.message?.includes("Expected Schema Message") ||
242
+ err?.message?.includes("null or length 0") ||
243
+ err?.message?.includes("EOF") ||
244
+ err?.code === "EPIPE" ||
245
+ err?.code === "ERR_STREAM_PREMATURE_CLOSE" ||
246
+ err?.code === "ERR_STREAM_DESTROYED"
247
+ ) {
248
+ return;
249
+ }
250
+ throw e;
251
+ }
252
+ }
253
+ } finally {
254
+ try {
255
+ await reader.cancel();
256
+ } catch {
257
+ // already closed
258
+ }
259
+ }
260
+ }
261
+
262
+ async function serveOnce(reader: IpcStreamReader, writer: IpcStreamWriter): Promise<void> {
263
+ const stream = await reader.readStream();
264
+ if (!stream) {
265
+ throw new Error("EOF");
266
+ }
267
+ const { schema, batches } = stream;
268
+ if (batches.length === 0) {
269
+ const err = new RpcError("ProtocolError", "Request stream contains no batches", "");
270
+ const errBatch = buildErrorBatch(EMPTY_SCHEMA, err, serverId, null);
271
+ await writer.writeStream(EMPTY_SCHEMA, [errBatch]);
272
+ return;
273
+ }
274
+ const batch = batches[0];
275
+ let methodName: string;
276
+ let params: Record<string, unknown>;
277
+ let requestId: string | null;
278
+ try {
279
+ const parsed = parseRequest(schema, batch);
280
+ methodName = parsed.methodName;
281
+ params = parsed.params;
282
+ requestId = parsed.requestId;
283
+ } catch (e: unknown) {
284
+ const errBatch = buildErrorBatch(EMPTY_SCHEMA, e as Error, serverId, null);
285
+ await writer.writeStream(EMPTY_SCHEMA, [errBatch]);
286
+ if (e instanceof VersionError || e instanceof RpcError) return;
287
+ throw e;
288
+ }
289
+
290
+ if (methodName === DESCRIBE_METHOD_NAME && enableDescribe) {
291
+ const { batch: descBatch } = await describeInfo();
292
+ await writer.writeStream(descBatch.schema, [descBatch]);
293
+ return;
294
+ }
295
+
296
+ const methods = protocol.getMethods();
297
+ const method = methods.get(methodName);
298
+ if (!method) {
299
+ const available = [...methods.keys()].sort();
300
+ const err = new Error(`Unknown method: '${methodName}'. Available methods: [${available.join(", ")}]`);
301
+ const errBatch = buildErrorBatch(EMPTY_SCHEMA, err, serverId, requestId);
302
+ await writer.writeStream(EMPTY_SCHEMA, [errBatch]);
303
+ return;
304
+ }
305
+
306
+ const methodType = method.type === MethodType.UNARY ? "unary" : "stream";
307
+ let requestData: Uint8Array | undefined;
308
+ try {
309
+ requestData = serializeBatch(batch);
310
+ } catch {
311
+ // best-effort
312
+ }
313
+ const { protocolHash } = await describeInfo();
314
+ const info: DispatchInfo = {
315
+ method: methodName,
316
+ methodType,
317
+ serverId,
318
+ requestId,
319
+ protocol: protocol.name,
320
+ protocolHash,
321
+ protocolVersion,
322
+ kind: TransportKind.TCP,
323
+ principal: "",
324
+ authDomain: "",
325
+ authenticated: false,
326
+ remoteAddr: "",
327
+ requestData,
328
+ };
329
+ const stats: CallStatistics = {
330
+ inputBatches: 0,
331
+ outputBatches: 0,
332
+ inputRows: 0,
333
+ outputRows: 0,
334
+ inputBytes: 0,
335
+ outputBytes: 0,
336
+ };
337
+
338
+ const token = dispatchHook?.onDispatchStart(info);
339
+ let dispatchError: Error | undefined;
340
+ applyDefaults(params, method.defaults);
341
+ try {
342
+ if (method.type === MethodType.UNARY) {
343
+ await dispatchUnary(method, params, writer, serverId, requestId, externalConfig, TransportKind.TCP);
344
+ } else {
345
+ await dispatchStream(method, params, writer, reader, serverId, requestId, externalConfig, TransportKind.TCP);
346
+ }
347
+ } catch (e) {
348
+ dispatchError = e instanceof Error ? e : new Error(String(e));
349
+ throw e;
350
+ } finally {
351
+ dispatchHook?.onDispatchEnd(token, info, stats, dispatchError);
352
+ }
353
+ }
354
+
355
+ // bind + listen
356
+ await new Promise<void>((resolve, reject) => {
357
+ server.listen({ host, port: requestedPort, backlog }, () => resolve());
358
+ server.once("error", (err) => reject(err));
359
+ });
360
+
361
+ const address = server.address();
362
+ const boundPort = typeof address === "object" && address ? address.port : requestedPort;
363
+
364
+ options.onBound?.(host, boundPort);
365
+ // Cross-language launcher contract: announce on stdout, then write nothing
366
+ // more to stdout for the rest of the process lifetime.
367
+ announcementSink.write(`TCP:${host}:${boundPort}\n`);
368
+
369
+ // Start the idle timer with a startup grace window.
370
+ if (idleTimeoutS > 0) {
371
+ setTimeout(() => {
372
+ if (activeConnections === 0 && !stopped) armIdleTimer();
373
+ }, startupGraceS * 1000).unref?.();
374
+ }
375
+
376
+ return {
377
+ host,
378
+ port: boundPort,
379
+ stop: shutdown,
380
+ done,
381
+ };
382
+ }
@@ -0,0 +1,38 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ //! Serve a protocol over a caller-provided byte-stream pair — the stream
5
+ //! sibling of `serveTcp` / `serveUnix`, with no socket/listener of its own.
6
+ //!
7
+ //! Useful for transports the launcher helpers don't cover: a Web Worker /
8
+ //! `MessagePort` bridge (postMessage), an in-memory pipe, or a pre-connected
9
+ //! socket. The host side already has this symmetry via `pipeConnect`.
10
+
11
+ import type { Socket } from "node:net";
12
+
13
+ import type { Protocol } from "./protocol.js";
14
+ import { VgiRpcServer } from "./server.js";
15
+ import { TransportKind } from "./types.js";
16
+
17
+ export interface ServeStreamOptions {
18
+ /** Incoming request bytes — a web `ReadableStream<Uint8Array>` or a Node
19
+ * `Readable` (e.g. a `Duplex` bridging a MessagePort). */
20
+ readable: ReadableStream<Uint8Array> | NodeJS.ReadableStream;
21
+ /** Outgoing response sink — a stdout-like fd number, or a `net.Socket` /
22
+ * structurally-compatible `Duplex`. Omit for the stdout fd. */
23
+ writable?: number | Socket;
24
+ /** Passed through to the `VgiRpcServer` constructor (describe, hooks, …). */
25
+ serverOptions?: ConstructorParameters<typeof VgiRpcServer>[1];
26
+ /** Reported to the `on_serve_start` hook. Defaults to `PIPE`. */
27
+ transportKind?: TransportKind;
28
+ }
29
+
30
+ /**
31
+ * Serve `protocol` over the provided `readable`/`writable` until the readable
32
+ * ends. Thin wrapper over {@link VgiRpcServer.serveConnection}. Resolves on
33
+ * clean EOF; rejects on a real protocol/transport error.
34
+ */
35
+ export async function serveStream(protocol: Protocol, options: ServeStreamOptions): Promise<void> {
36
+ const server = new VgiRpcServer(protocol, options.serverOptions);
37
+ await server.serveConnection(options.readable, options.writable, options.transportKind);
38
+ }
package/src/server.ts CHANGED
@@ -161,10 +161,8 @@ export class VgiRpcServer {
161
161
  );
162
162
  }
163
163
 
164
- /** Start the server loop. Reads requests until stdin closes. */
164
+ /** Start the server loop over stdin/stdout. Reads requests until stdin closes. */
165
165
  async run(): Promise<void> {
166
- const stdin = process.stdin as unknown as ReadableStream<Uint8Array>;
167
-
168
166
  // Warn if running interactively
169
167
  if (process.stdin.isTTY || process.stdout.isTTY) {
170
168
  process.stderr.write(
@@ -174,21 +172,44 @@ export class VgiRpcServer {
174
172
  "(e.g. vgi_rpc.connect()).\n",
175
173
  );
176
174
  }
175
+ const stdin = process.stdin as unknown as ReadableStream<Uint8Array>;
176
+ // writable omitted → IpcStreamWriter defaults to the stdout fd.
177
+ await this.serveConnection(stdin);
178
+ }
177
179
 
178
- const reader = await IpcStreamReader.create(stdin);
179
- const writer = new IpcStreamWriter();
180
+ /**
181
+ * Serve requests over an explicit byte-stream pair until the readable ends —
182
+ * the transport-agnostic core that {@link run} (stdin/stdout) is built on.
183
+ *
184
+ * Use this to serve over any duplex channel that the stdio/unix/tcp helpers
185
+ * don't cover: a Web Worker / `MessagePort` bridge, an in-memory pipe, or a
186
+ * pre-connected socket. The loop, on_serve_start firing, and EOF/broken-pipe
187
+ * handling are identical to {@link run}.
188
+ *
189
+ * @param readable incoming request bytes — a web `ReadableStream<Uint8Array>`
190
+ * or a Node `Readable` (e.g. a `Duplex` bridging a MessagePort).
191
+ * @param writable outgoing response sink — a stdout-like fd number, or a
192
+ * `net.Socket` / structurally-compatible `Duplex`. Omit for the stdout fd.
193
+ * @param transportKind reported to the `on_serve_start` hook (default `PIPE`).
194
+ */
195
+ async serveConnection(
196
+ readable: ReadableStream<Uint8Array> | NodeJS.ReadableStream,
197
+ writable?: number | import("node:net").Socket,
198
+ transportKind: TransportKind = TransportKind.PIPE,
199
+ ): Promise<void> {
200
+ const reader = await IpcStreamReader.create(readable);
201
+ const writer = new IpcStreamWriter(writable);
180
202
 
181
203
  try {
182
204
  while (true) {
183
- // Fire on_serve_start lazily so the hook can do work that
184
- // depends on the transport binding (matches Python which fires
185
- // it inside serve()). Inside the loop so a failure on the very
205
+ // Fire on_serve_start lazily so the hook can do work that depends on
206
+ // the transport binding. Inside the loop so a failure on the very
186
207
  // first request can be retried.
187
- await this.notifyTransport(TransportKind.PIPE);
208
+ await this.notifyTransport(transportKind);
188
209
  await this.serveOne(reader, writer);
189
210
  }
190
211
  } catch (e: any) {
191
- // EOF or broken pipe → clean exit
212
+ // EOF or broken pipe / closed channel → clean exit
192
213
  if (
193
214
  e.message?.includes("closed") ||
194
215
  e.message?.includes("Expected Schema Message") ||
package/src/types.ts CHANGED
@@ -29,6 +29,8 @@ export enum MethodType {
29
29
  * - `PIPE` — Stdio worker (the standalone {@link VgiRpcServer} loop).
30
30
  * - `HTTP` — Fetch-style HTTP handler (`createHttpHandler`).
31
31
  * - `UNIX` — AF_UNIX socket handler (the launcher path).
32
+ * - `TCP` — AF_INET socket handler. Raw Arrow-IPC framing over a bare TCP
33
+ * socket — no auth/TLS; use `HTTP` for untrusted networks.
32
34
  */
33
35
  export enum TransportKind {
34
36
  /** Stdio worker — the standalone {@link VgiRpcServer} loop. */
@@ -37,6 +39,9 @@ export enum TransportKind {
37
39
  HTTP = "http",
38
40
  /** AF_UNIX socket handler (the launcher path). */
39
41
  UNIX = "unix",
42
+ /** AF_INET (TCP) socket handler. Raw Arrow-IPC framing over a bare TCP
43
+ * socket — no authentication or TLS; trusted networks only. */
44
+ TCP = "tcp",
40
45
  }
41
46
 
42
47
  /**