@query-farm/vgi-rpc 0.6.3 → 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.
- package/dist/access-log.d.ts +50 -0
- package/dist/access-log.d.ts.map +1 -0
- package/dist/arrow/impl-arrowjs/index.d.ts +96 -0
- package/dist/arrow/impl-arrowjs/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/index.d.ts +102 -0
- package/dist/arrow/impl-flechette/index.d.ts.map +1 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts +11 -0
- package/dist/arrow/impl-flechette/message-meta.d.ts.map +1 -0
- package/dist/arrow/index.d.ts +4 -0
- package/dist/arrow/index.d.ts.map +1 -0
- package/dist/arrow/predicates.d.ts +44 -0
- package/dist/arrow/predicates.d.ts.map +1 -0
- package/dist/arrow/types.d.ts +62 -0
- package/dist/arrow/types.d.ts.map +1 -0
- package/dist/client/capabilities.d.ts +25 -0
- package/dist/client/capabilities.d.ts.map +1 -0
- package/dist/client/connect.d.ts.map +1 -1
- package/dist/client/introspect.d.ts +7 -0
- package/dist/client/introspect.d.ts.map +1 -1
- package/dist/client/ipc.d.ts +8 -2
- package/dist/client/ipc.d.ts.map +1 -1
- package/dist/client/pipe.d.ts.map +1 -1
- package/dist/client/stream.d.ts +11 -2
- package/dist/client/stream.d.ts.map +1 -1
- package/dist/client/uploadUrl.d.ts +25 -0
- package/dist/client/uploadUrl.d.ts.map +1 -0
- package/dist/constants.d.ts +15 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/crypto.d.ts +22 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/dispatch/describe.d.ts +10 -6
- package/dist/dispatch/describe.d.ts.map +1 -1
- package/dist/dispatch/stream.d.ts +2 -2
- package/dist/dispatch/stream.d.ts.map +1 -1
- package/dist/dispatch/unary.d.ts +2 -2
- package/dist/dispatch/unary.d.ts.map +1 -1
- package/dist/errors.d.ts +46 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/external.d.ts +25 -5
- package/dist/external.d.ts.map +1 -1
- package/dist/http/bearer.d.ts.map +1 -1
- package/dist/http/common.d.ts +42 -7
- package/dist/http/common.d.ts.map +1 -1
- package/dist/http/dispatch.d.ts +20 -2
- package/dist/http/dispatch.d.ts.map +1 -1
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/mtls.d.ts +2 -1
- package/dist/http/mtls.d.ts.map +1 -1
- package/dist/http/oauth-pkce.d.ts +141 -0
- package/dist/http/oauth-pkce.d.ts.map +1 -0
- package/dist/http/pages.d.ts +3 -0
- package/dist/http/pages.d.ts.map +1 -1
- package/dist/http/sticky.d.ts +124 -0
- package/dist/http/sticky.d.ts.map +1 -0
- package/dist/http/token.d.ts +38 -12
- package/dist/http/token.d.ts.map +1 -1
- package/dist/http/types.d.ts +68 -5
- package/dist/http/types.d.ts.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1275 -3507
- package/dist/index.js.map +19 -37
- package/dist/launcher/hash.d.ts +22 -0
- package/dist/launcher/hash.d.ts.map +1 -0
- package/dist/launcher/index.d.ts +23 -0
- package/dist/launcher/index.d.ts.map +1 -0
- package/dist/launcher/launch.d.ts +27 -0
- package/dist/launcher/launch.d.ts.map +1 -0
- package/dist/launcher/lock.d.ts +19 -0
- package/dist/launcher/lock.d.ts.map +1 -0
- package/dist/launcher/serve-unix.d.ts +54 -0
- package/dist/launcher/serve-unix.d.ts.map +1 -0
- package/dist/launcher/state.d.ts +59 -0
- package/dist/launcher/state.d.ts.map +1 -0
- package/dist/otel.d.ts.map +1 -1
- package/dist/protocol.d.ts +16 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/schema.d.ts +45 -18
- package/dist/schema.d.ts.map +1 -1
- package/dist/server.d.ts +23 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +216 -12
- package/dist/types.d.ts.map +1 -1
- package/dist/util/gzip.d.ts +10 -0
- package/dist/util/gzip.d.ts.map +1 -0
- package/dist/util/schema.d.ts +3 -15
- package/dist/util/schema.d.ts.map +1 -1
- package/dist/util/web-crypto.d.ts +22 -0
- package/dist/util/web-crypto.d.ts.map +1 -0
- package/dist/util/zstd.d.ts +26 -3
- package/dist/util/zstd.d.ts.map +1 -1
- package/dist/wire/opaque.d.ts +11 -0
- package/dist/wire/opaque.d.ts.map +1 -0
- package/dist/wire/reader.d.ts +5 -5
- package/dist/wire/reader.d.ts.map +1 -1
- package/dist/wire/request.d.ts +11 -3
- package/dist/wire/request.d.ts.map +1 -1
- package/dist/wire/response.d.ts +6 -6
- package/dist/wire/response.d.ts.map +1 -1
- package/dist/wire/writer.d.ts +49 -39
- package/dist/wire/writer.d.ts.map +1 -1
- package/package.json +24 -10
- package/src/access-log.ts +195 -0
- package/src/arrow/impl-arrowjs/index.ts +433 -0
- package/src/arrow/impl-flechette/index.ts +414 -0
- package/src/arrow/impl-flechette/message-meta.ts +174 -0
- package/src/arrow/index.ts +89 -0
- package/src/arrow/predicates.ts +56 -0
- package/src/arrow/types.ts +73 -0
- package/src/client/capabilities.ts +84 -0
- package/src/client/connect.ts +103 -26
- package/src/client/introspect.ts +60 -38
- package/src/client/ipc.ts +37 -27
- package/src/client/pipe.ts +12 -9
- package/src/client/stream.ts +34 -19
- package/src/client/uploadUrl.ts +169 -0
- package/src/constants.ts +18 -1
- package/src/crypto.ts +95 -0
- package/src/dispatch/describe.ts +146 -107
- package/src/dispatch/stream.ts +53 -24
- package/src/dispatch/unary.ts +5 -4
- package/src/errors.ts +76 -0
- package/src/external.ts +43 -29
- package/src/http/bearer.ts +2 -5
- package/src/http/common.ts +90 -23
- package/src/http/dispatch.ts +373 -46
- package/src/http/handler.ts +794 -68
- package/src/http/index.ts +1 -0
- package/src/http/mtls.ts +18 -3
- package/src/http/oauth-pkce.ts +1035 -0
- package/src/http/pages.ts +30 -15
- package/src/http/sticky.ts +429 -0
- package/src/http/token.ts +165 -75
- package/src/http/types.ts +69 -5
- package/src/index.ts +40 -1
- package/src/launcher/hash.ts +104 -0
- package/src/launcher/index.ts +35 -0
- package/src/launcher/launch.ts +284 -0
- package/src/launcher/lock.ts +171 -0
- package/src/launcher/serve-unix.ts +385 -0
- package/src/launcher/state.ts +245 -0
- package/src/otel.ts +39 -33
- package/src/protocol.ts +27 -3
- package/src/schema.ts +107 -56
- package/src/server.ts +196 -20
- package/src/types.ts +322 -18
- package/src/util/gzip.ts +63 -0
- package/src/util/schema.ts +4 -22
- package/src/util/web-crypto.ts +98 -0
- package/src/util/zstd.ts +133 -14
- package/src/wire/opaque.ts +37 -0
- package/src/wire/reader.ts +5 -4
- package/src/wire/request.ts +67 -8
- package/src/wire/response.ts +51 -85
- package/src/wire/writer.ts +165 -69
- package/dist/util/conform.d.ts +0 -18
- package/dist/util/conform.d.ts.map +0 -1
- 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 };
|