@hediet/linkrpc 0.0.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.
- package/README.md +383 -0
- package/dist/chunks/_empty-crypto-Bi0tGx5K.js +8 -0
- package/dist/chunks/boundedTrafficSubscription-1L592xc7.js +1126 -0
- package/dist/chunks/boundedTrafficSubscription-1L592xc7.js.map +1 -0
- package/dist/chunks/hub.interfaces-BzWfsVT2.js +526 -0
- package/dist/chunks/hub.interfaces-BzWfsVT2.js.map +1 -0
- package/dist/chunks/hubAccess-DwTZPiI8.d.ts +79 -0
- package/dist/chunks/hubAccess-DwTZPiI8.d.ts.map +1 -0
- package/dist/chunks/hubFacade-CQflVkVC.js +85 -0
- package/dist/chunks/hubFacade-CQflVkVC.js.map +1 -0
- package/dist/chunks/hubFacade-Dkgw2pTi.d.ts +101 -0
- package/dist/chunks/hubFacade-Dkgw2pTi.d.ts.map +1 -0
- package/dist/chunks/linkRpcConnection-CtlQmetO.d.ts +3623 -0
- package/dist/chunks/linkRpcConnection-CtlQmetO.d.ts.map +1 -0
- package/dist/chunks/rolldown-runtime-4LSo1kEK.js +17 -0
- package/dist/chunks/src-D3NUIwyo.js +7795 -0
- package/dist/chunks/src-D3NUIwyo.js.map +1 -0
- package/dist/hub/client/index.d.ts +50 -0
- package/dist/hub/client/index.d.ts.map +1 -0
- package/dist/hub/client/index.js +97 -0
- package/dist/hub/client/index.js.map +1 -0
- package/dist/hub/common/index.d.ts +1189 -0
- package/dist/hub/common/index.d.ts.map +1 -0
- package/dist/hub/common/index.js +267 -0
- package/dist/hub/common/index.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/inspection/index.d.ts +66 -0
- package/dist/inspection/index.d.ts.map +1 -0
- package/dist/inspection/index.js +6 -0
- package/dist/node.d.ts +548 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +1087 -0
- package/dist/node.js.map +1 -0
- package/dist/web.d.ts +44 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +58 -0
- package/dist/web.js.map +1 -0
- package/package.json +59 -0
package/dist/node.js
ADDED
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
/*---------------------------------------------------------------------------------------------
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
import { A as NotificationType, C as defineInterface, D as sha256, E as computeInterfaceHash, F as normalizeJsonSchema, M as notificationType, N as requestType, O as jcsCanonicalize, P as zodToSvcJsonSchema, S as InterfaceDefinition, T as EXTENSION_PREFIX, _ as zTrafficEvent, a as topologyInterface, b as zTrafficTransitEvent, c as zParticipantDescriptorSource, d as zTopologyLink, f as zTopologyLinkEndpoint, g as zTopologyTransportInfo, h as zTopologyTransportEndpoint, i as nodeInterface, j as RequestType, k as jcsCanonicalizeBytes, l as zRouteClaim, m as zTopologyPort, o as trafficInterface, p as zTopologyNode, s as zParticipantDescriptor, u as zTopologyGraph, v as zTrafficOverflowEvent, w as interfaceFromSchema, x as zTrafficWatchResult, y as zTrafficTransitEndpoint } from "./chunks/boundedTrafficSubscription-1L592xc7.js";
|
|
6
|
+
import { $t as bareInterfaceTarget, A as attachCapabilities, At as PRINCIPAL_PREFIX, B as LinkRpcConnection, Bt as isRequest, C as identityStorageInterface, Ct as getKeyId, D as PublicWrappingIdentity, Dt as signingInput, E as PublicSigningIdentity, Et as signingDomainValue, F as generateX25519Keypair, Ft as principalForPublicKey, Gt as StreamDir, H as JsonRpcChannel, Ht as STREAM_METHOD, I as formatEndpointUri, It as publicKeyForKeyId, Jt as directoryInterface, Kt as streamInterface, L as isHubEndpoint, Lt as resolveSigningKey, M as verifyRpcCall, Mt as bytesToBase64Url, N as crypto_exports, Nt as keyIdForPrincipal, O as Principal, Ot as withSignature, P as generateKeypair, Pt as keyIdForPublicKey, Qt as generateTsInterface, R as parseEndpointUri, Rt as ErrorCode, S as identityInterface, St as LINKRPC_UNSIGNED_KEY, T as KeypairSigningIdentity, Tt as signedHash, Ut as StreamControlReason, V as ServiceHandle, Vt as isResponse, Wt as StreamControlType, Xt as schemasInterface, Yt as directoryWatchNever, Zt as zServiceIdPattern, _ as InMemoryManagedIdentity, _t as HUBRPC_META_KEY, a as verifyCall, an as componentSchemaRef, at as TransportPair, b as registerIdentityOnOverlay, bt as LINKRPC_META_KEY, c as invoke, ct as requireObjectParams, d as prefix, dt as parseMethodName, en as createSchemaToZod, et as DEFAULT_RPC_TIMEOUT_MS, f as signCapability, ft as capabilityPermits, g as createManagedPrincipal, gt as permissionPermits, h as PrincipalWithStore, ht as permissionMatchesTarget, i as signParams, in as componentSchemaName, it as MultiplexedTransport, j as signRpcCall, jt as base64UrlToBytes, k as CapBag, kt as KEY_ID_PREFIX, l as issueCapability, lt as stripLinkRpcWireMeta, m as SigningSender, mt as matchParams, n as createSeededMemoryPrincipal, nn as materializeJsonSchema, nt as Channel, o as capBagFreshAt, on as isAssignable, ot as connectTransports, p as OneShotCapStaging, pt as hasSignedCapabilityShape, qt as defaultsInterface, r as createSeededSigningIdentity, rn as assertSchemaReferences, rt as RpcError, s as capabilityFreshAt, st as traceMessageTransport, t as createMemoryPrincipal, tn as schemaToZod, tt as withRpcTimeout, u as permits, ut as methodNameToTarget, v as InMemoryManagedIdentityStorage, vt as HUBRPC_SIGNATURE_KEY, w as KeypairIdentity, wt as readSignature, x as registerLazyIdentityOnOverlay, xt as LINKRPC_SIGNATURE_KEY, y as createManagedIdentity, yt as HUBRPC_UNSIGNED_KEY, z as ChannelConnector, zt as isNotification } from "./chunks/src-D3NUIwyo.js";
|
|
7
|
+
import { n as hubFromConnection } from "./chunks/hubFacade-CQflVkVC.js";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import * as net from "node:net";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
//#region src/node/ndjsonTransport.ts
|
|
16
|
+
/**
|
|
17
|
+
* Newline-delimited JSON transport over a Node Readable/Writable pair.
|
|
18
|
+
* One JSON value per line, both directions.
|
|
19
|
+
*
|
|
20
|
+
* Not LSP-style headers — those add no value for a private link between two
|
|
21
|
+
* cooperating processes and require a length-prefixed framer.
|
|
22
|
+
*/
|
|
23
|
+
var NdjsonTransport = class {
|
|
24
|
+
_input;
|
|
25
|
+
_output;
|
|
26
|
+
_onClose;
|
|
27
|
+
_listener;
|
|
28
|
+
_buffer = [];
|
|
29
|
+
_residual = "";
|
|
30
|
+
_closed = false;
|
|
31
|
+
constructor(_input, _output, _onClose) {
|
|
32
|
+
this._input = _input;
|
|
33
|
+
this._output = _output;
|
|
34
|
+
this._onClose = _onClose;
|
|
35
|
+
this._input.setEncoding?.("utf8");
|
|
36
|
+
this._input.on("data", (chunk) => this._onData(chunk));
|
|
37
|
+
this._input.on("end", () => this._onEnd());
|
|
38
|
+
this._input.on("close", () => this._onEnd());
|
|
39
|
+
this._input.on("error", () => this._onEnd());
|
|
40
|
+
if (!Object.is(this._output, this._input)) this._output.on("error", () => this._onEnd());
|
|
41
|
+
}
|
|
42
|
+
send(message) {
|
|
43
|
+
if (this._closed) return;
|
|
44
|
+
this._output.write(JSON.stringify(message) + "\n");
|
|
45
|
+
}
|
|
46
|
+
setListener(listener) {
|
|
47
|
+
this._listener = listener;
|
|
48
|
+
if (listener) while (this._buffer.length > 0 && this._listener) {
|
|
49
|
+
const m = this._buffer.shift();
|
|
50
|
+
this._listener(m);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
dispose() {
|
|
54
|
+
if (this._closed) return;
|
|
55
|
+
this._closed = true;
|
|
56
|
+
this._onClose?.();
|
|
57
|
+
}
|
|
58
|
+
_onData(chunk) {
|
|
59
|
+
this._residual += chunk;
|
|
60
|
+
let nl;
|
|
61
|
+
while ((nl = this._residual.indexOf("\n")) >= 0) {
|
|
62
|
+
const line = this._residual.slice(0, nl).trim();
|
|
63
|
+
this._residual = this._residual.slice(nl + 1);
|
|
64
|
+
if (!line) continue;
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(line);
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
this._deliver(parsed);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
_onEnd() {
|
|
75
|
+
if (!this._closed) {
|
|
76
|
+
this._closed = true;
|
|
77
|
+
this._onClose?.();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
_deliver(m) {
|
|
81
|
+
if (this._listener) this._listener(m);
|
|
82
|
+
else this._buffer.push(m);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/node/initialize.ts
|
|
87
|
+
/**
|
|
88
|
+
* Reserved transport-handshake method. Sent as the very first message on a
|
|
89
|
+
* connection that authenticates and/or negotiates transport details. It is
|
|
90
|
+
* handled entirely by the transport layer (via {@link connectNdjson}) and is
|
|
91
|
+
* never forwarded to the hub or any service — it is not a routed call.
|
|
92
|
+
*/
|
|
93
|
+
const INITIALIZE_METHOD = "hubrpc::initialize";
|
|
94
|
+
/** Transitional alias accepted by servers created during the LinkRPC wire-name migration. */
|
|
95
|
+
const LINKRPC_INITIALIZE_METHOD_ALIAS = "linkrpc::initialize";
|
|
96
|
+
/** Current transport protocol version. */
|
|
97
|
+
const INITIALIZE_PROTOCOL_VERSION = 1;
|
|
98
|
+
/** Default time (ms) to wait for the handshake message before giving up. */
|
|
99
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 1e4;
|
|
100
|
+
/** Fixed request id for the initiator's `hubrpc::initialize` request. */
|
|
101
|
+
const INITIALIZE_REQUEST_ID = 0;
|
|
102
|
+
/**
|
|
103
|
+
* The single supported way to build an ndjson transport. Constructs the
|
|
104
|
+
* transport over `input`/`output` and, when {@link ConnectNdjsonOptions.initialize}
|
|
105
|
+
* is set, runs the `hubrpc::initialize` handshake internally before resolving.
|
|
106
|
+
*
|
|
107
|
+
* Rejects (after disposing the transport) when the handshake fails — a bad or
|
|
108
|
+
* missing token, a wrong/absent first message, or a timeout. The accepting side
|
|
109
|
+
* should treat a rejection as "drop this connection".
|
|
110
|
+
*/
|
|
111
|
+
async function connectNdjson(opts) {
|
|
112
|
+
const role = opts.initialize;
|
|
113
|
+
let rejectClosedHandshake;
|
|
114
|
+
const closedDuringHandshake = role ? new Promise((_resolve, reject) => {
|
|
115
|
+
rejectClosedHandshake = reject;
|
|
116
|
+
}) : void 0;
|
|
117
|
+
const baseTransport = new NdjsonTransport(opts.input, opts.output, () => {
|
|
118
|
+
try {
|
|
119
|
+
opts.onClose?.();
|
|
120
|
+
} finally {
|
|
121
|
+
rejectClosedHandshake?.(/* @__PURE__ */ new Error("hubrpc::initialize: transport closed during handshake"));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
const transport = opts.trace === void 0 ? baseTransport : traceMessageTransport(baseTransport, opts.trace);
|
|
125
|
+
if (!role) return { transport };
|
|
126
|
+
try {
|
|
127
|
+
const { token } = await Promise.race([runInitializeHandshake(transport, role, { ...opts.handshakeTimeoutMs !== void 0 ? { handshakeTimeoutMs: opts.handshakeTimeoutMs } : {} }), closedDuringHandshake]);
|
|
128
|
+
rejectClosedHandshake = void 0;
|
|
129
|
+
return {
|
|
130
|
+
transport,
|
|
131
|
+
...token !== void 0 ? { token } : {}
|
|
132
|
+
};
|
|
133
|
+
} catch (err) {
|
|
134
|
+
rejectClosedHandshake = void 0;
|
|
135
|
+
transport.dispose();
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Run the `hubrpc::initialize` handshake on an already-constructed transport.
|
|
141
|
+
* Used for transports that the caller builds itself (e.g. WebSocket), where
|
|
142
|
+
* {@link connectNdjson} does not apply. On failure it throws but does NOT
|
|
143
|
+
* dispose the transport — the caller owns it and decides how to tear down.
|
|
144
|
+
*
|
|
145
|
+
* For ndjson links, prefer {@link connectNdjson}, which constructs the
|
|
146
|
+
* transport and runs this handshake atomically.
|
|
147
|
+
*/
|
|
148
|
+
async function runInitializeHandshake(transport, role, options = {}) {
|
|
149
|
+
const timeoutMs = options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
|
150
|
+
if (role.kind === "client") {
|
|
151
|
+
await _runClientHandshake(transport, role.token, timeoutMs);
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
154
|
+
return { token: await _runServerHandshake(transport, role.isTokenAccepted, timeoutMs) };
|
|
155
|
+
}
|
|
156
|
+
async function _runClientHandshake(transport, token, timeoutMs) {
|
|
157
|
+
const reply = _nextMessage(transport, timeoutMs);
|
|
158
|
+
const params = {
|
|
159
|
+
protocolVersion: 1,
|
|
160
|
+
...token !== void 0 ? { token } : {}
|
|
161
|
+
};
|
|
162
|
+
transport.send({
|
|
163
|
+
jsonrpc: "2.0",
|
|
164
|
+
id: INITIALIZE_REQUEST_ID,
|
|
165
|
+
method: INITIALIZE_METHOD,
|
|
166
|
+
params
|
|
167
|
+
});
|
|
168
|
+
const message = await reply;
|
|
169
|
+
if (isRequest(message) || !("id" in message) || message.id !== INITIALIZE_REQUEST_ID) throw new Error("hubrpc::initialize: unexpected reply to handshake");
|
|
170
|
+
if ("error" in message) throw new Error(`hubrpc::initialize rejected: ${message.error.message}`);
|
|
171
|
+
}
|
|
172
|
+
async function _runServerHandshake(transport, isTokenAccepted, timeoutMs) {
|
|
173
|
+
const first = await _nextMessage(transport, timeoutMs);
|
|
174
|
+
if (!isRequest(first) || first.method !== "hubrpc::initialize" && first.method !== "linkrpc::initialize") throw new Error("hubrpc::initialize: expected initialize as the first message");
|
|
175
|
+
const params = first.params ?? {};
|
|
176
|
+
if (!await isTokenAccepted(params.token)) {
|
|
177
|
+
transport.send({
|
|
178
|
+
jsonrpc: "2.0",
|
|
179
|
+
id: first.id,
|
|
180
|
+
error: {
|
|
181
|
+
code: ErrorCode.invalidRequest,
|
|
182
|
+
message: "unauthenticated"
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
throw new Error("hubrpc::initialize: unauthenticated");
|
|
186
|
+
}
|
|
187
|
+
transport.send({
|
|
188
|
+
jsonrpc: "2.0",
|
|
189
|
+
id: first.id,
|
|
190
|
+
result: { protocolVersion: 1 }
|
|
191
|
+
});
|
|
192
|
+
return params.token;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Consume exactly one incoming message, then detach. Relies on
|
|
196
|
+
* {@link NdjsonTransport}'s buffering: setting the listener to `undefined` from
|
|
197
|
+
* inside the callback stops the buffer drain, leaving any later messages queued
|
|
198
|
+
* for the real listener the channel attaches afterwards.
|
|
199
|
+
*/
|
|
200
|
+
function _nextMessage(transport, timeoutMs) {
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
transport.setListener(void 0);
|
|
204
|
+
reject(/* @__PURE__ */ new Error("hubrpc::initialize: handshake timed out"));
|
|
205
|
+
}, timeoutMs);
|
|
206
|
+
transport.setListener((message) => {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
transport.setListener(void 0);
|
|
209
|
+
resolve(message);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/node/stdio.ts
|
|
215
|
+
/**
|
|
216
|
+
* Spawn a child process and connect to its stdio as a linkrpc connection.
|
|
217
|
+
*
|
|
218
|
+
* Pass a single command line (split on whitespace) or an array `[cmd, ...args]`.
|
|
219
|
+
* The child's stderr is piped to the parent's stderr so logs surface naturally.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* const c = await connectToCmdStdio("node ./ghConnector.js");
|
|
223
|
+
* const gh = c.get(ghInterface);
|
|
224
|
+
* await gh.createIssue({ title: "..." });
|
|
225
|
+
*/
|
|
226
|
+
async function connectToCmdStdio(cmd) {
|
|
227
|
+
const parts = typeof cmd === "string" ? cmd.split(/\s+/).filter(Boolean) : [...cmd];
|
|
228
|
+
if (parts.length === 0) throw new Error("connectToCmdStdio: empty command");
|
|
229
|
+
const [bin, ...args] = parts;
|
|
230
|
+
const child = spawn(bin, args, { stdio: [
|
|
231
|
+
"pipe",
|
|
232
|
+
"pipe",
|
|
233
|
+
"inherit"
|
|
234
|
+
] });
|
|
235
|
+
if (!child.stdin || !child.stdout) throw new Error("connectToCmdStdio: child has no stdio");
|
|
236
|
+
const { transport } = await connectNdjson({
|
|
237
|
+
input: child.stdout,
|
|
238
|
+
output: child.stdin,
|
|
239
|
+
onClose: () => {
|
|
240
|
+
if (!child.killed) child.kill();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return LinkRpcConnection.fromTransport(transport);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* For the child side: a linkrpc connection over this process's stdin/stdout.
|
|
247
|
+
* The child MUST NOT write user output to stdout — only RPC messages — or
|
|
248
|
+
* the parent's parser will choke.
|
|
249
|
+
*/
|
|
250
|
+
async function serveOnStdio() {
|
|
251
|
+
const { transport } = await connectNdjson({
|
|
252
|
+
input: process.stdin,
|
|
253
|
+
output: process.stdout
|
|
254
|
+
});
|
|
255
|
+
return LinkRpcConnection.fromTransport(transport);
|
|
256
|
+
}
|
|
257
|
+
async function openStdioChannel() {
|
|
258
|
+
const { transport } = await connectNdjson({
|
|
259
|
+
input: process.stdin,
|
|
260
|
+
output: process.stdout
|
|
261
|
+
});
|
|
262
|
+
const channel = JsonRpcChannel.create(transport);
|
|
263
|
+
const closeListeners = /* @__PURE__ */ new Set();
|
|
264
|
+
let closed = false;
|
|
265
|
+
const fireClose = () => {
|
|
266
|
+
if (closed) return;
|
|
267
|
+
closed = true;
|
|
268
|
+
for (const l of closeListeners) try {
|
|
269
|
+
l();
|
|
270
|
+
} catch {}
|
|
271
|
+
closeListeners.clear();
|
|
272
|
+
};
|
|
273
|
+
process.stdin.on("end", fireClose);
|
|
274
|
+
process.stdin.on("close", fireClose);
|
|
275
|
+
return Object.assign(channel, { onClose: (listener) => {
|
|
276
|
+
if (closed) {
|
|
277
|
+
queueMicrotask(listener);
|
|
278
|
+
return { dispose: () => {} };
|
|
279
|
+
}
|
|
280
|
+
closeListeners.add(listener);
|
|
281
|
+
return { dispose: () => closeListeners.delete(listener) };
|
|
282
|
+
} });
|
|
283
|
+
}
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/node/webSocketClientTransport.ts
|
|
286
|
+
/**
|
|
287
|
+
* Client-side WebSocket transport: one JSON-RPC message per text frame.
|
|
288
|
+
* Mirror of the hub's server-side `WebSocketTransport`, kept here so the
|
|
289
|
+
* core node entry can reach a `ws://` / `wss://` hub without depending on
|
|
290
|
+
* `@hediet/linkrpc-hub`.
|
|
291
|
+
*
|
|
292
|
+
* Uses the platform-global `WebSocket` (Node 22+, browsers), so no `ws`
|
|
293
|
+
* package dependency is required on the client side.
|
|
294
|
+
*
|
|
295
|
+
* Takes ownership of the socket: {@link dispose} closes it, and a peer
|
|
296
|
+
* close / error fires `onClose` exactly once.
|
|
297
|
+
*/
|
|
298
|
+
var WebSocketTransport = class {
|
|
299
|
+
_ws;
|
|
300
|
+
_onClose;
|
|
301
|
+
_listener;
|
|
302
|
+
_buffer = [];
|
|
303
|
+
_closed = false;
|
|
304
|
+
constructor(_ws, _onClose) {
|
|
305
|
+
this._ws = _ws;
|
|
306
|
+
this._onClose = _onClose;
|
|
307
|
+
_ws.binaryType = "arraybuffer";
|
|
308
|
+
_ws.addEventListener("message", (event) => {
|
|
309
|
+
const data = event.data;
|
|
310
|
+
const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? new TextDecoder().decode(data) : ArrayBuffer.isView(data) ? new TextDecoder().decode(data) : String(data);
|
|
311
|
+
for (const line of text.split("\n")) {
|
|
312
|
+
const trimmed = line.trim();
|
|
313
|
+
if (!trimmed) continue;
|
|
314
|
+
let parsed;
|
|
315
|
+
try {
|
|
316
|
+
parsed = JSON.parse(trimmed);
|
|
317
|
+
} catch {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
this._deliver(parsed);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
const onEnd = () => {
|
|
324
|
+
if (this._closed) return;
|
|
325
|
+
this._closed = true;
|
|
326
|
+
this._onClose?.();
|
|
327
|
+
};
|
|
328
|
+
_ws.addEventListener("close", onEnd);
|
|
329
|
+
_ws.addEventListener("error", onEnd);
|
|
330
|
+
}
|
|
331
|
+
send(message) {
|
|
332
|
+
if (this._closed) return;
|
|
333
|
+
if (this._ws.readyState !== WebSocket.OPEN) return;
|
|
334
|
+
this._ws.send(JSON.stringify(message));
|
|
335
|
+
}
|
|
336
|
+
setListener(listener) {
|
|
337
|
+
this._listener = listener;
|
|
338
|
+
if (listener) while (this._buffer.length > 0 && this._listener) {
|
|
339
|
+
const m = this._buffer.shift();
|
|
340
|
+
this._listener(m);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
dispose() {
|
|
344
|
+
if (this._closed) return;
|
|
345
|
+
this._closed = true;
|
|
346
|
+
try {
|
|
347
|
+
this._ws.close();
|
|
348
|
+
} catch {}
|
|
349
|
+
this._onClose?.();
|
|
350
|
+
}
|
|
351
|
+
_deliver(m) {
|
|
352
|
+
if (this._listener) this._listener(m);
|
|
353
|
+
else this._buffer.push(m);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
/**
|
|
357
|
+
* Open a WebSocket to `url` and resolve once it is connected. Rejects if the
|
|
358
|
+
* socket errors before open.
|
|
359
|
+
*
|
|
360
|
+
* Authentication is NOT done here: the hub's WebSocket server authenticates
|
|
361
|
+
* over the in-band `hubrpc::initialize` handshake (so browsers, which cannot
|
|
362
|
+
* set request headers on the `WebSocket` constructor, work too). Run
|
|
363
|
+
* {@link runInitializeHandshake} on the resulting {@link WebSocketTransport}.
|
|
364
|
+
*
|
|
365
|
+
* Headers ride on the upgrade request via the non-standard `headers` option
|
|
366
|
+
* supported by Node's global `WebSocket` (undici); browsers ignore it.
|
|
367
|
+
*/
|
|
368
|
+
function openWebSocket(url, opts = {}) {
|
|
369
|
+
const headers = { ...opts.headers };
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
const ws = new WebSocket(url, { headers });
|
|
372
|
+
const onOpen = () => {
|
|
373
|
+
ws.removeEventListener("error", onError);
|
|
374
|
+
resolve(ws);
|
|
375
|
+
};
|
|
376
|
+
const onError = (event) => {
|
|
377
|
+
ws.removeEventListener("open", onOpen);
|
|
378
|
+
const message = event.message;
|
|
379
|
+
reject(/* @__PURE__ */ new Error(message ? `WebSocket error: ${message}` : "WebSocket connection failed"));
|
|
380
|
+
};
|
|
381
|
+
ws.addEventListener("open", onOpen, { once: true });
|
|
382
|
+
ws.addEventListener("error", onError, { once: true });
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/node/hubClient.ts
|
|
387
|
+
/** Env var the hub publishes for its endpoint (named pipe / UDS path). */
|
|
388
|
+
const LINKRPC_ENDPOINT_VAR = "LINKRPC_ENDPOINT";
|
|
389
|
+
/** Env var the hub publishes for its shared attribution token. */
|
|
390
|
+
const LINKRPC_TOKEN_VAR = "LINKRPC_TOKEN";
|
|
391
|
+
/**
|
|
392
|
+
* Open a connection to a running linkrpc hub. Reads `LINKRPC_ENDPOINT` and
|
|
393
|
+
* `LINKRPC_TOKEN` from the environment unless overridden. Sends the single-
|
|
394
|
+
* line `{"hello":1,"token":"..."}` preamble that the hub's socket server
|
|
395
|
+
* requires, then wraps the socket in an `LinkRpcConnection`.
|
|
396
|
+
*
|
|
397
|
+
* Throws if env vars are missing, if the socket cannot be opened, or if the
|
|
398
|
+
* hub refuses the preamble (the socket simply closes — callers should treat
|
|
399
|
+
* an immediate close of the channel as an auth failure).
|
|
400
|
+
*/
|
|
401
|
+
async function connectToHub(options = {}) {
|
|
402
|
+
const hubChannel = await openHubChannel(options);
|
|
403
|
+
const signing = {
|
|
404
|
+
principal: options.principal,
|
|
405
|
+
oneShotCaps: options.oneShotCaps
|
|
406
|
+
};
|
|
407
|
+
const channel = SigningSender.wrapChannel(hubChannel, signing);
|
|
408
|
+
const connection = new LinkRpcConnection(channel);
|
|
409
|
+
return {
|
|
410
|
+
connection,
|
|
411
|
+
hub: hubFromConnection(connection),
|
|
412
|
+
endpoint: hubChannel.endpoint,
|
|
413
|
+
token: hubChannel.token,
|
|
414
|
+
signing,
|
|
415
|
+
onClose: hubChannel.onClose,
|
|
416
|
+
close: () => {
|
|
417
|
+
connection.close();
|
|
418
|
+
hubChannel.close();
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Open the hub socket and send the preamble, returning the raw
|
|
424
|
+
* {@link HubChannel} without any signing. Use this when you want to
|
|
425
|
+
* compose identity yourself; otherwise prefer {@link connectToHub}.
|
|
426
|
+
*/
|
|
427
|
+
async function openHubChannel(options = {}) {
|
|
428
|
+
const endpoint = options.endpoint ?? process.env["LINKRPC_ENDPOINT"];
|
|
429
|
+
const token = options.token ?? process.env["LINKRPC_TOKEN"] ?? "";
|
|
430
|
+
if (!endpoint) throw new Error(`${LINKRPC_ENDPOINT_VAR} is not set; cannot connect to linkrpc hub.`);
|
|
431
|
+
const closeListeners = /* @__PURE__ */ new Set();
|
|
432
|
+
let closed = false;
|
|
433
|
+
let closeChannel;
|
|
434
|
+
const fireClose = () => {
|
|
435
|
+
if (closed) return;
|
|
436
|
+
closed = true;
|
|
437
|
+
closeChannel?.();
|
|
438
|
+
for (const l of closeListeners) try {
|
|
439
|
+
l();
|
|
440
|
+
} catch {}
|
|
441
|
+
closeListeners.clear();
|
|
442
|
+
};
|
|
443
|
+
const { transport, destroy } = await _openHubTransport(endpoint, token, fireClose);
|
|
444
|
+
const rpc = JsonRpcChannel.createWithClose(transport);
|
|
445
|
+
closeChannel = rpc.close;
|
|
446
|
+
if (closed) rpc.close();
|
|
447
|
+
return Object.assign(rpc.channel, {
|
|
448
|
+
endpoint,
|
|
449
|
+
token,
|
|
450
|
+
onClose: (listener) => {
|
|
451
|
+
if (closed) {
|
|
452
|
+
queueMicrotask(listener);
|
|
453
|
+
return { dispose: () => {} };
|
|
454
|
+
}
|
|
455
|
+
closeListeners.add(listener);
|
|
456
|
+
return { dispose: () => closeListeners.delete(listener) };
|
|
457
|
+
},
|
|
458
|
+
close: () => {
|
|
459
|
+
destroy();
|
|
460
|
+
fireClose();
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
/** True for `ws://` / `wss://` endpoints (case-insensitive). */
|
|
465
|
+
function _isWebSocketEndpoint(endpoint) {
|
|
466
|
+
return /^wss?:\/\//i.test(endpoint);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Open the underlying transport for {@link openHubChannel}, picking the
|
|
470
|
+
* scheme from the endpoint:
|
|
471
|
+
* - `ws://` / `wss://` → WebSocket; the token is presented via the
|
|
472
|
+
* `hubrpc::initialize` handshake (browsers cannot set request headers).
|
|
473
|
+
* - anything else → named pipe / UDS; the token is presented via the
|
|
474
|
+
* `hubrpc::initialize` handshake the socket server requires.
|
|
475
|
+
*
|
|
476
|
+
* `onClose` fires once when the peer ends the transport.
|
|
477
|
+
*/
|
|
478
|
+
async function _openHubTransport(endpoint, token, onClose) {
|
|
479
|
+
if (_isWebSocketEndpoint(endpoint)) {
|
|
480
|
+
const ws = await openWebSocket(endpoint);
|
|
481
|
+
const transport = new WebSocketTransport(ws, onClose);
|
|
482
|
+
const destroy = () => {
|
|
483
|
+
try {
|
|
484
|
+
ws.close();
|
|
485
|
+
} catch {}
|
|
486
|
+
};
|
|
487
|
+
try {
|
|
488
|
+
await runInitializeHandshake(transport, {
|
|
489
|
+
kind: "client",
|
|
490
|
+
token
|
|
491
|
+
});
|
|
492
|
+
} catch (err) {
|
|
493
|
+
transport.dispose();
|
|
494
|
+
destroy();
|
|
495
|
+
throw err;
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
transport,
|
|
499
|
+
destroy
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const socket = await _openSocket(endpoint);
|
|
503
|
+
const { transport } = await connectNdjson({
|
|
504
|
+
input: socket,
|
|
505
|
+
output: socket,
|
|
506
|
+
onClose: () => {
|
|
507
|
+
socket.destroy();
|
|
508
|
+
onClose();
|
|
509
|
+
},
|
|
510
|
+
initialize: {
|
|
511
|
+
kind: "client",
|
|
512
|
+
token
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
return {
|
|
516
|
+
transport,
|
|
517
|
+
destroy: () => socket.destroy()
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
async function registerHubPrefix(opts) {
|
|
521
|
+
let params;
|
|
522
|
+
if (opts.identity) {
|
|
523
|
+
const signer = new KeypairSigningIdentity(opts.identity.principal, opts.identity.keypair.privateKey);
|
|
524
|
+
params = await signParams({
|
|
525
|
+
method: "hubServiceIdRegistry::registerServiceId",
|
|
526
|
+
params: { requestedPrefix: opts.prefix },
|
|
527
|
+
signingIdentity: signer,
|
|
528
|
+
...opts.capabilities && opts.capabilities.length > 0 ? { capabilities: [...opts.capabilities] } : {}
|
|
529
|
+
});
|
|
530
|
+
} else {
|
|
531
|
+
if (opts.capabilities && opts.capabilities.length > 0) throw new Error("registerHubPrefix: capabilities require identity to be set");
|
|
532
|
+
params = { requestedPrefix: opts.prefix };
|
|
533
|
+
}
|
|
534
|
+
await opts.handle.connection.channel.sendRequest("hubServiceIdRegistry::registerServiceId", params);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Load the Ed25519 + X25519 keypairs for `id` from disk, or
|
|
538
|
+
* generate-and-persist them on first call. The slot is keyed by SHA-256 of
|
|
539
|
+
* `id` so callers can pass any stable string (e.g. `import.meta.filename`)
|
|
540
|
+
* without worrying about filesystem-safe characters.
|
|
541
|
+
*
|
|
542
|
+
* Identity files written before wrapping support carried only the Ed25519
|
|
543
|
+
* keypair; on load they are transparently migrated — the Ed25519 keypair
|
|
544
|
+
* (and therefore the `nodeId`) is preserved and a fresh X25519 wrap keypair
|
|
545
|
+
* is generated and written back.
|
|
546
|
+
*
|
|
547
|
+
* NOTE: keys are stored unencrypted. Only suitable for local development
|
|
548
|
+
* identities — not for anything that must resist file-system attackers.
|
|
549
|
+
*/
|
|
550
|
+
async function loadOrCreateIdentity(opts) {
|
|
551
|
+
const file = opts.file ?? path.join(opts.storeDir ?? _defaultStoreDir(), `${_slotName(opts.id)}.json`);
|
|
552
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
553
|
+
try {
|
|
554
|
+
const raw = await fs.readFile(file, "utf8");
|
|
555
|
+
const parsed = JSON.parse(raw);
|
|
556
|
+
if (typeof parsed.privateKey !== "string" || typeof parsed.publicKey !== "string") throw new Error("malformed identity file");
|
|
557
|
+
const privateKey = base64UrlToBytes(parsed.privateKey);
|
|
558
|
+
const publicKey = base64UrlToBytes(parsed.publicKey);
|
|
559
|
+
const keypair = {
|
|
560
|
+
privateKey,
|
|
561
|
+
publicKey
|
|
562
|
+
};
|
|
563
|
+
let wrapKeypair;
|
|
564
|
+
if (typeof parsed.wrapPrivateKey === "string" && typeof parsed.wrapPublicKey === "string") wrapKeypair = {
|
|
565
|
+
privateKey: base64UrlToBytes(parsed.wrapPrivateKey),
|
|
566
|
+
publicKey: base64UrlToBytes(parsed.wrapPublicKey)
|
|
567
|
+
};
|
|
568
|
+
else {
|
|
569
|
+
wrapKeypair = await generateX25519Keypair();
|
|
570
|
+
await _writeIdentityFile(file, keypair, wrapKeypair, opts.id);
|
|
571
|
+
}
|
|
572
|
+
return {
|
|
573
|
+
keypair,
|
|
574
|
+
wrapKeypair,
|
|
575
|
+
principal: principalForPublicKey(publicKey),
|
|
576
|
+
file
|
|
577
|
+
};
|
|
578
|
+
} catch (e) {
|
|
579
|
+
if (e.code !== "ENOENT") throw e;
|
|
580
|
+
}
|
|
581
|
+
const keypair = await generateKeypair();
|
|
582
|
+
const wrapKeypair = await generateX25519Keypair();
|
|
583
|
+
await _writeIdentityFile(file, keypair, wrapKeypair, opts.id);
|
|
584
|
+
return {
|
|
585
|
+
keypair,
|
|
586
|
+
wrapKeypair,
|
|
587
|
+
principal: principalForPublicKey(keypair.publicKey),
|
|
588
|
+
file
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
async function _writeIdentityFile(file, keypair, wrapKeypair, id) {
|
|
592
|
+
const payload = {
|
|
593
|
+
privateKey: bytesToBase64Url(keypair.privateKey),
|
|
594
|
+
publicKey: bytesToBase64Url(keypair.publicKey),
|
|
595
|
+
wrapPrivateKey: bytesToBase64Url(wrapKeypair.privateKey),
|
|
596
|
+
wrapPublicKey: bytesToBase64Url(wrapKeypair.publicKey),
|
|
597
|
+
id
|
|
598
|
+
};
|
|
599
|
+
await fs.writeFile(file, JSON.stringify(payload, null, 2), { mode: 384 });
|
|
600
|
+
}
|
|
601
|
+
function _defaultStoreDir() {
|
|
602
|
+
const home = os.homedir();
|
|
603
|
+
if (process.platform === "win32") {
|
|
604
|
+
const appdata = process.env.APPDATA;
|
|
605
|
+
if (appdata) return path.join(appdata, "hubrpc", "identities");
|
|
606
|
+
return path.join(home, "AppData", "Roaming", "hubrpc", "identities");
|
|
607
|
+
}
|
|
608
|
+
if (process.platform === "darwin") return path.join(home, "Library", "Application Support", "hubrpc", "identities");
|
|
609
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
610
|
+
if (xdg) return path.join(xdg, "hubrpc", "identities");
|
|
611
|
+
return path.join(home, ".config", "hubrpc", "identities");
|
|
612
|
+
}
|
|
613
|
+
function _slotName(id) {
|
|
614
|
+
const digest = sha256(new TextEncoder().encode(id));
|
|
615
|
+
let out = "";
|
|
616
|
+
for (let i = 0; i < 16; i++) out += digest[i].toString(16).padStart(2, "0");
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
async function loadPersistedRegisterCap(args) {
|
|
620
|
+
const file = _capFilePath(args.identityId, args.prefix, args.storeDir);
|
|
621
|
+
try {
|
|
622
|
+
const raw = await fs.readFile(file, "utf8");
|
|
623
|
+
const parsed = JSON.parse(raw);
|
|
624
|
+
if (!parsed.capability) return void 0;
|
|
625
|
+
if (parsed.capability.expiresAtMs !== void 0 && parsed.capability.expiresAtMs < Date.now()) return;
|
|
626
|
+
return {
|
|
627
|
+
capability: parsed.capability,
|
|
628
|
+
file
|
|
629
|
+
};
|
|
630
|
+
} catch (e) {
|
|
631
|
+
if (e.code !== "ENOENT") throw e;
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function persistRegisterCap(args) {
|
|
636
|
+
const file = _capFilePath(args.identityId, args.prefix, args.storeDir);
|
|
637
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
638
|
+
await fs.writeFile(file, JSON.stringify({ capability: args.capability }, null, 2), { mode: 384 });
|
|
639
|
+
return file;
|
|
640
|
+
}
|
|
641
|
+
function _capFilePath(identityId, prefix, storeDir) {
|
|
642
|
+
const dir = storeDir ?? _defaultStoreDir();
|
|
643
|
+
const slot = _slotName(identityId);
|
|
644
|
+
return path.join(dir, `${slot}-cap-${_slotName(prefix)}.json`);
|
|
645
|
+
}
|
|
646
|
+
function _openSocket(endpoint) {
|
|
647
|
+
return new Promise((resolve, reject) => {
|
|
648
|
+
const socket = net.createConnection(endpoint);
|
|
649
|
+
const onError = (err) => {
|
|
650
|
+
socket.removeListener("connect", onConnect);
|
|
651
|
+
reject(err);
|
|
652
|
+
};
|
|
653
|
+
const onConnect = () => {
|
|
654
|
+
socket.removeListener("error", onError);
|
|
655
|
+
resolve(socket);
|
|
656
|
+
};
|
|
657
|
+
socket.once("error", onError);
|
|
658
|
+
socket.once("connect", onConnect);
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/node/hubReconnect.ts
|
|
663
|
+
/**
|
|
664
|
+
* Connect to the hub, register `prefix`, run `setup`, and keep the
|
|
665
|
+
* connection alive across hub restarts. When the socket closes, retries
|
|
666
|
+
* with exponential backoff (re-reading `LINKRPC_ENDPOINT` / `LINKRPC_TOKEN`
|
|
667
|
+
* from `process.env` on each attempt so rotated tokens published by the
|
|
668
|
+
* hub host get picked up if the parent shell re-exports them).
|
|
669
|
+
*
|
|
670
|
+
* The hub keeps recently-rotated tokens accepted for a grace window, so
|
|
671
|
+
* even processes that captured the env at spawn time can usually reconnect
|
|
672
|
+
* after a host reload without the parent re-exporting anything.
|
|
673
|
+
*/
|
|
674
|
+
function serveOverHubWithReconnect(options) {
|
|
675
|
+
const log = _makeLog(options.log, options.name ?? options.prefix);
|
|
676
|
+
const initial = options.initialBackoffMs ?? 200;
|
|
677
|
+
const max = options.maxBackoffMs ?? 5e3;
|
|
678
|
+
let stopped = false;
|
|
679
|
+
let current;
|
|
680
|
+
const stop = () => {
|
|
681
|
+
if (stopped) return;
|
|
682
|
+
stopped = true;
|
|
683
|
+
current?.close();
|
|
684
|
+
current = void 0;
|
|
685
|
+
};
|
|
686
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
687
|
+
const claimer = options.identity ? createHubPrefixClaimer({
|
|
688
|
+
prefix: options.prefix,
|
|
689
|
+
identity: options.identity,
|
|
690
|
+
log
|
|
691
|
+
}) : void 0;
|
|
692
|
+
return {
|
|
693
|
+
stop,
|
|
694
|
+
done: (async () => {
|
|
695
|
+
let attempt = 0;
|
|
696
|
+
let backoff = initial;
|
|
697
|
+
while (!stopped) try {
|
|
698
|
+
if (attempt > 0) log(`reconnecting (attempt ${attempt + 1})…`);
|
|
699
|
+
const handle = await connectToHub();
|
|
700
|
+
current = handle;
|
|
701
|
+
if (claimer) await claimer.claim(handle);
|
|
702
|
+
else await registerHubPrefix({
|
|
703
|
+
handle,
|
|
704
|
+
prefix: options.prefix
|
|
705
|
+
});
|
|
706
|
+
await options.setup(handle.connection);
|
|
707
|
+
log(attempt === 0 ? `connected to hub at ${handle.endpoint} (prefix "${options.prefix}")` : `reconnected to hub (prefix "${options.prefix}")`);
|
|
708
|
+
attempt = 0;
|
|
709
|
+
backoff = initial;
|
|
710
|
+
await new Promise((resolve) => {
|
|
711
|
+
handle.onClose(() => resolve());
|
|
712
|
+
});
|
|
713
|
+
if (stopped) break;
|
|
714
|
+
log(`disconnected from hub`);
|
|
715
|
+
current = void 0;
|
|
716
|
+
} catch (err) {
|
|
717
|
+
if (stopped) break;
|
|
718
|
+
current?.close();
|
|
719
|
+
current = void 0;
|
|
720
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
721
|
+
log(`connect failed: ${msg}; retrying in ${backoff} ms`);
|
|
722
|
+
await _delay(backoff, options.signal);
|
|
723
|
+
attempt++;
|
|
724
|
+
backoff = Math.min(max, backoff * 2);
|
|
725
|
+
}
|
|
726
|
+
})()
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Build a reusable, stateful claimer for the signed register + capability
|
|
731
|
+
* flow. Use this with {@link connectService}'s `reconnecting` connector to
|
|
732
|
+
* claim a prefix on each (re)connect:
|
|
733
|
+
*
|
|
734
|
+
* ```ts
|
|
735
|
+
* const claimer = createHubPrefixClaimer({ prefix, identity: { slot } });
|
|
736
|
+
* connectService({
|
|
737
|
+
* connector: reconnecting(async () => {
|
|
738
|
+
* const handle = await connectToHub();
|
|
739
|
+
* try { await claimer.claim(handle); } catch (e) { handle.close(); throw e; }
|
|
740
|
+
* return handle;
|
|
741
|
+
* }),
|
|
742
|
+
* onConnect: ({ connection }) => registerService(connection),
|
|
743
|
+
* });
|
|
744
|
+
* ```
|
|
745
|
+
*/
|
|
746
|
+
function createHubPrefixClaimer(opts) {
|
|
747
|
+
const log = opts.log ?? (() => {});
|
|
748
|
+
let identity;
|
|
749
|
+
let cachedCap;
|
|
750
|
+
let capLoaded = false;
|
|
751
|
+
return { async claim(handle) {
|
|
752
|
+
if (!identity) {
|
|
753
|
+
const loaded = await loadOrCreateIdentity({
|
|
754
|
+
id: opts.identity.slot,
|
|
755
|
+
...opts.identity.storeDir !== void 0 ? { storeDir: opts.identity.storeDir } : {}
|
|
756
|
+
});
|
|
757
|
+
identity = {
|
|
758
|
+
principal: loaded.principal,
|
|
759
|
+
keypair: loaded.keypair
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
if (!capLoaded) {
|
|
763
|
+
cachedCap = (await loadPersistedRegisterCap({
|
|
764
|
+
identityId: opts.identity.slot,
|
|
765
|
+
prefix: opts.prefix,
|
|
766
|
+
...opts.identity.storeDir !== void 0 ? { storeDir: opts.identity.storeDir } : {}
|
|
767
|
+
}))?.capability;
|
|
768
|
+
capLoaded = true;
|
|
769
|
+
}
|
|
770
|
+
cachedCap = await _registerSigned(handle, opts.prefix, identity, cachedCap, opts.identity, log);
|
|
771
|
+
} };
|
|
772
|
+
}
|
|
773
|
+
const DEFAULT_ISSUER_METHOD = "hub-admin::hub-admin.capabilities::issueRegisterCap";
|
|
774
|
+
/**
|
|
775
|
+
* Try `registerHubPrefix` with `cachedCap`. On `permissionRequired`, call
|
|
776
|
+
* the hub-admin issuer to mint a fresh cap, persist it, retry. Returns the
|
|
777
|
+
* cap that actually worked (caller stores it for the next reconnect).
|
|
778
|
+
*/
|
|
779
|
+
async function _registerSigned(handle, prefix, identity, cachedCap, identityOpts, log) {
|
|
780
|
+
try {
|
|
781
|
+
await registerHubPrefix({
|
|
782
|
+
handle,
|
|
783
|
+
prefix,
|
|
784
|
+
identity,
|
|
785
|
+
...cachedCap ? { capabilities: [cachedCap] } : {}
|
|
786
|
+
});
|
|
787
|
+
return cachedCap;
|
|
788
|
+
} catch (e) {
|
|
789
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
790
|
+
if (!_isPermissionRequired(e) || identityOpts.issuerMethod === null) throw e;
|
|
791
|
+
log(`register denied (${msg}); requesting capability from hub-admin issuer…`);
|
|
792
|
+
}
|
|
793
|
+
const issuerMethod = identityOpts.issuerMethod ?? DEFAULT_ISSUER_METHOD;
|
|
794
|
+
const signer = new KeypairSigningIdentity(identity.principal, identity.keypair.privateKey);
|
|
795
|
+
const signedIssueParams = await signParams({
|
|
796
|
+
method: issuerMethod,
|
|
797
|
+
params: {
|
|
798
|
+
audience: identity.principal,
|
|
799
|
+
prefix,
|
|
800
|
+
...identityOpts.purpose !== void 0 ? { purpose: identityOpts.purpose } : {}
|
|
801
|
+
},
|
|
802
|
+
signingIdentity: signer
|
|
803
|
+
});
|
|
804
|
+
const issueResp = await handle.connection.channel.sendRequest(issuerMethod, signedIssueParams);
|
|
805
|
+
if (!issueResp?.capability) throw new Error("hub-admin issuer returned no capability");
|
|
806
|
+
const fresh = issueResp.capability;
|
|
807
|
+
await persistRegisterCap({
|
|
808
|
+
identityId: identityOpts.slot,
|
|
809
|
+
prefix,
|
|
810
|
+
capability: fresh,
|
|
811
|
+
...identityOpts.storeDir !== void 0 ? { storeDir: identityOpts.storeDir } : {}
|
|
812
|
+
});
|
|
813
|
+
await registerHubPrefix({
|
|
814
|
+
handle,
|
|
815
|
+
prefix,
|
|
816
|
+
identity,
|
|
817
|
+
capabilities: [fresh]
|
|
818
|
+
});
|
|
819
|
+
return fresh;
|
|
820
|
+
}
|
|
821
|
+
function _isPermissionRequired(e) {
|
|
822
|
+
if (e === null || typeof e !== "object") return false;
|
|
823
|
+
const rec = e;
|
|
824
|
+
if (typeof rec.code === "number") return rec.code === ErrorCode.permissionRequired;
|
|
825
|
+
const msg = e.message ?? "";
|
|
826
|
+
return /permission required/i.test(msg);
|
|
827
|
+
}
|
|
828
|
+
function _makeLog(sink, name) {
|
|
829
|
+
if (!sink) return () => {};
|
|
830
|
+
const write = sink === true ? (line) => {
|
|
831
|
+
process.stderr.write(line + "\n");
|
|
832
|
+
} : sink;
|
|
833
|
+
return (line) => write(`[linkrpc:${name}] ${line}`);
|
|
834
|
+
}
|
|
835
|
+
function _delay(ms, signal) {
|
|
836
|
+
return new Promise((resolve) => {
|
|
837
|
+
if (signal?.aborted) {
|
|
838
|
+
resolve();
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const t = setTimeout(() => {
|
|
842
|
+
signal?.removeEventListener("abort", onAbort);
|
|
843
|
+
resolve();
|
|
844
|
+
}, ms);
|
|
845
|
+
const onAbort = () => {
|
|
846
|
+
clearTimeout(t);
|
|
847
|
+
resolve();
|
|
848
|
+
};
|
|
849
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
//#endregion
|
|
853
|
+
//#region src/node/fileManagedIdentityStorage.ts
|
|
854
|
+
/**
|
|
855
|
+
* A {@link ManagedIdentityStorage} backed by a single JSON file (one object
|
|
856
|
+
* keyed by the storage keys). Suitable for self-managed identities that
|
|
857
|
+
* persist their capability bag locally — e.g. pass it to `CapBag.load`.
|
|
858
|
+
*
|
|
859
|
+
* The file is created (mode 0o600) on first write; reads of a missing file
|
|
860
|
+
* yield an empty store. Not concurrency-safe across processes.
|
|
861
|
+
*/
|
|
862
|
+
function fileManagedIdentityStorage(file) {
|
|
863
|
+
const read = async () => {
|
|
864
|
+
try {
|
|
865
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
866
|
+
} catch (e) {
|
|
867
|
+
if (e.code === "ENOENT") return {};
|
|
868
|
+
throw e;
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
const write = async (data) => {
|
|
872
|
+
await mkdir(dirname(file), { recursive: true });
|
|
873
|
+
await writeFile(file, JSON.stringify(data, null, 2), { mode: 384 });
|
|
874
|
+
};
|
|
875
|
+
return {
|
|
876
|
+
get: async (key) => (await read())[key],
|
|
877
|
+
set: async (key, value) => {
|
|
878
|
+
const data = await read();
|
|
879
|
+
data[key] = value;
|
|
880
|
+
await write(data);
|
|
881
|
+
},
|
|
882
|
+
delete: async (key) => {
|
|
883
|
+
const data = await read();
|
|
884
|
+
const existed = key in data;
|
|
885
|
+
delete data[key];
|
|
886
|
+
await write(data);
|
|
887
|
+
return existed;
|
|
888
|
+
},
|
|
889
|
+
list: async (prefix = "") => Object.keys(await read()).filter((k) => k.startsWith(prefix))
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region src/node/principal.ts
|
|
894
|
+
/**
|
|
895
|
+
* Self-managed principal: a local Ed25519 + X25519 keypair (created/loaded on
|
|
896
|
+
* disk) signs and wraps/unwraps for outbound calls, and granted caps are
|
|
897
|
+
* cached in a sibling file. Purely local — no transport needed — and
|
|
898
|
+
* cheap/idempotent, so it can be re-derived on each reconnect. Used with
|
|
899
|
+
* `--use-non-managed-identity`.
|
|
900
|
+
*/
|
|
901
|
+
async function createSelfManagedPrincipal(identityKey) {
|
|
902
|
+
return _selfManagedPrincipal({ id: identityKey });
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Self-managed principal whose keypair lives at an explicit file path (rather
|
|
906
|
+
* than a slot derived from an id). Caps are cached in a sibling `.caps.json`.
|
|
907
|
+
* Used by callers that want to pin the identity to a specific file, e.g. the
|
|
908
|
+
* CLI's `--principal file:<path>`.
|
|
909
|
+
*/
|
|
910
|
+
async function createSelfManagedPrincipalFromFile(file) {
|
|
911
|
+
return _selfManagedPrincipal({
|
|
912
|
+
id: `file:${file}`,
|
|
913
|
+
file
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
async function _selfManagedPrincipal(opts) {
|
|
917
|
+
const id = await loadOrCreateIdentity({
|
|
918
|
+
id: opts.id,
|
|
919
|
+
file: opts.file
|
|
920
|
+
});
|
|
921
|
+
const capsFile = id.file.endsWith(".json") ? id.file.replace(/\.json$/, ".caps.json") : `${id.file}.caps.json`;
|
|
922
|
+
const capBag = await CapBag.load({ storage: fileManagedIdentityStorage(capsFile) });
|
|
923
|
+
const identity = new KeypairIdentity(id.principal, id.keypair.privateKey, id.wrapKeypair);
|
|
924
|
+
return new Principal(identity, capBag);
|
|
925
|
+
}
|
|
926
|
+
//#endregion
|
|
927
|
+
//#region src/node/headerDelimitedTransport.ts
|
|
928
|
+
const HEADER_SEPARATOR = Buffer.from("\r\n\r\n");
|
|
929
|
+
const DEFAULT_MAX_HEADER_BYTES = 65536;
|
|
930
|
+
const DEFAULT_MAX_CONTENT_LENGTH = 67108864;
|
|
931
|
+
const DEFAULT_MAX_PENDING_MESSAGES = 1024;
|
|
932
|
+
/**
|
|
933
|
+
* LSP "base protocol" framing over a Node Readable/Writable pair.
|
|
934
|
+
*
|
|
935
|
+
* The streams remain owned by the caller: close/dispose detach listeners but
|
|
936
|
+
* never end, destroy, or otherwise take ownership of either stream.
|
|
937
|
+
*/
|
|
938
|
+
var HeaderDelimitedTransport = class {
|
|
939
|
+
_input;
|
|
940
|
+
_output;
|
|
941
|
+
_onClose;
|
|
942
|
+
_listener;
|
|
943
|
+
_pending = [];
|
|
944
|
+
_residual = Buffer.alloc(0);
|
|
945
|
+
_expectedLength;
|
|
946
|
+
_closed = false;
|
|
947
|
+
_closeReason;
|
|
948
|
+
_maxHeaderBytes;
|
|
949
|
+
_maxContentLength;
|
|
950
|
+
_maxPendingMessages;
|
|
951
|
+
_onDataListener = (chunk) => this._onData(chunk);
|
|
952
|
+
_onEndListener = () => this._onEnd();
|
|
953
|
+
_onInputErrorListener = (error) => this.close(`Header-delimited input error: ${error.message}`);
|
|
954
|
+
_onOutputErrorListener = (error) => this.close(`Header-delimited output error: ${error.message}`);
|
|
955
|
+
constructor(_input, _output, _onClose, options = {}) {
|
|
956
|
+
this._input = _input;
|
|
957
|
+
this._output = _output;
|
|
958
|
+
this._onClose = _onClose;
|
|
959
|
+
this._maxHeaderBytes = positiveLimit(options.maxHeaderBytes, DEFAULT_MAX_HEADER_BYTES, "maxHeaderBytes");
|
|
960
|
+
this._maxContentLength = positiveLimit(options.maxContentLength, DEFAULT_MAX_CONTENT_LENGTH, "maxContentLength");
|
|
961
|
+
this._maxPendingMessages = positiveLimit(options.maxPendingMessages, DEFAULT_MAX_PENDING_MESSAGES, "maxPendingMessages");
|
|
962
|
+
_input.on("data", this._onDataListener);
|
|
963
|
+
_input.on("end", this._onEndListener);
|
|
964
|
+
_input.on("close", this._onEndListener);
|
|
965
|
+
_input.on("error", this._onInputErrorListener);
|
|
966
|
+
_output.on("error", this._onOutputErrorListener);
|
|
967
|
+
}
|
|
968
|
+
get closed() {
|
|
969
|
+
return this._closed;
|
|
970
|
+
}
|
|
971
|
+
get closeReason() {
|
|
972
|
+
return this._closeReason;
|
|
973
|
+
}
|
|
974
|
+
send(message) {
|
|
975
|
+
if (this._closed) return Promise.reject(new Error(this._closeReason ?? "Header-delimited transport is closed"));
|
|
976
|
+
const body = Buffer.from(JSON.stringify(message), "utf8");
|
|
977
|
+
if (body.length > this._maxContentLength) return Promise.reject(/* @__PURE__ */ new Error(`Header-delimited payload exceeds ${this._maxContentLength} bytes`));
|
|
978
|
+
const wire = Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"), body]);
|
|
979
|
+
return new Promise((resolve, reject) => {
|
|
980
|
+
this._output.write(wire, (error) => {
|
|
981
|
+
if (error) reject(error);
|
|
982
|
+
else resolve();
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
setListener(listener) {
|
|
987
|
+
this._listener = listener;
|
|
988
|
+
while (this._listener && this._pending.length > 0) this._listener(this._pending.shift());
|
|
989
|
+
}
|
|
990
|
+
dispose() {
|
|
991
|
+
this.close("Header-delimited transport disposed");
|
|
992
|
+
}
|
|
993
|
+
close(reason = "Header-delimited transport closed") {
|
|
994
|
+
if (this._closed) return;
|
|
995
|
+
this._closed = true;
|
|
996
|
+
this._closeReason = reason;
|
|
997
|
+
this._input.off("data", this._onDataListener);
|
|
998
|
+
this._input.off("end", this._onEndListener);
|
|
999
|
+
this._input.off("close", this._onEndListener);
|
|
1000
|
+
this._input.off("error", this._onInputErrorListener);
|
|
1001
|
+
this._output.off("error", this._onOutputErrorListener);
|
|
1002
|
+
this._onClose?.(reason);
|
|
1003
|
+
}
|
|
1004
|
+
_onData(chunk) {
|
|
1005
|
+
if (this._closed) return;
|
|
1006
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
1007
|
+
this._residual = this._residual.length === 0 ? Buffer.from(bytes) : Buffer.concat([this._residual, bytes]);
|
|
1008
|
+
while (!this._closed) {
|
|
1009
|
+
if (this._expectedLength === void 0) {
|
|
1010
|
+
const headerEnd = this._residual.indexOf(HEADER_SEPARATOR);
|
|
1011
|
+
if (headerEnd < 0) {
|
|
1012
|
+
if (this._residual.length > this._maxHeaderBytes) this.close(`Invalid header-delimited frame: header exceeds ${this._maxHeaderBytes} bytes`);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (headerEnd + HEADER_SEPARATOR.length > this._maxHeaderBytes) {
|
|
1016
|
+
this.close(`Invalid header-delimited frame: header exceeds ${this._maxHeaderBytes} bytes`);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
this._expectedLength = parseHeaders(this._residual.subarray(0, headerEnd), this._maxContentLength);
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
this.close(`Invalid header-delimited frame: ${error instanceof Error ? error.message : String(error)}`);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
this._residual = this._residual.subarray(headerEnd + HEADER_SEPARATOR.length);
|
|
1026
|
+
}
|
|
1027
|
+
if (this._residual.length < this._expectedLength) return;
|
|
1028
|
+
const body = this._residual.subarray(0, this._expectedLength);
|
|
1029
|
+
this._residual = this._residual.subarray(this._expectedLength);
|
|
1030
|
+
this._expectedLength = void 0;
|
|
1031
|
+
let parsed;
|
|
1032
|
+
try {
|
|
1033
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
this.close(`Invalid header-delimited JSON payload: ${error instanceof Error ? error.message : String(error)}`);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
this._deliver(parsed);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
_onEnd() {
|
|
1042
|
+
if (this._closed) return;
|
|
1043
|
+
this.close(this._expectedLength !== void 0 || this._residual.length > 0 ? "Truncated header-delimited frame" : "Header-delimited input closed");
|
|
1044
|
+
}
|
|
1045
|
+
_deliver(message) {
|
|
1046
|
+
if (this._listener) {
|
|
1047
|
+
this._listener(message);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (this._pending.length >= this._maxPendingMessages) {
|
|
1051
|
+
this.close(`Header-delimited pending message limit ${this._maxPendingMessages} exceeded`);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
this._pending.push(message);
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
function parseHeaders(headerBytes, maxContentLength) {
|
|
1058
|
+
const values = /* @__PURE__ */ new Map();
|
|
1059
|
+
for (const line of headerBytes.toString("ascii").split("\r\n")) {
|
|
1060
|
+
const colon = line.indexOf(":");
|
|
1061
|
+
if (colon <= 0) throw new Error(`malformed header ${JSON.stringify(line)}`);
|
|
1062
|
+
const name = line.slice(0, colon).trim().toLowerCase();
|
|
1063
|
+
const value = line.slice(colon + 1).trim();
|
|
1064
|
+
if (values.has(name)) throw new Error(`duplicate ${name} header`);
|
|
1065
|
+
values.set(name, value);
|
|
1066
|
+
}
|
|
1067
|
+
const rawLength = values.get("content-length");
|
|
1068
|
+
if (rawLength === void 0 || !/^(0|[1-9]\d*)$/.test(rawLength)) throw new Error("missing or invalid Content-Length header");
|
|
1069
|
+
const length = Number(rawLength);
|
|
1070
|
+
if (!Number.isSafeInteger(length) || length > maxContentLength) throw new Error(`Content-Length exceeds ${maxContentLength} bytes`);
|
|
1071
|
+
const contentType = values.get("content-type");
|
|
1072
|
+
if (contentType !== void 0 && !isSupportedContentType(contentType)) throw new Error(`unsupported Content-Type ${JSON.stringify(contentType)}`);
|
|
1073
|
+
return length;
|
|
1074
|
+
}
|
|
1075
|
+
function isSupportedContentType(value) {
|
|
1076
|
+
const parts = value.split(";").map((part) => part.trim().toLowerCase());
|
|
1077
|
+
return parts[0] === "application/vscode-jsonrpc" && parts.slice(1).every((part) => /^charset\s*=\s*"?utf-?8"?$/.test(part));
|
|
1078
|
+
}
|
|
1079
|
+
function positiveLimit(value, fallback, name) {
|
|
1080
|
+
const result = value ?? fallback;
|
|
1081
|
+
if (!Number.isSafeInteger(result) || result <= 0) throw new Error(`${name} must be a positive integer`);
|
|
1082
|
+
return result;
|
|
1083
|
+
}
|
|
1084
|
+
//#endregion
|
|
1085
|
+
export { CapBag, Channel, ChannelConnector, DEFAULT_RPC_TIMEOUT_MS, EXTENSION_PREFIX, ErrorCode, HUBRPC_META_KEY, HUBRPC_SIGNATURE_KEY, HUBRPC_UNSIGNED_KEY, HeaderDelimitedTransport, INITIALIZE_METHOD, INITIALIZE_PROTOCOL_VERSION, InMemoryManagedIdentity, InMemoryManagedIdentityStorage, InterfaceDefinition, JsonRpcChannel, KEY_ID_PREFIX, KeypairIdentity, KeypairSigningIdentity, LINKRPC_ENDPOINT_VAR, LINKRPC_INITIALIZE_METHOD_ALIAS, LINKRPC_META_KEY, LINKRPC_SIGNATURE_KEY, LINKRPC_TOKEN_VAR, LINKRPC_UNSIGNED_KEY, LinkRpcConnection, MultiplexedTransport, NotificationType, OneShotCapStaging, PRINCIPAL_PREFIX, Principal, PrincipalWithStore, PublicSigningIdentity, PublicWrappingIdentity, RequestType, RpcError, STREAM_METHOD, ServiceHandle, SigningSender, StreamControlReason, StreamControlType, StreamDir, TransportPair, WebSocketTransport, assertSchemaReferences, attachCapabilities, bareInterfaceTarget, base64UrlToBytes, bytesToBase64Url, capBagFreshAt, capabilityFreshAt, capabilityPermits, componentSchemaName, componentSchemaRef, computeInterfaceHash, connectNdjson, connectToCmdStdio, connectToHub, connectTransports, createHubPrefixClaimer, createManagedIdentity, createManagedPrincipal, createMemoryPrincipal, createSchemaToZod, createSeededMemoryPrincipal, createSeededSigningIdentity, createSelfManagedPrincipal, createSelfManagedPrincipalFromFile, crypto_exports as crypto, defaultsInterface, defineInterface, directoryInterface, directoryWatchNever, fileManagedIdentityStorage, formatEndpointUri, generateTsInterface, getKeyId, hasSignedCapabilityShape, identityInterface, identityStorageInterface, interfaceFromSchema, invoke, isAssignable, isHubEndpoint, isNotification, isRequest, isResponse, issueCapability, jcsCanonicalize, jcsCanonicalizeBytes, keyIdForPrincipal, keyIdForPublicKey, loadOrCreateIdentity, matchParams, materializeJsonSchema, methodNameToTarget, nodeInterface, normalizeJsonSchema, notificationType, openHubChannel, openStdioChannel, openWebSocket, parseEndpointUri, parseMethodName, permissionMatchesTarget, permissionPermits, permits, prefix, principalForPublicKey, publicKeyForKeyId, readSignature, registerHubPrefix, registerIdentityOnOverlay, registerLazyIdentityOnOverlay, requestType, requireObjectParams, resolveSigningKey, runInitializeHandshake, schemaToZod, schemasInterface, serveOnStdio, serveOverHubWithReconnect, signCapability, signParams, signRpcCall, signedHash, signingDomainValue, signingInput, streamInterface, stripLinkRpcWireMeta, topologyInterface, traceMessageTransport, trafficInterface, verifyCall, verifyRpcCall, withRpcTimeout, withSignature, zParticipantDescriptor, zParticipantDescriptorSource, zRouteClaim, zServiceIdPattern, zTopologyGraph, zTopologyLink, zTopologyLinkEndpoint, zTopologyNode, zTopologyPort, zTopologyTransportEndpoint, zTopologyTransportInfo, zTrafficEvent, zTrafficOverflowEvent, zTrafficTransitEndpoint, zTrafficTransitEvent, zTrafficWatchResult, zodToSvcJsonSchema };
|
|
1086
|
+
|
|
1087
|
+
//# sourceMappingURL=node.js.map
|