@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,385 @@
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
+ readonly socketPath: string;
83
+ /** Shut down the listener and unlink the socket file. */
84
+ stop(): Promise<void>;
85
+ /** Promise that resolves when the server has stopped (idle timeout, stop(),
86
+ * or a fatal error). Mirrors Python's blocking `serve()` return. */
87
+ readonly done: Promise<void>;
88
+ }
89
+
90
+ /**
91
+ * Bind an AF_UNIX socket and serve `protocol` over per-connection IPC streams.
92
+ *
93
+ * Sequential listen — one client at a time, just like Python's `serve_unix`.
94
+ * Each connection gets its own dispatch loop and shares the protocol.
95
+ */
96
+ export async function serveUnix(protocol: Protocol, options: ServeUnixOptions): Promise<ServeUnixHandle> {
97
+ const sockPath = path.resolve(options.unixPath);
98
+ const idleTimeoutS = options.idleTimeout ?? 300;
99
+ const startupGraceS = options.startupGraceSeconds ?? 5;
100
+ const protocolVersion = options.protocolVersion ?? "";
101
+ const serverId = options.serverId ?? crypto.randomUUID().replace(/-/g, "").slice(0, 12);
102
+ const enableDescribe = options.enableDescribe ?? true;
103
+ const dispatchHook = options.dispatchHook ?? null;
104
+ const externalConfig = options.externalLocation;
105
+ const onServeStart = options.onServeStart ?? null;
106
+ const backlog = options.backlog ?? 16;
107
+ const announcementSink = options.announcementSink ?? process.stdout;
108
+
109
+ // Defensive probe-then-bind: an existing live worker on this path means a
110
+ // peer launcher already won the race. Refuse to bind so we don't take
111
+ // its connections. (A stale path with no listener was unlinked by the
112
+ // launcher before spawning us, but a leftover from a co-launcher right
113
+ // now is possible.)
114
+ if (existsSync(sockPath)) {
115
+ try {
116
+ // Best-effort cleanup; bind below will surface any real conflict.
117
+ unlinkSync(sockPath);
118
+ } catch {
119
+ // ignore — let listen() fail with EADDRINUSE.
120
+ }
121
+ }
122
+
123
+ // Cache for __describe__ — Web-Crypto digest is async, so memoise.
124
+ let describePromise: Promise<{ batch: import("../arrow/index.js").VgiBatch; protocolHash: string }> | null = null;
125
+ function describeInfo(): Promise<{ batch: import("../arrow/index.js").VgiBatch; protocolHash: string }> {
126
+ if (!describePromise) {
127
+ describePromise = buildDescribeBatch(protocol.name, protocol.getMethods(), serverId).then(
128
+ ({ batch, metadata }) => ({
129
+ batch,
130
+ protocolHash: metadata.get("vgi_rpc.protocol_hash") ?? "",
131
+ }),
132
+ );
133
+ }
134
+ return describePromise;
135
+ }
136
+
137
+ // Lifecycle: only commit `serveStartFired` after the hook returns successfully.
138
+ let serveStartFired = false;
139
+ async function notifyTransport(): Promise<void> {
140
+ if (serveStartFired) return;
141
+ if (onServeStart) {
142
+ await onServeStart(TransportKind.UNIX);
143
+ }
144
+ serveStartFired = true;
145
+ }
146
+
147
+ const server: Server = createServer({ allowHalfOpen: false });
148
+
149
+ let activeConnections = 0;
150
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
151
+ let resolveDone: () => void = () => {};
152
+ let rejectDone: (err: unknown) => void = () => {};
153
+ const done = new Promise<void>((resolve, reject) => {
154
+ resolveDone = resolve;
155
+ rejectDone = reject;
156
+ });
157
+ let stopped = false;
158
+
159
+ function armIdleTimer(): void {
160
+ if (idleTimeoutS <= 0) return;
161
+ if (idleTimer) clearTimeout(idleTimer);
162
+ idleTimer = setTimeout(() => {
163
+ if (activeConnections === 0 && !stopped) {
164
+ void shutdown();
165
+ }
166
+ }, idleTimeoutS * 1000);
167
+ }
168
+
169
+ function disarmIdleTimer(): void {
170
+ if (idleTimer) {
171
+ clearTimeout(idleTimer);
172
+ idleTimer = null;
173
+ }
174
+ }
175
+
176
+ async function shutdown(): Promise<void> {
177
+ if (stopped) return;
178
+ stopped = true;
179
+ disarmIdleTimer();
180
+ await new Promise<void>((resolve) => {
181
+ server.close(() => resolve());
182
+ });
183
+ try {
184
+ unlinkSync(sockPath);
185
+ } catch {
186
+ // already gone
187
+ }
188
+ resolveDone();
189
+ }
190
+
191
+ server.on("connection", (socket) => {
192
+ activeConnections += 1;
193
+ disarmIdleTimer();
194
+ handleConnection(socket)
195
+ .catch((err) => {
196
+ // Per-connection errors must not take down the server — log to stderr
197
+ // and let the next connection proceed.
198
+ process.stderr.write(`vgi-rpc/unix: connection failed: ${(err as Error)?.message ?? err}\n`);
199
+ })
200
+ .finally(() => {
201
+ activeConnections -= 1;
202
+ socket.destroy();
203
+ if (activeConnections === 0 && !stopped) {
204
+ armIdleTimer();
205
+ }
206
+ });
207
+ });
208
+
209
+ server.on("error", (err) => {
210
+ if (stopped) return;
211
+ rejectDone(err);
212
+ });
213
+
214
+ async function handleConnection(socket: Socket): Promise<void> {
215
+ // The reader takes any Node Readable; sockets are duplex Readables.
216
+ const reader = await IpcStreamReader.create(socket);
217
+ // Build the writer over the Socket itself, not its raw fd. AF_UNIX
218
+ // sockets in Node are non-blocking; a fd-based writer would do
219
+ // `fs.writeSync` and busy-wait on EAGAIN whenever the ~8 KB kernel send
220
+ // buffer fills (trivial for any Arrow batch of meaningful size). That
221
+ // synchronous wait freezes the shared event loop and starves every
222
+ // *other* connection's handler — observed as 30 s `catalog_attach`
223
+ // timeouts from co-running unittest processes. Going through
224
+ // `socket.write` + `'drain'` lets the JS thread yield while the kernel
225
+ // drains the buffer.
226
+ const writer = new IpcStreamWriter(socket);
227
+
228
+ try {
229
+ // Fire on_serve_start lazily — first request retries on hook failure.
230
+ await notifyTransport();
231
+
232
+ while (true) {
233
+ try {
234
+ await serveOnce(reader, writer);
235
+ } catch (e: unknown) {
236
+ const err = e as { code?: string; message?: string };
237
+ // EOF/closed client → end this connection cleanly.
238
+ if (
239
+ err?.message?.includes("closed") ||
240
+ err?.message?.includes("Expected Schema Message") ||
241
+ err?.message?.includes("null or length 0") ||
242
+ err?.message?.includes("EOF") ||
243
+ err?.code === "EPIPE" ||
244
+ err?.code === "ERR_STREAM_PREMATURE_CLOSE" ||
245
+ err?.code === "ERR_STREAM_DESTROYED"
246
+ ) {
247
+ return;
248
+ }
249
+ throw e;
250
+ }
251
+ }
252
+ } finally {
253
+ try {
254
+ await reader.cancel();
255
+ } catch {
256
+ // already closed
257
+ }
258
+ }
259
+ }
260
+
261
+ async function serveOnce(reader: IpcStreamReader, writer: IpcStreamWriter): Promise<void> {
262
+ const stream = await reader.readStream();
263
+ if (!stream) {
264
+ throw new Error("EOF");
265
+ }
266
+ const { schema, batches } = stream;
267
+ if (batches.length === 0) {
268
+ const err = new RpcError("ProtocolError", "Request stream contains no batches", "");
269
+ const errBatch = buildErrorBatch(EMPTY_SCHEMA, err, serverId, null);
270
+ await writer.writeStream(EMPTY_SCHEMA, [errBatch]);
271
+ return;
272
+ }
273
+ const batch = batches[0];
274
+ let methodName: string;
275
+ let params: Record<string, unknown>;
276
+ let requestId: string | null;
277
+ try {
278
+ const parsed = parseRequest(schema, batch);
279
+ methodName = parsed.methodName;
280
+ params = parsed.params;
281
+ requestId = parsed.requestId;
282
+ } catch (e: unknown) {
283
+ const errBatch = buildErrorBatch(EMPTY_SCHEMA, e as Error, serverId, null);
284
+ await writer.writeStream(EMPTY_SCHEMA, [errBatch]);
285
+ if (e instanceof VersionError || e instanceof RpcError) return;
286
+ throw e;
287
+ }
288
+
289
+ if (methodName === DESCRIBE_METHOD_NAME && enableDescribe) {
290
+ const { batch: descBatch } = await describeInfo();
291
+ await writer.writeStream(descBatch.schema, [descBatch]);
292
+ return;
293
+ }
294
+
295
+ const methods = protocol.getMethods();
296
+ const method = methods.get(methodName);
297
+ if (!method) {
298
+ const available = [...methods.keys()].sort();
299
+ const err = new Error(`Unknown method: '${methodName}'. Available methods: [${available.join(", ")}]`);
300
+ const errBatch = buildErrorBatch(EMPTY_SCHEMA, err, serverId, requestId);
301
+ await writer.writeStream(EMPTY_SCHEMA, [errBatch]);
302
+ return;
303
+ }
304
+
305
+ const methodType = method.type === MethodType.UNARY ? "unary" : "stream";
306
+ let requestData: Uint8Array | undefined;
307
+ try {
308
+ requestData = serializeBatch(batch);
309
+ } catch {
310
+ // best-effort
311
+ }
312
+ const { protocolHash } = await describeInfo();
313
+ const info: DispatchInfo = {
314
+ method: methodName,
315
+ methodType,
316
+ serverId,
317
+ requestId,
318
+ protocol: protocol.name,
319
+ protocolHash,
320
+ protocolVersion,
321
+ kind: TransportKind.UNIX,
322
+ principal: "",
323
+ authDomain: "",
324
+ authenticated: false,
325
+ remoteAddr: "",
326
+ requestData,
327
+ };
328
+ const stats: CallStatistics = {
329
+ inputBatches: 0,
330
+ outputBatches: 0,
331
+ inputRows: 0,
332
+ outputRows: 0,
333
+ inputBytes: 0,
334
+ outputBytes: 0,
335
+ };
336
+
337
+ const token = dispatchHook?.onDispatchStart(info);
338
+ let dispatchError: Error | undefined;
339
+ applyDefaults(params, method.defaults);
340
+ try {
341
+ if (method.type === MethodType.UNARY) {
342
+ await dispatchUnary(method, params, writer, serverId, requestId, externalConfig, TransportKind.UNIX);
343
+ } else {
344
+ await dispatchStream(method, params, writer, reader, serverId, requestId, externalConfig, TransportKind.UNIX);
345
+ }
346
+ } catch (e) {
347
+ dispatchError = e instanceof Error ? e : new Error(String(e));
348
+ throw e;
349
+ } finally {
350
+ dispatchHook?.onDispatchEnd(token, info, stats, dispatchError);
351
+ }
352
+ }
353
+
354
+ // bind + listen
355
+ await new Promise<void>((resolve, reject) => {
356
+ server.listen({ path: sockPath, backlog }, () => resolve());
357
+ server.once("error", (err) => reject(err));
358
+ });
359
+
360
+ // Set a tight mode on the bound socket so peers from other UIDs can't
361
+ // even initiate a connection.
362
+ try {
363
+ const { chmodSync } = await import("node:fs");
364
+ chmodSync(sockPath, 0o600);
365
+ } catch {
366
+ // best-effort — operator-managed dirs may already be 0700
367
+ }
368
+
369
+ options.onBound?.(sockPath);
370
+ // Cross-language launcher contract: announce on stdout.
371
+ announcementSink.write(`UNIX:${sockPath}\n`);
372
+
373
+ // Start the idle timer with a startup grace window.
374
+ if (idleTimeoutS > 0) {
375
+ setTimeout(() => {
376
+ if (activeConnections === 0 && !stopped) armIdleTimer();
377
+ }, startupGraceS * 1000).unref?.();
378
+ }
379
+
380
+ return {
381
+ socketPath: sockPath,
382
+ stop: shutdown,
383
+ done,
384
+ };
385
+ }
@@ -0,0 +1,245 @@
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
+ lockPath: string;
19
+ sockPath: string;
20
+ metaPath: string;
21
+ }
22
+
23
+ export function socketPaths(stateDir: string, hashId: string): SocketPaths {
24
+ return {
25
+ lockPath: path.join(stateDir, `${hashId}.lock`),
26
+ sockPath: path.join(stateDir, `${hashId}.sock`),
27
+ metaPath: path.join(stateDir, `${hashId}.meta`),
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Resolve the per-user state directory used for lockfiles + sockets.
33
+ *
34
+ * - Linux: `$XDG_RUNTIME_DIR/vgi-rpc/` when set (systemd-managed,
35
+ * auto-cleaned on logout); otherwise `$TMPDIR/vgi-rpc-$UID/`.
36
+ * - macOS / BSD: `$TMPDIR/vgi-rpc-$UID/`.
37
+ * - Windows: `$TMP/vgi-rpc/`.
38
+ *
39
+ * The directory is created mode 0700 if missing. On POSIX, we refuse to
40
+ * operate on a directory not owned by the current user — defends against
41
+ * a hijacked `/tmp/vgi-rpc-$UID` left by an attacker.
42
+ */
43
+ export function defaultStateDir(): string {
44
+ let base: string;
45
+ if (process.platform === "win32") {
46
+ base = path.join(tmpdir(), "vgi-rpc");
47
+ } else {
48
+ const xdg = process.env.XDG_RUNTIME_DIR;
49
+ if (xdg) {
50
+ base = path.join(xdg, "vgi-rpc");
51
+ } else {
52
+ const uid = typeof process.geteuid === "function" ? process.geteuid() : 0;
53
+ base = path.join(tmpdir(), `vgi-rpc-${uid}`);
54
+ }
55
+ }
56
+ mkdirSync(base, { recursive: true, mode: 0o700 });
57
+ if (process.platform !== "win32" && typeof process.geteuid === "function") {
58
+ // Tighten mode every call (cheap + idempotent) and refuse a hijacked dir.
59
+ try {
60
+ // chmodSync is in node:fs; using statSync is enough to read the owner.
61
+ const st = statSync(base);
62
+ if (st.uid !== process.geteuid()) {
63
+ throw new Error(`state directory ${base} is not owned by current user`);
64
+ }
65
+ } catch (err) {
66
+ if ((err as { code?: string })?.code === "ENOENT") {
67
+ // Race with the mkdirSync above — leave it to the next caller.
68
+ } else {
69
+ throw err;
70
+ }
71
+ }
72
+ }
73
+ return base;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Meta file
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** Best-effort write of human-readable launch metadata. Used by `--status`. */
81
+ export function writeMeta(metaPath: string, workerArgv: readonly string[], cwd: string, sockPath: string): void {
82
+ const payload = {
83
+ cmd: [...workerArgv],
84
+ cwd,
85
+ socket: sockPath,
86
+ started_at: Date.now() / 1000,
87
+ launcher_pid: process.pid,
88
+ };
89
+ try {
90
+ writeFileSync(metaPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
91
+ } catch {
92
+ // observability is best-effort
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Status / GC
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export interface StatusRow {
101
+ hashId: string;
102
+ cmd: string[];
103
+ cwd: string;
104
+ socket: string;
105
+ startedAt: number | null;
106
+ alive: boolean;
107
+ }
108
+
109
+ export interface GcResult {
110
+ /** Hash IDs of stale entries that were removed. */
111
+ cleaned: string[];
112
+ /** Hash IDs whose lockfile is currently held (a launch is in flight or the
113
+ * worker is alive). */
114
+ skippedInUse: string[];
115
+ }
116
+
117
+ /** Probe whether anyone is currently accepting on `sockPath`. */
118
+ export async function probeSocket(sockPath: string, timeoutMs = 2000): Promise<boolean> {
119
+ if (!existsSync(sockPath)) return false;
120
+ // Lazy require so workerd / browser bundles that never call probeSocket
121
+ // don't trip on `node:net`.
122
+ const net = await import("node:net");
123
+ return new Promise<boolean>((resolve) => {
124
+ const sock = net.createConnection({ path: sockPath });
125
+ const timer = setTimeout(() => {
126
+ sock.destroy();
127
+ resolve(false);
128
+ }, timeoutMs);
129
+ sock.once("connect", () => {
130
+ clearTimeout(timer);
131
+ sock.end();
132
+ resolve(true);
133
+ });
134
+ sock.once("error", () => {
135
+ clearTimeout(timer);
136
+ resolve(false);
137
+ });
138
+ });
139
+ }
140
+
141
+ function tryReadMeta(metaPath: string): { cmd: string[]; cwd: string; startedAt: number | null } {
142
+ try {
143
+ const raw = readFileSync(metaPath, "utf8");
144
+ const meta = JSON.parse(raw);
145
+ return {
146
+ cmd: Array.isArray(meta.cmd) ? meta.cmd.map(String) : [],
147
+ cwd: typeof meta.cwd === "string" ? meta.cwd : "",
148
+ startedAt: typeof meta.started_at === "number" ? meta.started_at : null,
149
+ };
150
+ } catch {
151
+ return { cmd: [], cwd: "", startedAt: null };
152
+ }
153
+ }
154
+
155
+ /** List one row per `<hash>.lock` in `stateDir`. Read-only; takes no locks. */
156
+ export async function statusRows(stateDir: string): Promise<StatusRow[]> {
157
+ const { readdirSync } = await import("node:fs");
158
+ const rows: StatusRow[] = [];
159
+ let entries: string[];
160
+ try {
161
+ entries = readdirSync(stateDir);
162
+ } catch {
163
+ return rows;
164
+ }
165
+ for (const name of entries.sort()) {
166
+ if (!name.endsWith(".lock")) continue;
167
+ const hashId = name.slice(0, -5);
168
+ const { sockPath, metaPath } = socketPaths(stateDir, hashId);
169
+ const meta = tryReadMeta(metaPath);
170
+ rows.push({
171
+ hashId,
172
+ cmd: meta.cmd,
173
+ cwd: meta.cwd,
174
+ socket: sockPath,
175
+ startedAt: meta.startedAt,
176
+ alive: await probeSocket(sockPath),
177
+ });
178
+ }
179
+ return rows;
180
+ }
181
+
182
+ /**
183
+ * Remove `<hash>.lock`/`.sock`/`.meta` triples whose worker is no longer
184
+ * accepting connections.
185
+ *
186
+ * @param tryAcquire Function provided by the lock module so we don't pull
187
+ * the lock implementation into a circular import. Returns a release
188
+ * callback when the lock can be acquired non-blocking, or `null` when
189
+ * it's already held (the worker is alive or another launch is in flight).
190
+ */
191
+ export async function gcStateDir(
192
+ stateDir: string,
193
+ tryAcquire: (lockPath: string) => Promise<(() => void) | null>,
194
+ options?: { limit?: number; excludeHash?: string },
195
+ ): Promise<GcResult> {
196
+ const { readdirSync } = await import("node:fs");
197
+ const cleaned: string[] = [];
198
+ const skipped: string[] = [];
199
+ const limit = options?.limit ?? null;
200
+ const excludeHash = options?.excludeHash ?? null;
201
+
202
+ let entries: string[];
203
+ try {
204
+ entries = readdirSync(stateDir);
205
+ } catch {
206
+ return { cleaned, skippedInUse: skipped };
207
+ }
208
+
209
+ let seen = 0;
210
+ for (const name of entries.sort()) {
211
+ if (!name.endsWith(".lock")) continue;
212
+ if (limit !== null && seen >= limit) break;
213
+ seen += 1;
214
+ const hashId = name.slice(0, -5);
215
+ if (excludeHash !== null && hashId === excludeHash) continue;
216
+
217
+ const { lockPath, sockPath, metaPath } = socketPaths(stateDir, hashId);
218
+ const release = await tryAcquire(lockPath);
219
+ if (release === null) {
220
+ skipped.push(hashId);
221
+ continue;
222
+ }
223
+ try {
224
+ if (await probeSocket(sockPath)) {
225
+ // Worker is alive but didn't hold its own lock — odd, but leave it.
226
+ continue;
227
+ }
228
+ for (const p of [sockPath, metaPath, lockPath]) {
229
+ try {
230
+ unlinkSync(p);
231
+ } catch {
232
+ // best-effort
233
+ }
234
+ }
235
+ cleaned.push(hashId);
236
+ } finally {
237
+ release();
238
+ }
239
+ }
240
+ return { cleaned, skippedInUse: skipped };
241
+ }
242
+
243
+ /** Re-export for callers that want canonical hashing without going through the
244
+ * launch entry point. */
245
+ export { computeHash };