@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,386 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * AF_UNIX worker runner for vgi-rpc TypeScript.
6
+ *
7
+ * Bind a deterministic Unix-domain socket, accept connections one at a
8
+ * time (sequential listen, matching Python's `serve_unix`), and dispatch
9
+ * each via the existing {@link VgiRpcServer.serveOne} loop. Implements
10
+ * the cross-language launcher contract:
11
+ *
12
+ * - Accept `--unix PATH` and `--idle-timeout SEC` (parsed by callers).
13
+ * - Emit `UNIX:<absolute-path>\n` to stdout once bind+listen succeed.
14
+ * - Self-terminate after `idleTimeout` seconds with zero connected
15
+ * clients; the timer starts ticking only after a `startupGrace`
16
+ * window so a slow first caller doesn't see the server vanish.
17
+ */
18
+
19
+ import { existsSync, unlinkSync } from "node:fs";
20
+ import { createServer, type Server, type Socket } from "node:net";
21
+ import * as path from "node:path";
22
+ import { schema as makeSchema, serializeBatch } from "../arrow/index.js";
23
+ import { DESCRIBE_METHOD_NAME } from "../constants.js";
24
+ import { buildDescribeBatch } from "../dispatch/describe.js";
25
+ import { dispatchStream } from "../dispatch/stream.js";
26
+ import { dispatchUnary } from "../dispatch/unary.js";
27
+ import { RpcError, VersionError } from "../errors.js";
28
+ import type { ExternalLocationConfig } from "../external.js";
29
+ import type { Protocol } from "../protocol.js";
30
+ import {
31
+ type CallStatistics,
32
+ type DispatchHook,
33
+ type DispatchInfo,
34
+ MethodType,
35
+ type ServeStartHook,
36
+ TransportKind,
37
+ } from "../types.js";
38
+ import { IpcStreamReader } from "../wire/reader.js";
39
+ import { applyDefaults, parseRequest } from "../wire/request.js";
40
+ import { buildErrorBatch } from "../wire/response.js";
41
+ import { IpcStreamWriter } from "../wire/writer.js";
42
+
43
+ const EMPTY_SCHEMA = makeSchema([]);
44
+
45
+ /** Configuration for {@link serveUnix}. */
46
+ export interface ServeUnixOptions {
47
+ /** Absolute path to the Unix socket file the worker should bind. */
48
+ unixPath: string;
49
+ /** Self-terminate after this many seconds with zero connected clients.
50
+ * Default: 300. `0` disables the timer (server runs until killed). */
51
+ idleTimeout?: number;
52
+ /** Grace period after `listen()` succeeds before the idle timer starts
53
+ * ticking. Default: 5 — gives the first launcher caller a chance to
54
+ * connect after the `UNIX:<path>` announcement. */
55
+ startupGraceSeconds?: number;
56
+ /** Optional logical-service / protocol-contract version label. */
57
+ protocolVersion?: string;
58
+ /** Custom server identifier. */
59
+ serverId?: string;
60
+ /** Enable __describe__ method. Default: true. */
61
+ enableDescribe?: boolean;
62
+ /** Optional dispatch hook for observability. */
63
+ dispatchHook?: DispatchHook;
64
+ /** Optional external-storage config for large-batch externalisation. */
65
+ externalLocation?: ExternalLocationConfig;
66
+ /** Lifecycle hook fired once before the first dispatched request. */
67
+ onServeStart?: ServeStartHook;
68
+ /** Maximum sequential listen backlog. Mirrors Python's `serve_unix`
69
+ * (`backlog=16`). Default: 16. */
70
+ backlog?: number;
71
+ /** Called *after* `listen()` returns successfully but *before*
72
+ * `UNIX:<path>` is printed. The launcher uses this hook to write the
73
+ * announcement only after we're sure the bind took. */
74
+ onBound?: (sockPath: string) => void;
75
+ /** Override the stream used for the `UNIX:<path>` line. Defaults to
76
+ * `process.stdout`. */
77
+ announcementSink?: NodeJS.WritableStream;
78
+ }
79
+
80
+ /** Handle returned by {@link serveUnix} for callers that want to stop the server. */
81
+ export interface ServeUnixHandle {
82
+ /** Absolute path of the bound AF_UNIX socket the server is listening on. */
83
+ readonly socketPath: string;
84
+ /** Shut down the listener and unlink the socket file. */
85
+ stop(): Promise<void>;
86
+ /** Promise that resolves when the server has stopped (idle timeout, stop(),
87
+ * or a fatal error). Mirrors Python's blocking `serve()` return. */
88
+ readonly done: Promise<void>;
89
+ }
90
+
91
+ /**
92
+ * Bind an AF_UNIX socket and serve `protocol` over per-connection IPC streams.
93
+ *
94
+ * Sequential listen — one client at a time, just like Python's `serve_unix`.
95
+ * Each connection gets its own dispatch loop and shares the protocol.
96
+ */
97
+ export async function serveUnix(protocol: Protocol, options: ServeUnixOptions): Promise<ServeUnixHandle> {
98
+ const sockPath = path.resolve(options.unixPath);
99
+ const idleTimeoutS = options.idleTimeout ?? 300;
100
+ const startupGraceS = options.startupGraceSeconds ?? 5;
101
+ const protocolVersion = options.protocolVersion ?? "";
102
+ const serverId = options.serverId ?? crypto.randomUUID().replace(/-/g, "").slice(0, 12);
103
+ const enableDescribe = options.enableDescribe ?? true;
104
+ const dispatchHook = options.dispatchHook ?? null;
105
+ const externalConfig = options.externalLocation;
106
+ const onServeStart = options.onServeStart ?? null;
107
+ const backlog = options.backlog ?? 16;
108
+ const announcementSink = options.announcementSink ?? process.stdout;
109
+
110
+ // Defensive probe-then-bind: an existing live worker on this path means a
111
+ // peer launcher already won the race. Refuse to bind so we don't take
112
+ // its connections. (A stale path with no listener was unlinked by the
113
+ // launcher before spawning us, but a leftover from a co-launcher right
114
+ // now is possible.)
115
+ if (existsSync(sockPath)) {
116
+ try {
117
+ // Best-effort cleanup; bind below will surface any real conflict.
118
+ unlinkSync(sockPath);
119
+ } catch {
120
+ // ignore — let listen() fail with EADDRINUSE.
121
+ }
122
+ }
123
+
124
+ // Cache for __describe__ — Web-Crypto digest is async, so memoise.
125
+ let describePromise: Promise<{ batch: import("../arrow/index.js").VgiBatch; protocolHash: string }> | null = null;
126
+ function describeInfo(): Promise<{ batch: import("../arrow/index.js").VgiBatch; protocolHash: string }> {
127
+ if (!describePromise) {
128
+ describePromise = buildDescribeBatch(protocol.name, protocol.getMethods(), serverId).then(
129
+ ({ batch, metadata }) => ({
130
+ batch,
131
+ protocolHash: metadata.get("vgi_rpc.protocol_hash") ?? "",
132
+ }),
133
+ );
134
+ }
135
+ return describePromise;
136
+ }
137
+
138
+ // Lifecycle: only commit `serveStartFired` after the hook returns successfully.
139
+ let serveStartFired = false;
140
+ async function notifyTransport(): Promise<void> {
141
+ if (serveStartFired) return;
142
+ if (onServeStart) {
143
+ await onServeStart(TransportKind.UNIX);
144
+ }
145
+ serveStartFired = true;
146
+ }
147
+
148
+ const server: Server = createServer({ allowHalfOpen: false });
149
+
150
+ let activeConnections = 0;
151
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
152
+ let resolveDone: () => void = () => {};
153
+ let rejectDone: (err: unknown) => void = () => {};
154
+ const done = new Promise<void>((resolve, reject) => {
155
+ resolveDone = resolve;
156
+ rejectDone = reject;
157
+ });
158
+ let stopped = false;
159
+
160
+ function armIdleTimer(): void {
161
+ if (idleTimeoutS <= 0) return;
162
+ if (idleTimer) clearTimeout(idleTimer);
163
+ idleTimer = setTimeout(() => {
164
+ if (activeConnections === 0 && !stopped) {
165
+ void shutdown();
166
+ }
167
+ }, idleTimeoutS * 1000);
168
+ }
169
+
170
+ function disarmIdleTimer(): void {
171
+ if (idleTimer) {
172
+ clearTimeout(idleTimer);
173
+ idleTimer = null;
174
+ }
175
+ }
176
+
177
+ async function shutdown(): Promise<void> {
178
+ if (stopped) return;
179
+ stopped = true;
180
+ disarmIdleTimer();
181
+ await new Promise<void>((resolve) => {
182
+ server.close(() => resolve());
183
+ });
184
+ try {
185
+ unlinkSync(sockPath);
186
+ } catch {
187
+ // already gone
188
+ }
189
+ resolveDone();
190
+ }
191
+
192
+ server.on("connection", (socket) => {
193
+ activeConnections += 1;
194
+ disarmIdleTimer();
195
+ handleConnection(socket)
196
+ .catch((err) => {
197
+ // Per-connection errors must not take down the server — log to stderr
198
+ // and let the next connection proceed.
199
+ process.stderr.write(`vgi-rpc/unix: connection failed: ${(err as Error)?.message ?? err}\n`);
200
+ })
201
+ .finally(() => {
202
+ activeConnections -= 1;
203
+ socket.destroy();
204
+ if (activeConnections === 0 && !stopped) {
205
+ armIdleTimer();
206
+ }
207
+ });
208
+ });
209
+
210
+ server.on("error", (err) => {
211
+ if (stopped) return;
212
+ rejectDone(err);
213
+ });
214
+
215
+ async function handleConnection(socket: Socket): Promise<void> {
216
+ // The reader takes any Node Readable; sockets are duplex Readables.
217
+ const reader = await IpcStreamReader.create(socket);
218
+ // Build the writer over the Socket itself, not its raw fd. AF_UNIX
219
+ // sockets in Node are non-blocking; a fd-based writer would do
220
+ // `fs.writeSync` and busy-wait on EAGAIN whenever the ~8 KB kernel send
221
+ // buffer fills (trivial for any Arrow batch of meaningful size). That
222
+ // synchronous wait freezes the shared event loop and starves every
223
+ // *other* connection's handler — observed as 30 s `catalog_attach`
224
+ // timeouts from co-running unittest processes. Going through
225
+ // `socket.write` + `'drain'` lets the JS thread yield while the kernel
226
+ // drains the buffer.
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.UNIX,
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.UNIX);
344
+ } else {
345
+ await dispatchStream(method, params, writer, reader, serverId, requestId, externalConfig, TransportKind.UNIX);
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({ path: sockPath, backlog }, () => resolve());
358
+ server.once("error", (err) => reject(err));
359
+ });
360
+
361
+ // Set a tight mode on the bound socket so peers from other UIDs can't
362
+ // even initiate a connection.
363
+ try {
364
+ const { chmodSync } = await import("node:fs");
365
+ chmodSync(sockPath, 0o600);
366
+ } catch {
367
+ // best-effort — operator-managed dirs may already be 0700
368
+ }
369
+
370
+ options.onBound?.(sockPath);
371
+ // Cross-language launcher contract: announce on stdout.
372
+ announcementSink.write(`UNIX:${sockPath}\n`);
373
+
374
+ // Start the idle timer with a startup grace window.
375
+ if (idleTimeoutS > 0) {
376
+ setTimeout(() => {
377
+ if (activeConnections === 0 && !stopped) armIdleTimer();
378
+ }, startupGraceS * 1000).unref?.();
379
+ }
380
+
381
+ return {
382
+ socketPath: sockPath,
383
+ stop: shutdown,
384
+ done,
385
+ };
386
+ }
@@ -0,0 +1,257 @@
1
+ // © Copyright 2025-2026, Query.Farm LLC - https://query.farm
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * State directory + lock/sock/meta path layout for the AF_UNIX worker
6
+ * launcher. Mirrors `vgi_rpc.launcher` so cross-language tooling
7
+ * resolves to identical filesystem locations.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ import { computeHash } from "./hash.js";
15
+
16
+ /** Filesystem layout for one worker tuple: `<state_dir>/<hash>.{lock,sock,meta}`. */
17
+ export interface SocketPaths {
18
+ /** Advisory lockfile path (`<hash>.lock`) guarding launch + liveness. */
19
+ lockPath: string;
20
+ /** AF_UNIX socket path (`<hash>.sock`) the worker binds. */
21
+ sockPath: string;
22
+ /** Launch-metadata JSON path (`<hash>.meta`) read by `--status`. */
23
+ metaPath: string;
24
+ }
25
+
26
+ /** Derive the lock/sock/meta path triple for a worker `hashId` under `stateDir`. */
27
+ export function socketPaths(stateDir: string, hashId: string): SocketPaths {
28
+ return {
29
+ lockPath: path.join(stateDir, `${hashId}.lock`),
30
+ sockPath: path.join(stateDir, `${hashId}.sock`),
31
+ metaPath: path.join(stateDir, `${hashId}.meta`),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Resolve the per-user state directory used for lockfiles + sockets.
37
+ *
38
+ * - Linux: `$XDG_RUNTIME_DIR/vgi-rpc/` when set (systemd-managed,
39
+ * auto-cleaned on logout); otherwise `$TMPDIR/vgi-rpc-$UID/`.
40
+ * - macOS / BSD: `$TMPDIR/vgi-rpc-$UID/`.
41
+ * - Windows: `$TMP/vgi-rpc/`.
42
+ *
43
+ * The directory is created mode 0700 if missing. On POSIX, we refuse to
44
+ * operate on a directory not owned by the current user — defends against
45
+ * a hijacked `/tmp/vgi-rpc-$UID` left by an attacker.
46
+ */
47
+ export function defaultStateDir(): string {
48
+ let base: string;
49
+ if (process.platform === "win32") {
50
+ base = path.join(tmpdir(), "vgi-rpc");
51
+ } else {
52
+ const xdg = process.env.XDG_RUNTIME_DIR;
53
+ if (xdg) {
54
+ base = path.join(xdg, "vgi-rpc");
55
+ } else {
56
+ const uid = typeof process.geteuid === "function" ? process.geteuid() : 0;
57
+ base = path.join(tmpdir(), `vgi-rpc-${uid}`);
58
+ }
59
+ }
60
+ mkdirSync(base, { recursive: true, mode: 0o700 });
61
+ if (process.platform !== "win32" && typeof process.geteuid === "function") {
62
+ // Tighten mode every call (cheap + idempotent) and refuse a hijacked dir.
63
+ try {
64
+ // chmodSync is in node:fs; using statSync is enough to read the owner.
65
+ const st = statSync(base);
66
+ if (st.uid !== process.geteuid()) {
67
+ throw new Error(`state directory ${base} is not owned by current user`);
68
+ }
69
+ } catch (err) {
70
+ if ((err as { code?: string })?.code === "ENOENT") {
71
+ // Race with the mkdirSync above — leave it to the next caller.
72
+ } else {
73
+ throw err;
74
+ }
75
+ }
76
+ }
77
+ return base;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Meta file
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Best-effort write of human-readable launch metadata. Used by `--status`. */
85
+ export function writeMeta(metaPath: string, workerArgv: readonly string[], cwd: string, sockPath: string): void {
86
+ const payload = {
87
+ cmd: [...workerArgv],
88
+ cwd,
89
+ socket: sockPath,
90
+ started_at: Date.now() / 1000,
91
+ launcher_pid: process.pid,
92
+ };
93
+ try {
94
+ writeFileSync(metaPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
95
+ } catch {
96
+ // observability is best-effort
97
+ }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Status / GC
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** One row of `--status` output describing a launched worker tuple. */
105
+ export interface StatusRow {
106
+ /** Canonical hash identifying the worker tuple (the `<hash>` filename stem). */
107
+ hashId: string;
108
+ /** Worker argv recorded at launch, or `[]` when the meta file is missing. */
109
+ cmd: string[];
110
+ /** Working directory recorded at launch, or `""` when unknown. */
111
+ cwd: string;
112
+ /** AF_UNIX socket path the worker binds. */
113
+ socket: string;
114
+ /** Unix epoch seconds the worker was launched, or `null` when unknown. */
115
+ startedAt: number | null;
116
+ /** Whether a probe connection to {@link StatusRow.socket} currently succeeds. */
117
+ alive: boolean;
118
+ }
119
+
120
+ /** Outcome of a {@link gcStateDir} sweep. */
121
+ export interface GcResult {
122
+ /** Hash IDs of stale entries that were removed. */
123
+ cleaned: string[];
124
+ /** Hash IDs whose lockfile is currently held (a launch is in flight or the
125
+ * worker is alive). */
126
+ skippedInUse: string[];
127
+ }
128
+
129
+ /** Probe whether anyone is currently accepting on `sockPath`. */
130
+ export async function probeSocket(sockPath: string, timeoutMs = 2000): Promise<boolean> {
131
+ if (!existsSync(sockPath)) return false;
132
+ // Lazy require so workerd / browser bundles that never call probeSocket
133
+ // don't trip on `node:net`.
134
+ const net = await import("node:net");
135
+ return new Promise<boolean>((resolve) => {
136
+ const sock = net.createConnection({ path: sockPath });
137
+ const timer = setTimeout(() => {
138
+ sock.destroy();
139
+ resolve(false);
140
+ }, timeoutMs);
141
+ sock.once("connect", () => {
142
+ clearTimeout(timer);
143
+ sock.end();
144
+ resolve(true);
145
+ });
146
+ sock.once("error", () => {
147
+ clearTimeout(timer);
148
+ resolve(false);
149
+ });
150
+ });
151
+ }
152
+
153
+ function tryReadMeta(metaPath: string): { cmd: string[]; cwd: string; startedAt: number | null } {
154
+ try {
155
+ const raw = readFileSync(metaPath, "utf8");
156
+ const meta = JSON.parse(raw);
157
+ return {
158
+ cmd: Array.isArray(meta.cmd) ? meta.cmd.map(String) : [],
159
+ cwd: typeof meta.cwd === "string" ? meta.cwd : "",
160
+ startedAt: typeof meta.started_at === "number" ? meta.started_at : null,
161
+ };
162
+ } catch {
163
+ return { cmd: [], cwd: "", startedAt: null };
164
+ }
165
+ }
166
+
167
+ /** List one row per `<hash>.lock` in `stateDir`. Read-only; takes no locks. */
168
+ export async function statusRows(stateDir: string): Promise<StatusRow[]> {
169
+ const { readdirSync } = await import("node:fs");
170
+ const rows: StatusRow[] = [];
171
+ let entries: string[];
172
+ try {
173
+ entries = readdirSync(stateDir);
174
+ } catch {
175
+ return rows;
176
+ }
177
+ for (const name of entries.sort()) {
178
+ if (!name.endsWith(".lock")) continue;
179
+ const hashId = name.slice(0, -5);
180
+ const { sockPath, metaPath } = socketPaths(stateDir, hashId);
181
+ const meta = tryReadMeta(metaPath);
182
+ rows.push({
183
+ hashId,
184
+ cmd: meta.cmd,
185
+ cwd: meta.cwd,
186
+ socket: sockPath,
187
+ startedAt: meta.startedAt,
188
+ alive: await probeSocket(sockPath),
189
+ });
190
+ }
191
+ return rows;
192
+ }
193
+
194
+ /**
195
+ * Remove `<hash>.lock`/`.sock`/`.meta` triples whose worker is no longer
196
+ * accepting connections.
197
+ *
198
+ * @param tryAcquire Function provided by the lock module so we don't pull
199
+ * the lock implementation into a circular import. Returns a release
200
+ * callback when the lock can be acquired non-blocking, or `null` when
201
+ * it's already held (the worker is alive or another launch is in flight).
202
+ */
203
+ export async function gcStateDir(
204
+ stateDir: string,
205
+ tryAcquire: (lockPath: string) => Promise<(() => void) | null>,
206
+ options?: { limit?: number; excludeHash?: string },
207
+ ): Promise<GcResult> {
208
+ const { readdirSync } = await import("node:fs");
209
+ const cleaned: string[] = [];
210
+ const skipped: string[] = [];
211
+ const limit = options?.limit ?? null;
212
+ const excludeHash = options?.excludeHash ?? null;
213
+
214
+ let entries: string[];
215
+ try {
216
+ entries = readdirSync(stateDir);
217
+ } catch {
218
+ return { cleaned, skippedInUse: skipped };
219
+ }
220
+
221
+ let seen = 0;
222
+ for (const name of entries.sort()) {
223
+ if (!name.endsWith(".lock")) continue;
224
+ if (limit !== null && seen >= limit) break;
225
+ seen += 1;
226
+ const hashId = name.slice(0, -5);
227
+ if (excludeHash !== null && hashId === excludeHash) continue;
228
+
229
+ const { lockPath, sockPath, metaPath } = socketPaths(stateDir, hashId);
230
+ const release = await tryAcquire(lockPath);
231
+ if (release === null) {
232
+ skipped.push(hashId);
233
+ continue;
234
+ }
235
+ try {
236
+ if (await probeSocket(sockPath)) {
237
+ // Worker is alive but didn't hold its own lock — odd, but leave it.
238
+ continue;
239
+ }
240
+ for (const p of [sockPath, metaPath, lockPath]) {
241
+ try {
242
+ unlinkSync(p);
243
+ } catch {
244
+ // best-effort
245
+ }
246
+ }
247
+ cleaned.push(hashId);
248
+ } finally {
249
+ release();
250
+ }
251
+ }
252
+ return { cleaned, skippedInUse: skipped };
253
+ }
254
+
255
+ /** Re-export for callers that want canonical hashing without going through the
256
+ * launch entry point. */
257
+ export { computeHash };