@hediet/linkrpc-hub 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 +101 -0
- package/dist/chunks/config-BCvkg7jv.d.ts +566 -0
- package/dist/chunks/configFile-y6Phntqu.d.ts +9 -0
- package/dist/chunks/connectionTokenBinder.interfaces-B-beCg06.js +40 -0
- package/dist/chunks/connectionTokenBinder.interfaces-B-beCg06.js.map +1 -0
- package/dist/chunks/connectionTokenBinder.interfaces-BhzQ1DTS.d.ts +36 -0
- package/dist/chunks/hubConnectionAcceptor-B5-8JsFY.js +2768 -0
- package/dist/chunks/hubConnectionAcceptor-B5-8JsFY.js.map +1 -0
- package/dist/chunks/hubConnectionAcceptor-BwydvFa4.d.ts +1560 -0
- package/dist/chunks/index-CLIUrV88.d.ts +481 -0
- package/dist/chunks/node-CTXsQ6oa.js +460 -0
- package/dist/chunks/node-CTXsQ6oa.js.map +1 -0
- package/dist/chunks/nodeTransit-CWeFnbwt.js +444 -0
- package/dist/chunks/nodeTransit-CWeFnbwt.js.map +1 -0
- package/dist/chunks/nodeTransit-cmtZgdpW.d.ts +226 -0
- package/dist/chunks/runHub-P3YUwwdv.js +1797 -0
- package/dist/chunks/runHub-P3YUwwdv.js.map +1 -0
- package/dist/chunks/server-BAxchQhy.js +1368 -0
- package/dist/chunks/server-BAxchQhy.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +38 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +305 -0
- package/dist/config.js.map +1 -0
- package/dist/configFile.d.ts +2 -0
- package/dist/configFile.js +27 -0
- package/dist/configFile.js.map +1 -0
- package/dist/engine/runHub.d.ts +42 -0
- package/dist/engine/runHub.js +2 -0
- package/dist/hub/server/client.d.ts +2 -0
- package/dist/hub/server/client.js +2 -0
- package/dist/hub/server/connectionTokenBinder.d.ts +2 -0
- package/dist/hub/server/connectionTokenBinder.js +2 -0
- package/dist/hub/server/index.d.ts +5 -0
- package/dist/hub/server/index.js +5 -0
- package/dist/hub/server/node/index.d.ts +218 -0
- package/dist/hub/server/node/index.js +2 -0
- package/dist/hub/server/transit.d.ts +2 -0
- package/dist/hub/server/transit.js +2 -0
- package/dist/index.d.ts +512 -0
- package/dist/index.js +309 -0
- package/dist/index.js.map +1 -0
- package/dist/serve.d.ts +14 -0
- package/dist/serve.js +31 -0
- package/dist/serve.js.map +1 -0
- package/dist/spawn.d.ts +12 -0
- package/dist/spawn.js +25 -0
- package/dist/spawn.js.map +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,2768 @@
|
|
|
1
|
+
import { t as connectionTokenBinderInterface } from "./connectionTokenBinder.interfaces-B-beCg06.js";
|
|
2
|
+
import { ErrorCode, LinkRpcConnection, RpcError, STREAM_METHOD, StreamControlReason, StreamControlType, StreamDir, TransportPair, directoryInterface, directoryWatchNever, isNotification, isRequest, isResponse, methodNameToTarget, nodeInterface, parseMethodName, permits, registerLazyIdentityOnOverlay, schemasInterface, verifyCall, verifyRpcCall } from "@hediet/linkrpc";
|
|
3
|
+
import { ROOT_SERVICE_ID, SERVICE_ID_SEPARATOR, hubGrantedServiceIdInterface, hubServiceIdRegistryInterface, isServiceIdUnder, isValidServiceId, normalizeServiceIdScopes, serviceIdMatchesScopes } from "@hediet/linkrpc/hub/common";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { BoundedTrafficSubscription, TrafficWatchFlowTracker, nodeInterface as nodeInterface$1, topologyInterface, trafficInterface } from "@hediet/linkrpc/inspection";
|
|
6
|
+
import { safeParse } from "zod/v4/core";
|
|
7
|
+
//#region src/hub/server/routing/forwardingTable.ts
|
|
8
|
+
/**
|
|
9
|
+
* A longest-prefix routing table keyed by {@link ServiceId} prefixes — the
|
|
10
|
+
* hub's equivalent of an IP forwarding table.
|
|
11
|
+
*
|
|
12
|
+
* A prefix `"a/b"` owns every serviceId equal to it or beneath it
|
|
13
|
+
* (`"a/b"`, `"a/b/c"`, …) *unless* a longer claimed prefix matches first.
|
|
14
|
+
* Matching is segment-aware: `"a/bc"` is **not** under `"a/b"`.
|
|
15
|
+
*
|
|
16
|
+
* The table is intentionally a plain stateful container with no opinion on
|
|
17
|
+
* *who* may claim what — that policy lives in {@link Hub.claimPrefix}. This
|
|
18
|
+
* keeps the data structure trivially testable in isolation.
|
|
19
|
+
*/
|
|
20
|
+
var ForwardingTable = class {
|
|
21
|
+
_entries = /* @__PURE__ */ new Map();
|
|
22
|
+
/**
|
|
23
|
+
* Reverse index: value → the set of prefixes claimed for it. Kept in
|
|
24
|
+
* lock-step with {@link _entries} so {@link deleteByValue} (used when a
|
|
25
|
+
* link detaches) is O(claims-for-that-value) instead of an O(size) scan.
|
|
26
|
+
*/
|
|
27
|
+
_prefixesByValue = /* @__PURE__ */ new Map();
|
|
28
|
+
/** Number of claimed prefixes. */
|
|
29
|
+
get size() {
|
|
30
|
+
return this._entries.size;
|
|
31
|
+
}
|
|
32
|
+
has(prefix) {
|
|
33
|
+
return this._entries.has(prefix);
|
|
34
|
+
}
|
|
35
|
+
get(prefix) {
|
|
36
|
+
return this._entries.get(prefix);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Claim `prefix` for `value`. Overwrites any existing claim — callers
|
|
40
|
+
* that want claim-once semantics must check {@link has} first (the hub
|
|
41
|
+
* does).
|
|
42
|
+
*/
|
|
43
|
+
set(prefix, value) {
|
|
44
|
+
const prev = this._entries.get(prefix);
|
|
45
|
+
if (prev !== void 0 && prev !== value) this._dropFromIndex(prev, prefix);
|
|
46
|
+
this._entries.set(prefix, value);
|
|
47
|
+
let prefixes = this._prefixesByValue.get(value);
|
|
48
|
+
if (!prefixes) {
|
|
49
|
+
prefixes = /* @__PURE__ */ new Set();
|
|
50
|
+
this._prefixesByValue.set(value, prefixes);
|
|
51
|
+
}
|
|
52
|
+
prefixes.add(prefix);
|
|
53
|
+
}
|
|
54
|
+
delete(prefix) {
|
|
55
|
+
const value = this._entries.get(prefix);
|
|
56
|
+
if (value === void 0) return false;
|
|
57
|
+
this._entries.delete(prefix);
|
|
58
|
+
this._dropFromIndex(value, prefix);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Remove every claim bound to `value` (matched by reference/equality).
|
|
63
|
+
* O(number of prefixes that value owns). Returns the removed prefixes.
|
|
64
|
+
*/
|
|
65
|
+
deleteByValue(value) {
|
|
66
|
+
const prefixes = this._prefixesByValue.get(value);
|
|
67
|
+
if (!prefixes) return [];
|
|
68
|
+
this._prefixesByValue.delete(value);
|
|
69
|
+
for (const prefix of prefixes) this._entries.delete(prefix);
|
|
70
|
+
return [...prefixes];
|
|
71
|
+
}
|
|
72
|
+
_dropFromIndex(value, prefix) {
|
|
73
|
+
const prefixes = this._prefixesByValue.get(value);
|
|
74
|
+
if (!prefixes) return;
|
|
75
|
+
prefixes.delete(prefix);
|
|
76
|
+
if (prefixes.size === 0) this._prefixesByValue.delete(value);
|
|
77
|
+
}
|
|
78
|
+
entries() {
|
|
79
|
+
return this._entries.entries();
|
|
80
|
+
}
|
|
81
|
+
prefixes() {
|
|
82
|
+
return [...this._entries.keys()];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Find the value owning the longest claimed prefix of `serviceId`.
|
|
86
|
+
* Walks up the `'/'` segments — `"a/b/c"` tries `"a/b/c"`, `"a/b"`,
|
|
87
|
+
* `"a"` in that order — and returns the first hit, or `undefined` if
|
|
88
|
+
* none of its ancestors are claimed.
|
|
89
|
+
*/
|
|
90
|
+
longestPrefixMatch(serviceId) {
|
|
91
|
+
let candidate = serviceId;
|
|
92
|
+
while (candidate.length > 0) {
|
|
93
|
+
const value = this._entries.get(candidate);
|
|
94
|
+
if (value !== void 0) return {
|
|
95
|
+
prefix: candidate,
|
|
96
|
+
value
|
|
97
|
+
};
|
|
98
|
+
const slash = candidate.lastIndexOf(SERVICE_ID_SEPARATOR);
|
|
99
|
+
if (slash === -1) break;
|
|
100
|
+
candidate = candidate.slice(0, slash);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Validate a forwarding-table **prefix**: a non-root {@link ServiceId}. The
|
|
106
|
+
* root (`""`) is a valid service id but cannot be claimed as a prefix (it
|
|
107
|
+
* would capture every address); the uplink is the route of last resort
|
|
108
|
+
* instead. Returns an error string, or `undefined` when well-formed.
|
|
109
|
+
*/
|
|
110
|
+
function validatePrefix(prefix) {
|
|
111
|
+
if (typeof prefix !== "string" || prefix === ROOT_SERVICE_ID) return "prefix required";
|
|
112
|
+
if (!isValidServiceId(prefix)) return "prefix must be a valid serviceId (no leading/trailing/empty '/' segments)";
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/hub/server/routing/participantRoots.ts
|
|
116
|
+
/** Reconciles participant-root queries and watches over the Hub's routing links. */
|
|
117
|
+
var ParticipantRoots = class {
|
|
118
|
+
_options;
|
|
119
|
+
constructor(_options) {
|
|
120
|
+
this._options = _options;
|
|
121
|
+
}
|
|
122
|
+
async query(method, params, excludePrefix) {
|
|
123
|
+
return (await Promise.all([...this._participantLinks(excludePrefix)].map((link) => this._options.requestOnLink(link, method, params).then((result) => result, () => void 0)))).filter((result) => result !== void 0);
|
|
124
|
+
}
|
|
125
|
+
watch(method, params, onTick, excludePrefix) {
|
|
126
|
+
const watches = /* @__PURE__ */ new Map();
|
|
127
|
+
let disposed = false;
|
|
128
|
+
const stopState = (state) => {
|
|
129
|
+
if (state.retry !== void 0) clearTimeout(state.retry);
|
|
130
|
+
state.retry = void 0;
|
|
131
|
+
state.watch?.dispose();
|
|
132
|
+
state.watch = void 0;
|
|
133
|
+
};
|
|
134
|
+
const start = (link, state) => {
|
|
135
|
+
if (disposed || !this._participantLinks(excludePrefix).has(link)) return;
|
|
136
|
+
state.watch = this._options.watchOnLink(link, method, params, onTick, () => {
|
|
137
|
+
queueMicrotask(() => {
|
|
138
|
+
if (disposed || watches.get(link) !== state) return;
|
|
139
|
+
state.watch = void 0;
|
|
140
|
+
state.retry = setTimeout(() => {
|
|
141
|
+
state.retry = void 0;
|
|
142
|
+
start(link, state);
|
|
143
|
+
}, state.retryDelayMs);
|
|
144
|
+
state.retryDelayMs = Math.min(5e3, state.retryDelayMs * 2);
|
|
145
|
+
});
|
|
146
|
+
}, () => {
|
|
147
|
+
state.retryDelayMs = 200;
|
|
148
|
+
onTick();
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
const reconcile = () => {
|
|
152
|
+
const current = this._participantLinks(excludePrefix);
|
|
153
|
+
for (const [link, state] of watches) {
|
|
154
|
+
if (current.has(link)) continue;
|
|
155
|
+
stopState(state);
|
|
156
|
+
watches.delete(link);
|
|
157
|
+
}
|
|
158
|
+
for (const link of current) {
|
|
159
|
+
if (watches.has(link)) continue;
|
|
160
|
+
const state = { retryDelayMs: 200 };
|
|
161
|
+
watches.set(link, state);
|
|
162
|
+
start(link, state);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
reconcile();
|
|
166
|
+
const unsubscribe = this._options.onDidChangeRouting(reconcile);
|
|
167
|
+
return { dispose: () => {
|
|
168
|
+
if (disposed) return;
|
|
169
|
+
disposed = true;
|
|
170
|
+
unsubscribe();
|
|
171
|
+
for (const state of watches.values()) stopState(state);
|
|
172
|
+
watches.clear();
|
|
173
|
+
} };
|
|
174
|
+
}
|
|
175
|
+
_participantLinks(excludePrefix) {
|
|
176
|
+
const exclude = excludePrefix !== void 0 ? this._options.prefixOwner(excludePrefix) : void 0;
|
|
177
|
+
const uplink = this._options.uplink();
|
|
178
|
+
const links = /* @__PURE__ */ new Set();
|
|
179
|
+
for (const [, link] of this._options.forwardingEntries()) {
|
|
180
|
+
if (link === exclude || link === uplink) continue;
|
|
181
|
+
links.add(link);
|
|
182
|
+
}
|
|
183
|
+
return links;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/hub/server/routing/peerDiscovery.ts
|
|
188
|
+
const GET_NODE_ID_METHOD = `${nodeInterface$1.info.id}::getNodeId`;
|
|
189
|
+
/** Background policy for direct-peer identity; forwarding never waits for it. */
|
|
190
|
+
var PeerDiscovery = class {
|
|
191
|
+
_options;
|
|
192
|
+
_peers = /* @__PURE__ */ new WeakMap();
|
|
193
|
+
_timeoutMs;
|
|
194
|
+
constructor(_options) {
|
|
195
|
+
this._options = _options;
|
|
196
|
+
this._timeoutMs = _options.timeoutMs ?? 1e3;
|
|
197
|
+
if (!Number.isFinite(this._timeoutMs) || this._timeoutMs <= 0) throw new Error("peerIdentificationTimeoutMs must be a positive finite number");
|
|
198
|
+
}
|
|
199
|
+
attach(link) {
|
|
200
|
+
if (this._peers.has(link)) return;
|
|
201
|
+
const state = {
|
|
202
|
+
status: "pending",
|
|
203
|
+
retryDelayMs: 200
|
|
204
|
+
};
|
|
205
|
+
this._peers.set(link, state);
|
|
206
|
+
this._schedule(link, state, 0);
|
|
207
|
+
}
|
|
208
|
+
detach(link) {
|
|
209
|
+
const state = this._peers.get(link);
|
|
210
|
+
if (state?.timer !== void 0) clearTimeout(state.timer);
|
|
211
|
+
this._peers.delete(link);
|
|
212
|
+
}
|
|
213
|
+
info(link) {
|
|
214
|
+
return this._peers.get(link)?.info;
|
|
215
|
+
}
|
|
216
|
+
status(link) {
|
|
217
|
+
return this._peers.get(link)?.status ?? "pending";
|
|
218
|
+
}
|
|
219
|
+
identify(link) {
|
|
220
|
+
const state = this._peers.get(link);
|
|
221
|
+
if (state === void 0) return Promise.reject(/* @__PURE__ */ new Error("identifyPeer: link is detached"));
|
|
222
|
+
if (state.info !== void 0) return Promise.resolve(state.info);
|
|
223
|
+
if (state.attempt !== void 0) return state.attempt;
|
|
224
|
+
if (state.timer !== void 0) clearTimeout(state.timer);
|
|
225
|
+
state.timer = void 0;
|
|
226
|
+
const attempt = Promise.resolve().then(async () => {
|
|
227
|
+
if (this._peers.get(link) !== state) throw new Error("identifyPeer: link is detached");
|
|
228
|
+
const raw = await this._options.request(link, this._timeoutMs);
|
|
229
|
+
const parsed = safeParse(nodeInterface$1.members.getNodeId.resultSchema, raw);
|
|
230
|
+
if (!parsed.success || parsed.data.nodeId.length === 0 || parsed.data.portId.length === 0) throw new Error(`identifyPeer: invalid result from ${GET_NODE_ID_METHOD}`);
|
|
231
|
+
const info = parsed.data;
|
|
232
|
+
if (this._peers.get(link) !== state) throw new Error("identifyPeer: link is detached");
|
|
233
|
+
state.info = info;
|
|
234
|
+
state.status = "identified";
|
|
235
|
+
this._options.onDidChange();
|
|
236
|
+
return info;
|
|
237
|
+
}).catch((error) => {
|
|
238
|
+
if (this._peers.get(link) === state) {
|
|
239
|
+
const status = error instanceof RpcError && error.code === ErrorCode.methodNotFound ? "unsupported" : "error";
|
|
240
|
+
if (state.status !== status) {
|
|
241
|
+
state.status = status;
|
|
242
|
+
this._options.onDidChange();
|
|
243
|
+
}
|
|
244
|
+
const delay = state.status === "unsupported" ? 3e4 : state.retryDelayMs;
|
|
245
|
+
state.retryDelayMs = Math.min(5e3, state.retryDelayMs * 2);
|
|
246
|
+
this._schedule(link, state, delay * (.8 + Math.random() * .4));
|
|
247
|
+
}
|
|
248
|
+
throw new Error(`identifyPeer: ${GET_NODE_ID_METHOD} failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
249
|
+
}).finally(() => {
|
|
250
|
+
state.attempt = void 0;
|
|
251
|
+
});
|
|
252
|
+
state.attempt = attempt;
|
|
253
|
+
return attempt;
|
|
254
|
+
}
|
|
255
|
+
_schedule(link, state, delayMs) {
|
|
256
|
+
if (this._peers.get(link) !== state) return;
|
|
257
|
+
state.timer = setTimeout(() => {
|
|
258
|
+
state.timer = void 0;
|
|
259
|
+
if (this._peers.get(link) !== state) return;
|
|
260
|
+
this.identify(link).catch(() => {});
|
|
261
|
+
}, delayMs);
|
|
262
|
+
state.timer.unref();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/hub/server/routing/routingTopology.ts
|
|
267
|
+
/**
|
|
268
|
+
* Owns the Hub's inspection-facing topology state. Routing remains in Hub;
|
|
269
|
+
* this class only consumes snapshots and low-level request callbacks.
|
|
270
|
+
*/
|
|
271
|
+
var RoutingTopology = class {
|
|
272
|
+
_options;
|
|
273
|
+
_portIds = /* @__PURE__ */ new WeakMap();
|
|
274
|
+
_handleLinks = /* @__PURE__ */ new WeakMap();
|
|
275
|
+
_peers;
|
|
276
|
+
_inspectionServices = /* @__PURE__ */ new WeakMap();
|
|
277
|
+
_managedRoutingTopologies = /* @__PURE__ */ new WeakMap();
|
|
278
|
+
_managedTopologyFragments = /* @__PURE__ */ new Set();
|
|
279
|
+
constructor(_options) {
|
|
280
|
+
this._options = _options;
|
|
281
|
+
this._peers = new PeerDiscovery({
|
|
282
|
+
timeoutMs: _options.peerIdentificationTimeoutMs,
|
|
283
|
+
request: (link, timeoutMs) => _options.requestOnLink(link, GET_NODE_ID_METHOD, {}, timeoutMs),
|
|
284
|
+
onDidChange: _options.onDidChange
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
attachLink(link) {
|
|
288
|
+
this._peers.attach(link);
|
|
289
|
+
}
|
|
290
|
+
portId(link) {
|
|
291
|
+
let id = this._portIds.get(link);
|
|
292
|
+
if (id === void 0) {
|
|
293
|
+
id = this._options.generateTopologyId("port");
|
|
294
|
+
this._portIds.set(link, id);
|
|
295
|
+
}
|
|
296
|
+
return id;
|
|
297
|
+
}
|
|
298
|
+
registerHandle(handle, link) {
|
|
299
|
+
this._handleLinks.set(handle, link);
|
|
300
|
+
}
|
|
301
|
+
identifyPeer(link) {
|
|
302
|
+
return this._peers.identify(link);
|
|
303
|
+
}
|
|
304
|
+
registerManagedRoutingTopology(attached, topology) {
|
|
305
|
+
const link = this._attachedLink(attached, "registerManagedRoutingTopology");
|
|
306
|
+
this._managedRoutingTopologies.set(link, topology);
|
|
307
|
+
this._options.onDidChange();
|
|
308
|
+
let disposed = false;
|
|
309
|
+
return { dispose: () => {
|
|
310
|
+
if (disposed) return;
|
|
311
|
+
disposed = true;
|
|
312
|
+
if (this._managedRoutingTopologies.get(link) !== topology) return;
|
|
313
|
+
this._managedRoutingTopologies.delete(link);
|
|
314
|
+
this._options.onDidChange();
|
|
315
|
+
} };
|
|
316
|
+
}
|
|
317
|
+
registerManagedTopologyFragment(fragment) {
|
|
318
|
+
this._managedTopologyFragments.add(fragment);
|
|
319
|
+
this._options.onDidChange();
|
|
320
|
+
let disposed = false;
|
|
321
|
+
return { dispose: () => {
|
|
322
|
+
if (disposed) return;
|
|
323
|
+
disposed = true;
|
|
324
|
+
this._managedTopologyFragments.delete(fragment);
|
|
325
|
+
this._options.onDidChange();
|
|
326
|
+
} };
|
|
327
|
+
}
|
|
328
|
+
markInspectionService(attached, serviceId) {
|
|
329
|
+
const link = this._attachedLink(attached, "markInspectionService");
|
|
330
|
+
let services = this._inspectionServices.get(link);
|
|
331
|
+
if (services === void 0) {
|
|
332
|
+
services = /* @__PURE__ */ new Set();
|
|
333
|
+
this._inspectionServices.set(link, services);
|
|
334
|
+
}
|
|
335
|
+
services.add(serviceId);
|
|
336
|
+
}
|
|
337
|
+
isInspectionService(link, serviceId) {
|
|
338
|
+
return this._inspectionServices.get(link)?.has(serviceId) === true;
|
|
339
|
+
}
|
|
340
|
+
getTopologyGraph(observerServiceId) {
|
|
341
|
+
const allLinks = [...this._options.links()];
|
|
342
|
+
const visibleLinks = allLinks.filter((link) => !this._inspectionServices.has(link));
|
|
343
|
+
const hubPorts = allLinks.map((link) => ({
|
|
344
|
+
portId: this.portId(link),
|
|
345
|
+
label: this._options.edgeId(link)
|
|
346
|
+
}));
|
|
347
|
+
for (const link of visibleLinks) for (const hubLink of this._managedRoutingTopologies.get(link)?.hubLinks ?? []) if (!hubPorts.some((port) => port.portId === hubLink.hubPortId)) hubPorts.push({
|
|
348
|
+
portId: hubLink.hubPortId,
|
|
349
|
+
label: hubLink.label ?? hubLink.hubPortId
|
|
350
|
+
});
|
|
351
|
+
const nodes = [{
|
|
352
|
+
nodeId: this._options.nodeId,
|
|
353
|
+
kind: "hub",
|
|
354
|
+
...this._options.debugName !== void 0 ? { label: this._options.debugName } : {},
|
|
355
|
+
...this._options.descriptors !== void 0 ? { descriptors: [...this._options.descriptors] } : {},
|
|
356
|
+
ports: hubPorts
|
|
357
|
+
}];
|
|
358
|
+
const peerNodes = /* @__PURE__ */ new Map();
|
|
359
|
+
const links = [];
|
|
360
|
+
for (const link of visibleLinks) {
|
|
361
|
+
const peer = this._topologyPeer(link);
|
|
362
|
+
const managed = this._managedRoutingTopologies.get(link);
|
|
363
|
+
let peerNode = peerNodes.get(peer.nodeId);
|
|
364
|
+
if (peerNode === void 0) {
|
|
365
|
+
peerNode = {
|
|
366
|
+
nodeId: peer.nodeId,
|
|
367
|
+
ports: []
|
|
368
|
+
};
|
|
369
|
+
peerNodes.set(peer.nodeId, peerNode);
|
|
370
|
+
nodes.push(peerNode);
|
|
371
|
+
}
|
|
372
|
+
if (!peerNode.ports.some((port) => port.portId === peer.portId)) peerNode.ports.push({ portId: peer.portId });
|
|
373
|
+
if (managed !== void 0) {
|
|
374
|
+
nodes.push({
|
|
375
|
+
...managed.node,
|
|
376
|
+
ports: [...managed.node.ports]
|
|
377
|
+
});
|
|
378
|
+
for (const adjacentNode of managed.adjacentNodes ?? []) nodes.push({
|
|
379
|
+
...adjacentNode,
|
|
380
|
+
ports: [...adjacentNode.ports]
|
|
381
|
+
});
|
|
382
|
+
for (const hubLink of managed.hubLinks) links.push({
|
|
383
|
+
from: {
|
|
384
|
+
nodeId: this._options.nodeId,
|
|
385
|
+
portId: hubLink.hubPortId
|
|
386
|
+
},
|
|
387
|
+
to: {
|
|
388
|
+
nodeId: managed.node.nodeId,
|
|
389
|
+
portId: hubLink.nodePortId
|
|
390
|
+
},
|
|
391
|
+
...hubLink.label !== void 0 ? { label: hubLink.label } : {}
|
|
392
|
+
});
|
|
393
|
+
links.push({
|
|
394
|
+
from: {
|
|
395
|
+
nodeId: managed.node.nodeId,
|
|
396
|
+
portId: managed.peerPortId
|
|
397
|
+
},
|
|
398
|
+
to: peer,
|
|
399
|
+
...managed.peerLinkLabel !== void 0 ? { label: managed.peerLinkLabel } : {},
|
|
400
|
+
...managed.peerTransport !== void 0 ? { transport: managed.peerTransport } : {},
|
|
401
|
+
peerState: this._peers.status(link)
|
|
402
|
+
});
|
|
403
|
+
links.push(...managed.adjacentLinks ?? []);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const transport = this._options.transportInfo(link);
|
|
407
|
+
links.push({
|
|
408
|
+
from: {
|
|
409
|
+
nodeId: this._options.nodeId,
|
|
410
|
+
portId: this.portId(link)
|
|
411
|
+
},
|
|
412
|
+
to: peer,
|
|
413
|
+
label: this._options.edgeId(link),
|
|
414
|
+
peerState: this._peers.status(link),
|
|
415
|
+
...transport !== void 0 ? { transport } : {}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const routes = [];
|
|
419
|
+
for (const [serviceId, link] of this._options.forwardingEntries()) {
|
|
420
|
+
if (this.isInspectionService(link, serviceId)) {
|
|
421
|
+
routes.push({
|
|
422
|
+
serviceId,
|
|
423
|
+
nodeId: this._options.nodeId,
|
|
424
|
+
portId: this.portId(link),
|
|
425
|
+
match: "prefix"
|
|
426
|
+
});
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const peer = this._topologyPeer(link);
|
|
430
|
+
routes.push({
|
|
431
|
+
serviceId,
|
|
432
|
+
nodeId: peer.nodeId,
|
|
433
|
+
portId: peer.portId,
|
|
434
|
+
match: "prefix"
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
for (const fragment of this._managedTopologyFragments) {
|
|
438
|
+
nodes.push(...fragment.nodes.map((node) => ({
|
|
439
|
+
...node,
|
|
440
|
+
ports: [...node.ports]
|
|
441
|
+
})));
|
|
442
|
+
links.push(...fragment.links ?? []);
|
|
443
|
+
routes.push(...fragment.routes ?? []);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
observerServiceId,
|
|
447
|
+
entryNodeId: this._options.nodeId,
|
|
448
|
+
nodes,
|
|
449
|
+
links,
|
|
450
|
+
routes
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
cleanupLink(link) {
|
|
454
|
+
this._inspectionServices.delete(link);
|
|
455
|
+
this._peers.detach(link);
|
|
456
|
+
this._managedRoutingTopologies.delete(link);
|
|
457
|
+
}
|
|
458
|
+
_attachedLink(attached, operation) {
|
|
459
|
+
const link = this._handleLinks.get(attached);
|
|
460
|
+
if (link === void 0 || !this._options.isAttached(link)) throw new Error(`${operation}: link is not attached to this hub`);
|
|
461
|
+
return link;
|
|
462
|
+
}
|
|
463
|
+
_topologyPeer(link) {
|
|
464
|
+
const identified = this._peers.info(link);
|
|
465
|
+
if (identified !== void 0) return identified;
|
|
466
|
+
const localPortId = this.portId(link);
|
|
467
|
+
return {
|
|
468
|
+
nodeId: `${this._options.nodeId}:unidentified:${localPortId}`,
|
|
469
|
+
portId: `unidentified:${localPortId}`
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
//#endregion
|
|
474
|
+
//#region src/hub/server/routing/routingHub.ts
|
|
475
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 18e5;
|
|
476
|
+
/**
|
|
477
|
+
* A **Hub** is the entire routing concept of linkrpc v2 — the only noun.
|
|
478
|
+
* It is a tiny message router with an unauthenticated topology-correlation
|
|
479
|
+
* identity and exactly three moving
|
|
480
|
+
* parts, all expressed as {@link IMessageTransport} links:
|
|
481
|
+
*
|
|
482
|
+
* - **forwarding table** — longest-prefix serviceId → link map (the
|
|
483
|
+
* downstream neighbours / claimed subnets).
|
|
484
|
+
* - **loopback** — an optional link to the local root services (`bare`/
|
|
485
|
+
* `interface`-form calls land here and *never* leave the hub). This is
|
|
486
|
+
* the routing equivalent of `127.0.0.1`.
|
|
487
|
+
* - **uplink** — an optional default route. A hub *with* an uplink is also
|
|
488
|
+
* a participant of its parent hub, which is how hubs **nest** (a VS Code
|
|
489
|
+
* window hub uplinks to an OS hub uplinks to a home-server hub).
|
|
490
|
+
*
|
|
491
|
+
* Routing is pure longest-prefix forwarding with JSON-RPC id rewriting:
|
|
492
|
+
* a forwarded request keeps its full method name verbatim; only its `id`
|
|
493
|
+
* is swapped for a hub-local one so responses can be demultiplexed back to
|
|
494
|
+
* the originating link. There is no notion of identity, capabilities, or
|
|
495
|
+
* trust at this layer — those are policies layered on top.
|
|
496
|
+
*
|
|
497
|
+
* The single privileged write seam is {@link claimPrefix}; everything else
|
|
498
|
+
* is mechanical message shuffling.
|
|
499
|
+
*/
|
|
500
|
+
var Hub = class {
|
|
501
|
+
_options;
|
|
502
|
+
/** Unauthenticated topology-correlation identity for this hub. */
|
|
503
|
+
nodeId;
|
|
504
|
+
_table = new ForwardingTable();
|
|
505
|
+
_links = /* @__PURE__ */ new Set();
|
|
506
|
+
_pending = /* @__PURE__ */ new Map();
|
|
507
|
+
_uplink;
|
|
508
|
+
_loopback;
|
|
509
|
+
_nextId = 1;
|
|
510
|
+
_log;
|
|
511
|
+
_transitObservers = /* @__PURE__ */ new Set();
|
|
512
|
+
/**
|
|
513
|
+
* Listeners notified whenever the forwarding table changes (a prefix is
|
|
514
|
+
* claimed or released, or a link detaches dropping its claims). Coarse, by
|
|
515
|
+
* design: it carries no payload — consumers re-query the directory. This is
|
|
516
|
+
* the "poll now" nudge that backs `hubrpc.directory::watch`.
|
|
517
|
+
*/
|
|
518
|
+
_routingListeners = /* @__PURE__ */ new Set();
|
|
519
|
+
_topologyListeners = /* @__PURE__ */ new Set();
|
|
520
|
+
/** Stable inspection label per link; assigned lazily / by prefix claim. */
|
|
521
|
+
_edgeIds = /* @__PURE__ */ new WeakMap();
|
|
522
|
+
_transportInfo = /* @__PURE__ */ new WeakMap();
|
|
523
|
+
_nextEdgeId = 1;
|
|
524
|
+
_topology;
|
|
525
|
+
_participantRoots;
|
|
526
|
+
/** Per-request idle timeout in ms; `<= 0` disables. See {@link HubOptions.idleTimeoutMs}. */
|
|
527
|
+
_idleMs;
|
|
528
|
+
constructor(_options = {}) {
|
|
529
|
+
this._options = _options;
|
|
530
|
+
const generateTopologyId = _options.generateTopologyId ?? (() => randomUUID());
|
|
531
|
+
this.nodeId = _options.nodeId ?? generateTopologyId("node");
|
|
532
|
+
this._log = _options.logger;
|
|
533
|
+
if (_options.onTransit !== void 0) this._transitObservers.add(_options.onTransit);
|
|
534
|
+
this._idleMs = _options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
535
|
+
this._topology = new RoutingTopology({
|
|
536
|
+
nodeId: this.nodeId,
|
|
537
|
+
generateTopologyId,
|
|
538
|
+
debugName: _options.debugName,
|
|
539
|
+
descriptors: _options.descriptors,
|
|
540
|
+
peerIdentificationTimeoutMs: _options.peerIdentificationTimeoutMs,
|
|
541
|
+
links: () => this._links,
|
|
542
|
+
forwardingEntries: () => this._table.entries(),
|
|
543
|
+
edgeId: (link) => this._edgeId(link),
|
|
544
|
+
transportInfo: (link) => this._transportInfo.get(link),
|
|
545
|
+
isAttached: (link) => this._links.has(link),
|
|
546
|
+
requestOnLink: (link, method, params, timeoutMs) => this._requestOnLink(link, method, params, timeoutMs),
|
|
547
|
+
onDidChange: () => this._fireTopologyChanged()
|
|
548
|
+
});
|
|
549
|
+
this._participantRoots = new ParticipantRoots({
|
|
550
|
+
forwardingEntries: () => this._table.entries(),
|
|
551
|
+
prefixOwner: (prefix) => this._table.get(prefix),
|
|
552
|
+
uplink: () => this._uplink,
|
|
553
|
+
requestOnLink: (link, method, params) => this._requestOnLink(link, method, params),
|
|
554
|
+
watchOnLink: (link, method, params, onTick, onSettled, onEstablished) => this._watchOnLink(link, method, params, onTick, onSettled, onEstablished),
|
|
555
|
+
onDidChangeRouting: (listener) => this.onDidChangeRouting(listener)
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/** Prefix for log lines; identifies this hub when nested. */
|
|
559
|
+
_tag() {
|
|
560
|
+
return this._options.debugName !== void 0 ? `[${this._options.debugName}] ` : "";
|
|
561
|
+
}
|
|
562
|
+
/** Stable inspection label for a link; auto-assigned on first use. */
|
|
563
|
+
_edgeId(link) {
|
|
564
|
+
let id = this._edgeIds.get(link);
|
|
565
|
+
if (id === void 0) {
|
|
566
|
+
id = `e${this._nextEdgeId++}`;
|
|
567
|
+
this._edgeIds.set(link, id);
|
|
568
|
+
}
|
|
569
|
+
return id;
|
|
570
|
+
}
|
|
571
|
+
/** Give `link` a friendly inspection label (prefix / loopback / uplink). */
|
|
572
|
+
_labelEdge(link, label) {
|
|
573
|
+
const cur = this._edgeIds.get(link);
|
|
574
|
+
if (cur === void 0 || /^e\d+$/.test(cur)) this._edgeIds.set(link, label);
|
|
575
|
+
}
|
|
576
|
+
/** Emit a pre-built node transit. Callers guard construction by observer count. */
|
|
577
|
+
_emit(t) {
|
|
578
|
+
for (const observer of [...this._transitObservers]) try {
|
|
579
|
+
observer(t);
|
|
580
|
+
} catch (err) {
|
|
581
|
+
this._log?.warn(`${this._tag()}transit observer threw: ${String(err)}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
_transitEndpoint(link, requestId) {
|
|
585
|
+
return {
|
|
586
|
+
edgeId: this._edgeId(link),
|
|
587
|
+
portId: this._topology.portId(link),
|
|
588
|
+
...requestId !== void 0 ? { requestId } : {}
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/** Number of live transit observers. */
|
|
592
|
+
get transitObserverCount() {
|
|
593
|
+
return this._transitObservers.size;
|
|
594
|
+
}
|
|
595
|
+
/** Observe routed messages until the returned handle is disposed. */
|
|
596
|
+
observeTransits(observer) {
|
|
597
|
+
this._transitObservers.add(observer);
|
|
598
|
+
let disposed = false;
|
|
599
|
+
return { dispose: () => {
|
|
600
|
+
if (disposed) return;
|
|
601
|
+
disposed = true;
|
|
602
|
+
this._transitObservers.delete(observer);
|
|
603
|
+
} };
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Publish a transit from a routing node managed by this Hub. Callers should
|
|
607
|
+
* check {@link transitObserverCount} before constructing the transit.
|
|
608
|
+
*/
|
|
609
|
+
publishManagedTransit(transit) {
|
|
610
|
+
if (this._transitObservers.size === 0) return;
|
|
611
|
+
this._emit(transit);
|
|
612
|
+
}
|
|
613
|
+
/** Replace an attached link's direct topology edge with a managed routing node. */
|
|
614
|
+
registerManagedRoutingTopology(attached, topology) {
|
|
615
|
+
return this._topology.registerManagedRoutingTopology(attached, topology);
|
|
616
|
+
}
|
|
617
|
+
/** Add an in-process endpoint or routing fragment to this Hub's topology. */
|
|
618
|
+
registerManagedTopologyFragment(fragment) {
|
|
619
|
+
return this._topology.registerManagedTopologyFragment(fragment);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Subscribe to coarse routing-table changes (prefix claim/release/detach).
|
|
623
|
+
* The listener receives no arguments — it is a "something changed; re-query"
|
|
624
|
+
* nudge. Returns an unsubscribe function.
|
|
625
|
+
*/
|
|
626
|
+
onDidChangeRouting(listener) {
|
|
627
|
+
this._routingListeners.add(listener);
|
|
628
|
+
return () => {
|
|
629
|
+
this._routingListeners.delete(listener);
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/** Subscribe to topology changes, including routing and background peer discovery. */
|
|
633
|
+
onDidChangeTopology(listener) {
|
|
634
|
+
this._topologyListeners.add(listener);
|
|
635
|
+
return () => {
|
|
636
|
+
this._topologyListeners.delete(listener);
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/** Number of active routing invalidation observers. */
|
|
640
|
+
get routingObserverCount() {
|
|
641
|
+
return this._routingListeners.size;
|
|
642
|
+
}
|
|
643
|
+
get topologyObserverCount() {
|
|
644
|
+
return this._topologyListeners.size;
|
|
645
|
+
}
|
|
646
|
+
/** Fire all routing-change listeners. Best-effort; one throwing listener does not block the rest. */
|
|
647
|
+
_fireRoutingChanged() {
|
|
648
|
+
this._notifyListeners(this._routingListeners);
|
|
649
|
+
this._fireTopologyChanged();
|
|
650
|
+
}
|
|
651
|
+
_fireTopologyChanged() {
|
|
652
|
+
this._notifyListeners(this._topologyListeners);
|
|
653
|
+
}
|
|
654
|
+
_notifyListeners(listeners) {
|
|
655
|
+
for (const listener of [...listeners]) try {
|
|
656
|
+
listener();
|
|
657
|
+
} catch (err) {
|
|
658
|
+
this._log?.warn(`${this._tag()}change listener threw: ${String(err)}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/** Emit a stream transit with the owning request's correlated endpoints. */
|
|
662
|
+
_emitStream(note, inEp, outEp) {
|
|
663
|
+
if (this._transitObservers.size === 0) return;
|
|
664
|
+
this._emit({
|
|
665
|
+
timeMs: Date.now(),
|
|
666
|
+
nodeId: this.nodeId,
|
|
667
|
+
in: inEp,
|
|
668
|
+
out: outEp,
|
|
669
|
+
disposition: "forwarded",
|
|
670
|
+
kind: "stream",
|
|
671
|
+
method: STREAM_METHOD,
|
|
672
|
+
params: note.params
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Snapshot the requests currently in flight through this hub. Each row is
|
|
677
|
+
* one {@link _pending} entry — the authoritative in-flight set — annotated
|
|
678
|
+
* with its method, the edges it bridges, and how long it has waited. Useful
|
|
679
|
+
* for surfacing stuck/slow requests in an inspector.
|
|
680
|
+
*/
|
|
681
|
+
pendingRequests() {
|
|
682
|
+
const now = Date.now();
|
|
683
|
+
const out = [];
|
|
684
|
+
for (const [hubId, p] of this._pending) out.push({
|
|
685
|
+
hubId,
|
|
686
|
+
originalId: p.originalId,
|
|
687
|
+
method: p.method,
|
|
688
|
+
originEdgeId: this._edgeId(p.origin),
|
|
689
|
+
targetEdgeId: this._edgeId(p.target),
|
|
690
|
+
ageMs: now - p.startedAtMs
|
|
691
|
+
});
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Begin routing the messages of `link`. The hub installs itself as the
|
|
696
|
+
* link's listener; do not also attach another listener. Returns a
|
|
697
|
+
* disposable that stops routing the link (detaching the listener and
|
|
698
|
+
* dropping any forwarding entries, uplink/loopback slots, and pending
|
|
699
|
+
* responses bound to it) when disposed. Peer discovery starts in the
|
|
700
|
+
* background; unresolved or unsupported peers never block routing.
|
|
701
|
+
*/
|
|
702
|
+
attach(link, opts) {
|
|
703
|
+
const prefixes = opts?.routePrefixes ?? [];
|
|
704
|
+
const seen = /* @__PURE__ */ new Set();
|
|
705
|
+
for (const prefix of prefixes) {
|
|
706
|
+
this._validatePrefixClaim(prefix);
|
|
707
|
+
if (seen.has(prefix)) throw new Error(`claimPrefix: duplicate prefix '${prefix}'`);
|
|
708
|
+
seen.add(prefix);
|
|
709
|
+
}
|
|
710
|
+
if (opts?.exclusiveHubRootHandler === true && this._loopback !== void 0 && this._loopback !== link) throw new Error("exclusiveHubRootHandler: another link is already the hub-root handler");
|
|
711
|
+
if (opts?.edgeId !== void 0) this._edgeIds.set(link, opts.edgeId);
|
|
712
|
+
if (opts?.transport !== void 0) this._transportInfo.set(link, opts.transport);
|
|
713
|
+
let disposed = false;
|
|
714
|
+
const handle = {
|
|
715
|
+
dispose: () => {
|
|
716
|
+
if (disposed) return;
|
|
717
|
+
disposed = true;
|
|
718
|
+
this._detach(link);
|
|
719
|
+
},
|
|
720
|
+
setAsLoopback: () => {
|
|
721
|
+
if (disposed || !this._links.has(link)) throw new Error("setAsLoopback: link is detached");
|
|
722
|
+
this.setLoopback(link);
|
|
723
|
+
},
|
|
724
|
+
addPrefixRoute: (prefix) => this.claimPrefix(link, prefix),
|
|
725
|
+
releasePrefix: (prefix) => this.releasePrefix(prefix),
|
|
726
|
+
edgeId: this._edgeId(link),
|
|
727
|
+
portId: this._topology.portId(link),
|
|
728
|
+
identifyPeer: () => this._topology.identifyPeer(link),
|
|
729
|
+
request: (method, params, timeoutMs) => this._requestOnLink(link, method, params, timeoutMs)
|
|
730
|
+
};
|
|
731
|
+
this._topology.registerHandle(handle, link);
|
|
732
|
+
const isNew = !this._links.has(link);
|
|
733
|
+
const loopbackChanged = opts?.exclusiveHubRootHandler === true && this._loopback !== link;
|
|
734
|
+
if (isNew) {
|
|
735
|
+
this._links.add(link);
|
|
736
|
+
this._topology.attachLink(link);
|
|
737
|
+
}
|
|
738
|
+
if (opts?.exclusiveHubRootHandler === true) {
|
|
739
|
+
this._loopback = link;
|
|
740
|
+
this._labelEdge(link, "loopback");
|
|
741
|
+
}
|
|
742
|
+
for (const prefix of prefixes) this._claimPrefix(link, prefix);
|
|
743
|
+
if (isNew) link.setListener((m) => this._onMessage(link, m));
|
|
744
|
+
if (isNew || loopbackChanged || prefixes.length !== 0) this._fireRoutingChanged();
|
|
745
|
+
return handle;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Attach a fresh in-memory link and hand back its far end alongside the
|
|
749
|
+
* {@link AttachedLink} handle. Creates a {@link TransportPair} internally,
|
|
750
|
+
* {@link attach}es the near side to the hub, and returns the attach handle
|
|
751
|
+
* augmented with `transport` — the far side for a peer (a consumer
|
|
752
|
+
* connection, an in-process participant, …) to drive. Equivalent to
|
|
753
|
+
* `const p = new TransportPair(); const link = hub.attach(p.a);
|
|
754
|
+
* return { ...link, transport: p.b };`. Disposing the returned link also
|
|
755
|
+
* disposes `transport`.
|
|
756
|
+
*/
|
|
757
|
+
attachOut(opts) {
|
|
758
|
+
const pair = new TransportPair();
|
|
759
|
+
const link = this.attach(pair.a, opts);
|
|
760
|
+
const result = {
|
|
761
|
+
...link,
|
|
762
|
+
transport: pair.b,
|
|
763
|
+
dispose: () => {
|
|
764
|
+
link.dispose();
|
|
765
|
+
pair.b.dispose();
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
this._topology.registerHandle(result, pair.a);
|
|
769
|
+
return result;
|
|
770
|
+
}
|
|
771
|
+
_detach(link) {
|
|
772
|
+
if (!this._links.has(link)) return;
|
|
773
|
+
link.setListener(void 0);
|
|
774
|
+
this._links.delete(link);
|
|
775
|
+
this._topology.cleanupLink(link);
|
|
776
|
+
this._table.deleteByValue(link);
|
|
777
|
+
if (this._uplink === link) this._uplink = void 0;
|
|
778
|
+
if (this._loopback === link) this._loopback = void 0;
|
|
779
|
+
for (const [id, p] of [...this._pending]) if (p.origin === link) {
|
|
780
|
+
this._injectCancel(p.target, id, StreamControlReason.clientDisconnected);
|
|
781
|
+
if (this._transitObservers.size !== 0) this._emit({
|
|
782
|
+
timeMs: Date.now(),
|
|
783
|
+
nodeId: this.nodeId,
|
|
784
|
+
in: this._transitEndpoint(p.target, id),
|
|
785
|
+
disposition: "dropped",
|
|
786
|
+
kind: "response",
|
|
787
|
+
method: p.method,
|
|
788
|
+
error: {
|
|
789
|
+
code: ErrorCode.peerDisconnected,
|
|
790
|
+
message: "origin disconnected"
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
this._deletePending(id);
|
|
794
|
+
} else if (p.target === link) {
|
|
795
|
+
this._deletePending(id);
|
|
796
|
+
this._log?.warn(`${this._tag()}target detached mid-request (hubId=${id}); failing origin`);
|
|
797
|
+
if (this._transitObservers.size !== 0) this._emit({
|
|
798
|
+
timeMs: Date.now(),
|
|
799
|
+
nodeId: this.nodeId,
|
|
800
|
+
in: this._transitEndpoint(p.target, id),
|
|
801
|
+
out: this._transitEndpoint(p.origin, p.originalId),
|
|
802
|
+
disposition: "forwarded",
|
|
803
|
+
kind: "response",
|
|
804
|
+
method: p.method,
|
|
805
|
+
error: {
|
|
806
|
+
code: ErrorCode.peerDisconnected,
|
|
807
|
+
message: "target detached before responding"
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
this._replyError(p.origin, p.originalId, ErrorCode.peerDisconnected, "target detached before responding");
|
|
811
|
+
}
|
|
812
|
+
this._fireRoutingChanged();
|
|
813
|
+
}
|
|
814
|
+
/** Set (or clear) the default route. Automatically attaches the link. */
|
|
815
|
+
setUplink(link) {
|
|
816
|
+
const changed = this._uplink !== link;
|
|
817
|
+
this._uplink = link;
|
|
818
|
+
if (link) {
|
|
819
|
+
this.attach(link);
|
|
820
|
+
this._labelEdge(link, "uplink");
|
|
821
|
+
}
|
|
822
|
+
if (changed) this._fireRoutingChanged();
|
|
823
|
+
}
|
|
824
|
+
/** Set (or clear) the root handler. Clear the existing handler before assigning a different link. */
|
|
825
|
+
setLoopback(link) {
|
|
826
|
+
if (link !== void 0) this.attach(link, { exclusiveHubRootHandler: true });
|
|
827
|
+
else if (this._loopback !== void 0) {
|
|
828
|
+
this._loopback = void 0;
|
|
829
|
+
this._fireRoutingChanged();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Bind a serviceId `prefix` to `link`. Like initial attachment routes,
|
|
834
|
+
* this is a privileged operation; higher layers enforce who may claim.
|
|
835
|
+
*
|
|
836
|
+
* Claim-once: throws if the prefix is malformed or already owned.
|
|
837
|
+
*/
|
|
838
|
+
claimPrefix(link, prefix) {
|
|
839
|
+
this.attach(link, { routePrefixes: [prefix] });
|
|
840
|
+
}
|
|
841
|
+
_validatePrefixClaim(prefix) {
|
|
842
|
+
const err = validatePrefix(prefix);
|
|
843
|
+
if (err) throw new Error(`claimPrefix: ${err}`);
|
|
844
|
+
if (this._table.has(prefix)) throw new Error(`claimPrefix: prefix '${prefix}' is already claimed`);
|
|
845
|
+
}
|
|
846
|
+
_claimPrefix(link, prefix) {
|
|
847
|
+
this._table.set(prefix, link);
|
|
848
|
+
this._labelEdge(link, prefix);
|
|
849
|
+
this._log?.debug(`${this._tag()}claim prefix '${prefix}'`);
|
|
850
|
+
}
|
|
851
|
+
releasePrefix(prefix) {
|
|
852
|
+
const released = this._table.delete(prefix);
|
|
853
|
+
if (released) {
|
|
854
|
+
this._log?.debug(`${this._tag()}release prefix '${prefix}'`);
|
|
855
|
+
this._fireRoutingChanged();
|
|
856
|
+
}
|
|
857
|
+
return released;
|
|
858
|
+
}
|
|
859
|
+
/** Snapshot of currently-claimed prefixes. */
|
|
860
|
+
claimedPrefixes() {
|
|
861
|
+
return this._table.prefixes();
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Trust the inspection interfaces mounted for `serviceId` on this attached
|
|
865
|
+
* in-process link. Only exact reserved inspection interface calls are hidden.
|
|
866
|
+
*/
|
|
867
|
+
markInspectionService(attached, serviceId) {
|
|
868
|
+
this._topology.markInspectionService(attached, serviceId);
|
|
869
|
+
}
|
|
870
|
+
/** Build a topology snapshot on demand for the addressed observer service. */
|
|
871
|
+
getTopologyGraph(observerServiceId) {
|
|
872
|
+
return this._topology.getTopologyGraph(observerServiceId);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Fan a **root-form** request out to every distinct downstream participant
|
|
876
|
+
* link (the claimed-prefix owners), skipping the link that owns
|
|
877
|
+
* `excludePrefix` (typically the hub's own services prefix) and the uplink.
|
|
878
|
+
* Each participant is queried **once** even if it owns several prefixes.
|
|
879
|
+
* Failures (unreachable participant, timeout, no root handler) are dropped,
|
|
880
|
+
* so the result holds one entry per link that answered.
|
|
881
|
+
*
|
|
882
|
+
* The request is delivered to each participant's **root** services — on an
|
|
883
|
+
* overlay uplink the splitter routes a root-form method to the participant
|
|
884
|
+
* (`H·root → P`), which is never forwarded and never gated. That is what
|
|
885
|
+
* lets the hub's global directory gather participant self-listings without
|
|
886
|
+
* signing or capabilities.
|
|
887
|
+
*/
|
|
888
|
+
async queryParticipantRoots(method, params, excludePrefix) {
|
|
889
|
+
return this._participantRoots.query(method, params, excludePrefix);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Keep a root-form streaming request open on every current participant.
|
|
893
|
+
* Routing changes reconcile the participant set; application stream payloads
|
|
894
|
+
* are collapsed into a coarse callback so callers can re-query.
|
|
895
|
+
*/
|
|
896
|
+
watchParticipantRoots(method, params, onTick, excludePrefix) {
|
|
897
|
+
return this._participantRoots.watch(method, params, onTick, excludePrefix);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Send a single request down `link` and resolve with its result — the
|
|
901
|
+
* hub-originated analogue of {@link _routeRequest}. It allocates a hub id,
|
|
902
|
+
* parks a {@link _pending} entry whose `origin` is a tiny in-memory sink
|
|
903
|
+
* that settles this promise on the matching response, and writes the
|
|
904
|
+
* request to `link`. It therefore reuses the full demux / idle-timeout /
|
|
905
|
+
* detach machinery: a response routes back through {@link _demux} into the
|
|
906
|
+
* sink, an idle target fails it via {@link _onIdleTimeout}, and a detaching
|
|
907
|
+
* target fails it via {@link _detach}.
|
|
908
|
+
*/
|
|
909
|
+
_requestOnLink(link, method, params, timeoutMs) {
|
|
910
|
+
return new Promise((resolve, reject) => {
|
|
911
|
+
const hubId = this._nextId++;
|
|
912
|
+
const key = String(hubId);
|
|
913
|
+
let settled = false;
|
|
914
|
+
let requestTimeout;
|
|
915
|
+
const finish = (error, result) => {
|
|
916
|
+
if (settled) return;
|
|
917
|
+
settled = true;
|
|
918
|
+
if (requestTimeout !== void 0) clearTimeout(requestTimeout);
|
|
919
|
+
if (error !== void 0) reject(error);
|
|
920
|
+
else resolve(result);
|
|
921
|
+
};
|
|
922
|
+
const origin = {
|
|
923
|
+
send: (m) => {
|
|
924
|
+
const r = m;
|
|
925
|
+
const err = r.error;
|
|
926
|
+
if (err) finish(new RpcError(err.message ?? "request failed", err.code ?? ErrorCode.internalError, err.data));
|
|
927
|
+
else finish(void 0, r.result);
|
|
928
|
+
},
|
|
929
|
+
setListener: () => {},
|
|
930
|
+
dispose: () => {}
|
|
931
|
+
};
|
|
932
|
+
this._pending.set(key, {
|
|
933
|
+
origin,
|
|
934
|
+
originalId: hubId,
|
|
935
|
+
target: link,
|
|
936
|
+
method,
|
|
937
|
+
startedAtMs: Date.now()
|
|
938
|
+
});
|
|
939
|
+
this._armIdle(key);
|
|
940
|
+
if (timeoutMs !== void 0 && timeoutMs > 0) {
|
|
941
|
+
requestTimeout = setTimeout(() => {
|
|
942
|
+
if (this._pending.get(key)?.origin !== origin) return;
|
|
943
|
+
this._injectCancel(link, key, StreamControlReason.clientDisconnected);
|
|
944
|
+
this._deletePending(key);
|
|
945
|
+
finish(/* @__PURE__ */ new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
946
|
+
}, timeoutMs);
|
|
947
|
+
requestTimeout.unref();
|
|
948
|
+
}
|
|
949
|
+
const onSendError = (error) => {
|
|
950
|
+
if (this._pending.get(key)?.origin !== origin) return;
|
|
951
|
+
this._deletePending(key);
|
|
952
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
953
|
+
};
|
|
954
|
+
try {
|
|
955
|
+
Promise.resolve(link.send({
|
|
956
|
+
jsonrpc: "2.0",
|
|
957
|
+
id: hubId,
|
|
958
|
+
method,
|
|
959
|
+
params
|
|
960
|
+
})).catch(onSendError);
|
|
961
|
+
} catch (error) {
|
|
962
|
+
onSendError(error);
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
_watchOnLink(link, method, params, onTick, onSettled, onEstablished) {
|
|
967
|
+
const hubId = this._nextId++;
|
|
968
|
+
const key = String(hubId);
|
|
969
|
+
let active = true;
|
|
970
|
+
let pingTimer;
|
|
971
|
+
let establishedTimer;
|
|
972
|
+
const stop = (cancel) => {
|
|
973
|
+
if (!active) return;
|
|
974
|
+
active = false;
|
|
975
|
+
if (pingTimer !== void 0) clearInterval(pingTimer);
|
|
976
|
+
if (establishedTimer !== void 0) clearTimeout(establishedTimer);
|
|
977
|
+
if (this._pending.get(key)?.origin === origin) {
|
|
978
|
+
if (cancel) this._injectCancel(link, key, StreamControlReason.clientDisconnected);
|
|
979
|
+
this._deletePending(key);
|
|
980
|
+
}
|
|
981
|
+
if (!cancel) onSettled();
|
|
982
|
+
};
|
|
983
|
+
const sendControl = (control, dir) => {
|
|
984
|
+
const message = {
|
|
985
|
+
jsonrpc: "2.0",
|
|
986
|
+
method: STREAM_METHOD,
|
|
987
|
+
params: {
|
|
988
|
+
requestId: hubId,
|
|
989
|
+
dir,
|
|
990
|
+
control
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
try {
|
|
994
|
+
Promise.resolve(link.send(message)).catch(() => stop(false));
|
|
995
|
+
} catch {
|
|
996
|
+
stop(false);
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
const origin = {
|
|
1000
|
+
send: (message) => {
|
|
1001
|
+
if (isNotification(message) && message.method === STREAM_METHOD) {
|
|
1002
|
+
const stream = _streamParams(message);
|
|
1003
|
+
if (stream?.dir !== StreamDir.toCaller) return;
|
|
1004
|
+
if (stream.control?.type === StreamControlType.ping) sendControl({
|
|
1005
|
+
type: StreamControlType.pong,
|
|
1006
|
+
nonce: stream.control.nonce
|
|
1007
|
+
}, StreamDir.toCallee);
|
|
1008
|
+
if (stream.payload !== void 0) onTick();
|
|
1009
|
+
} else if (isResponse(message)) stop(false);
|
|
1010
|
+
},
|
|
1011
|
+
setListener: () => {},
|
|
1012
|
+
dispose: () => stop(true)
|
|
1013
|
+
};
|
|
1014
|
+
this._pending.set(key, {
|
|
1015
|
+
origin,
|
|
1016
|
+
originalId: hubId,
|
|
1017
|
+
target: link,
|
|
1018
|
+
method,
|
|
1019
|
+
startedAtMs: Date.now()
|
|
1020
|
+
});
|
|
1021
|
+
this._armIdle(key);
|
|
1022
|
+
establishedTimer = setTimeout(() => {
|
|
1023
|
+
establishedTimer = void 0;
|
|
1024
|
+
if (active && this._pending.get(key)?.origin === origin) onEstablished();
|
|
1025
|
+
}, 10);
|
|
1026
|
+
if (this._idleMs > 0) pingTimer = setInterval(() => {
|
|
1027
|
+
if (!active || this._pending.get(key)?.origin !== origin) {
|
|
1028
|
+
stop(false);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
this._resetIdle(key);
|
|
1032
|
+
sendControl({
|
|
1033
|
+
type: StreamControlType.ping,
|
|
1034
|
+
nonce: randomUUID()
|
|
1035
|
+
}, StreamDir.toCallee);
|
|
1036
|
+
}, Math.max(10, Math.floor(this._idleMs / 3)));
|
|
1037
|
+
const onSendError = () => stop(false);
|
|
1038
|
+
try {
|
|
1039
|
+
Promise.resolve(link.send({
|
|
1040
|
+
jsonrpc: "2.0",
|
|
1041
|
+
id: hubId,
|
|
1042
|
+
method,
|
|
1043
|
+
params
|
|
1044
|
+
})).catch(onSendError);
|
|
1045
|
+
} catch {
|
|
1046
|
+
onSendError();
|
|
1047
|
+
}
|
|
1048
|
+
return { dispose: () => stop(true) };
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* The link a still-in-flight forwarded request originally arrived on,
|
|
1052
|
+
* keyed by the **hub-rewritten** id the request now carries (i.e. the
|
|
1053
|
+
* `id` a target sees on its delivered message). Valid from the moment the
|
|
1054
|
+
* hub forwards the request until its response is demultiplexed; returns
|
|
1055
|
+
* `undefined` for unknown / already-answered ids.
|
|
1056
|
+
*
|
|
1057
|
+
* This is the seam a hub-hosted participant uses to learn *which* link a
|
|
1058
|
+
* call it is handling came from, so it can bind a prefix claim to that
|
|
1059
|
+
* exact link via {@link claimPrefix} without the routing core ever
|
|
1060
|
+
* growing a notion of authenticated identity.
|
|
1061
|
+
*/
|
|
1062
|
+
getSourceTransport(requestId) {
|
|
1063
|
+
return this._pending.get(String(requestId))?.origin;
|
|
1064
|
+
}
|
|
1065
|
+
_onMessage(from, m) {
|
|
1066
|
+
if (isResponse(m)) this._demux(from, m);
|
|
1067
|
+
else if (isRequest(m)) {
|
|
1068
|
+
if (m.method === GET_NODE_ID_METHOD) {
|
|
1069
|
+
from.send({
|
|
1070
|
+
jsonrpc: "2.0",
|
|
1071
|
+
id: m.id,
|
|
1072
|
+
result: {
|
|
1073
|
+
nodeId: this.nodeId,
|
|
1074
|
+
portId: this._topology.portId(from)
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
this._routeRequest(from, m);
|
|
1080
|
+
} else if (isNotification(m)) {
|
|
1081
|
+
if (m.method === STREAM_METHOD) this._routeStream(from, m);
|
|
1082
|
+
else this._routeNotification(from, m);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
_resolve(method, from) {
|
|
1086
|
+
const parsed = parseMethodName(method);
|
|
1087
|
+
if (!parsed) return { kind: "malformed" };
|
|
1088
|
+
if (parsed.kind === "bare" || parsed.kind === "interface") return {
|
|
1089
|
+
kind: "loopback",
|
|
1090
|
+
link: this._loopback
|
|
1091
|
+
};
|
|
1092
|
+
const match = this._table.longestPrefixMatch(parsed.serviceId);
|
|
1093
|
+
if (match) return {
|
|
1094
|
+
kind: "prefix",
|
|
1095
|
+
link: match.value,
|
|
1096
|
+
prefix: match.prefix
|
|
1097
|
+
};
|
|
1098
|
+
if (this._uplink !== void 0 && this._uplink === from) return {
|
|
1099
|
+
kind: "uplink",
|
|
1100
|
+
link: void 0
|
|
1101
|
+
};
|
|
1102
|
+
return {
|
|
1103
|
+
kind: "uplink",
|
|
1104
|
+
link: this._uplink
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
_routeRequest(from, req) {
|
|
1108
|
+
const res = this._resolve(req.method, from);
|
|
1109
|
+
const target = res.kind === "malformed" ? void 0 : res.link;
|
|
1110
|
+
if (!target) {
|
|
1111
|
+
const message = this._noTargetMessage(res, req.method);
|
|
1112
|
+
this._log?.warn(`${this._tag()}unroutable request '${req.method}' (id=${String(req.id)}): ${message}`);
|
|
1113
|
+
if (this._transitObservers.size !== 0) this._emit({
|
|
1114
|
+
timeMs: Date.now(),
|
|
1115
|
+
nodeId: this.nodeId,
|
|
1116
|
+
in: this._transitEndpoint(from, req.id),
|
|
1117
|
+
disposition: "unroutable",
|
|
1118
|
+
kind: "request",
|
|
1119
|
+
method: req.method,
|
|
1120
|
+
params: req.params
|
|
1121
|
+
});
|
|
1122
|
+
this._replyError(from, req.id, this._noTargetCode(res), message);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const hubId = this._nextId++;
|
|
1126
|
+
const key = String(hubId);
|
|
1127
|
+
this._pending.set(key, {
|
|
1128
|
+
origin: from,
|
|
1129
|
+
originalId: req.id,
|
|
1130
|
+
target,
|
|
1131
|
+
method: req.method,
|
|
1132
|
+
startedAtMs: Date.now()
|
|
1133
|
+
});
|
|
1134
|
+
this._armIdle(key);
|
|
1135
|
+
this._log?.trace(`${this._tag()}route '${req.method}' (${res.kind}${res.kind === "prefix" ? ` ${res.prefix}` : ""}) id=${String(req.id)}\u2192${hubId}`);
|
|
1136
|
+
if (this._transitObservers.size !== 0) this._emit({
|
|
1137
|
+
timeMs: Date.now(),
|
|
1138
|
+
nodeId: this.nodeId,
|
|
1139
|
+
in: this._transitEndpoint(from, req.id),
|
|
1140
|
+
out: this._transitEndpoint(target, hubId),
|
|
1141
|
+
disposition: "forwarded",
|
|
1142
|
+
kind: "request",
|
|
1143
|
+
method: req.method,
|
|
1144
|
+
params: req.params
|
|
1145
|
+
});
|
|
1146
|
+
const forwarded = {
|
|
1147
|
+
...req,
|
|
1148
|
+
id: hubId
|
|
1149
|
+
};
|
|
1150
|
+
const onSendError = (error) => {
|
|
1151
|
+
const pending = this._pending.get(key);
|
|
1152
|
+
if (pending === void 0 || pending.target !== target) return;
|
|
1153
|
+
this._deletePending(key);
|
|
1154
|
+
const message = `target send failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1155
|
+
this._log?.warn(`${this._tag()}${message} (hubId=${key})`);
|
|
1156
|
+
if (this._transitObservers.size !== 0) this._emit({
|
|
1157
|
+
timeMs: Date.now(),
|
|
1158
|
+
nodeId: this.nodeId,
|
|
1159
|
+
in: this._transitEndpoint(pending.target, hubId),
|
|
1160
|
+
out: this._transitEndpoint(pending.origin, pending.originalId),
|
|
1161
|
+
disposition: "dropped",
|
|
1162
|
+
kind: "response",
|
|
1163
|
+
method: pending.method,
|
|
1164
|
+
error: {
|
|
1165
|
+
code: ErrorCode.peerDisconnected,
|
|
1166
|
+
message
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
this._replyError(pending.origin, pending.originalId, ErrorCode.peerDisconnected, message);
|
|
1170
|
+
};
|
|
1171
|
+
try {
|
|
1172
|
+
Promise.resolve(target.send(forwarded)).catch(onSendError);
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
onSendError(error);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
_routeNotification(from, note) {
|
|
1178
|
+
const method = note.method;
|
|
1179
|
+
const params = note.params;
|
|
1180
|
+
const res = this._resolve(method, from);
|
|
1181
|
+
const target = res.kind === "malformed" ? void 0 : res.link;
|
|
1182
|
+
if (target) {
|
|
1183
|
+
if (this._transitObservers.size !== 0) this._emit({
|
|
1184
|
+
timeMs: Date.now(),
|
|
1185
|
+
nodeId: this.nodeId,
|
|
1186
|
+
in: this._transitEndpoint(from),
|
|
1187
|
+
out: this._transitEndpoint(target),
|
|
1188
|
+
disposition: "forwarded",
|
|
1189
|
+
kind: "notification",
|
|
1190
|
+
method,
|
|
1191
|
+
params
|
|
1192
|
+
});
|
|
1193
|
+
target.send(note);
|
|
1194
|
+
} else {
|
|
1195
|
+
if (this._transitObservers.size !== 0) this._emit({
|
|
1196
|
+
timeMs: Date.now(),
|
|
1197
|
+
nodeId: this.nodeId,
|
|
1198
|
+
in: this._transitEndpoint(from),
|
|
1199
|
+
disposition: res.kind === "malformed" ? "unroutable" : "dropped",
|
|
1200
|
+
kind: "notification",
|
|
1201
|
+
method,
|
|
1202
|
+
params
|
|
1203
|
+
});
|
|
1204
|
+
this._log?.trace(`${this._tag()}drop unroutable notification '${method}'`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
_demux(from, res) {
|
|
1208
|
+
if (res.id === null) return;
|
|
1209
|
+
const key = String(res.id);
|
|
1210
|
+
const pending = this._pending.get(key);
|
|
1211
|
+
if (!pending) return;
|
|
1212
|
+
if (from !== pending.target) return;
|
|
1213
|
+
this._deletePending(key);
|
|
1214
|
+
if (this._transitObservers.size !== 0) this._emit({
|
|
1215
|
+
timeMs: Date.now(),
|
|
1216
|
+
nodeId: this.nodeId,
|
|
1217
|
+
in: this._transitEndpoint(pending.target, res.id),
|
|
1218
|
+
out: this._transitEndpoint(pending.origin, pending.originalId),
|
|
1219
|
+
disposition: "forwarded",
|
|
1220
|
+
kind: "response",
|
|
1221
|
+
method: pending.method,
|
|
1222
|
+
result: res.result,
|
|
1223
|
+
error: res.error
|
|
1224
|
+
});
|
|
1225
|
+
pending.origin.send({
|
|
1226
|
+
...res,
|
|
1227
|
+
id: pending.originalId
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Route a `$stream::send` notification. Unlike an ordinary notification, a
|
|
1232
|
+
* stream message is **correlated to an in-flight request by `requestId`**,
|
|
1233
|
+
* not addressed by method name — so it follows the same path the request
|
|
1234
|
+
* established (recorded in {@link _pending}), exactly like a
|
|
1235
|
+
* {@link _demux | response}, and the hub rewrites `requestId` across the
|
|
1236
|
+
* forwarding boundary the same way it rewrites a response `id`.
|
|
1237
|
+
*
|
|
1238
|
+
* The message's explicit `dir` picks the traversal — no inference from the
|
|
1239
|
+
* arriving link:
|
|
1240
|
+
*
|
|
1241
|
+
* - **`toCaller`** (callee → caller progress): routed like a response. The
|
|
1242
|
+
* streamer used the hub-rewritten id it *received*, so `requestId` is a
|
|
1243
|
+
* pending hub id; only the link the request was forwarded to may stream
|
|
1244
|
+
* on it. Restore the origin's original id and forward to the origin.
|
|
1245
|
+
* - **`toCallee`** (caller → callee input / cancel / ping): the reverse
|
|
1246
|
+
* traversal of the same entry. The streamer used the original id it
|
|
1247
|
+
* *sent*; rewrite `requestId` to the hub id the target was handed and
|
|
1248
|
+
* forward to the target. Only the origin may stream this direction.
|
|
1249
|
+
*
|
|
1250
|
+
* Every routed stream message resets the request's idle timer (a keepalive
|
|
1251
|
+
* ping is exactly a `toCallee` control with no payload). A `$stream::send`
|
|
1252
|
+
* matching no in-flight request is dropped (late / unknown correlation).
|
|
1253
|
+
*/
|
|
1254
|
+
_routeStream(from, note) {
|
|
1255
|
+
const p = _streamParams(note);
|
|
1256
|
+
if (!p || p.requestId === void 0) {
|
|
1257
|
+
this._log?.trace(`${this._tag()}drop malformed '${STREAM_METHOD}' (no requestId)`);
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (p.dir === StreamDir.toCaller) {
|
|
1261
|
+
const key = String(p.requestId);
|
|
1262
|
+
const pending = this._pending.get(key);
|
|
1263
|
+
if (!pending || from !== pending.target) {
|
|
1264
|
+
this._log?.trace(`${this._tag()}drop unroutable toCaller '${STREAM_METHOD}' requestId=${String(p.requestId)}`);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
this._resetIdle(key);
|
|
1268
|
+
pending.origin.send(_withRequestId$1(note, pending.originalId));
|
|
1269
|
+
this._emitStream(note, this._transitEndpoint(pending.target, p.requestId), this._transitEndpoint(pending.origin, pending.originalId));
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
for (const [hubId, pending] of this._pending) if (pending.origin === from && String(pending.originalId) === String(p.requestId)) {
|
|
1273
|
+
this._resetIdle(hubId);
|
|
1274
|
+
pending.target.send(_withRequestId$1(note, Number(hubId)));
|
|
1275
|
+
this._emitStream(note, this._transitEndpoint(pending.origin, p.requestId), this._transitEndpoint(pending.target, Number(hubId)));
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
this._log?.trace(`${this._tag()}drop unroutable toCallee '${STREAM_METHOD}' requestId=${String(p.requestId)}`);
|
|
1279
|
+
}
|
|
1280
|
+
/** Delete a pending entry and clear its idle timer. */
|
|
1281
|
+
_deletePending(key) {
|
|
1282
|
+
const p = this._pending.get(key);
|
|
1283
|
+
if (!p) return;
|
|
1284
|
+
if (p.timer !== void 0) clearTimeout(p.timer);
|
|
1285
|
+
this._pending.delete(key);
|
|
1286
|
+
}
|
|
1287
|
+
/** Arm (or re-arm) the idle timer for a pending entry. No-op when disabled. */
|
|
1288
|
+
_armIdle(key) {
|
|
1289
|
+
if (this._idleMs <= 0) return;
|
|
1290
|
+
const t = setTimeout(() => this._onIdleTimeout(key), this._idleMs);
|
|
1291
|
+
t.unref?.();
|
|
1292
|
+
const p = this._pending.get(key);
|
|
1293
|
+
if (p) p.timer = t;
|
|
1294
|
+
else clearTimeout(t);
|
|
1295
|
+
}
|
|
1296
|
+
/** Reset the idle timer on stream activity. */
|
|
1297
|
+
_resetIdle(key) {
|
|
1298
|
+
const p = this._pending.get(key);
|
|
1299
|
+
if (!p) return;
|
|
1300
|
+
if (p.timer !== void 0) clearTimeout(p.timer);
|
|
1301
|
+
this._armIdle(key);
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* A request went idle past {@link _idleMs}. Cancel the (presumably hung)
|
|
1305
|
+
* callee, fail the caller with a `requestTimeout`, and drop the entry —
|
|
1306
|
+
* the bound that keeps `_pending` from growing without limit.
|
|
1307
|
+
*/
|
|
1308
|
+
_onIdleTimeout(key) {
|
|
1309
|
+
const p = this._pending.get(key);
|
|
1310
|
+
if (!p) return;
|
|
1311
|
+
this._log?.warn(`${this._tag()}request idle-timed-out (hubId=${key}); cancelling + failing origin`);
|
|
1312
|
+
this._injectCancel(p.target, key, StreamControlReason.idleTimeout);
|
|
1313
|
+
this._deletePending(key);
|
|
1314
|
+
if (this._transitObservers.size !== 0) this._emit({
|
|
1315
|
+
timeMs: Date.now(),
|
|
1316
|
+
nodeId: this.nodeId,
|
|
1317
|
+
in: this._transitEndpoint(p.target, key),
|
|
1318
|
+
out: this._transitEndpoint(p.origin, p.originalId),
|
|
1319
|
+
disposition: "forwarded",
|
|
1320
|
+
kind: "response",
|
|
1321
|
+
method: p.method,
|
|
1322
|
+
error: {
|
|
1323
|
+
code: ErrorCode.requestTimeout,
|
|
1324
|
+
message: "request idle-timed-out"
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
this._replyError(p.origin, p.originalId, ErrorCode.requestTimeout, "request idle-timed-out");
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Author a `toCallee` cancel toward `target` for the request the target
|
|
1331
|
+
* knows as `hubId`. Used on caller-disconnect and idle-timeout — the hub
|
|
1332
|
+
* itself originates the message, which is why `dir` is explicit on the
|
|
1333
|
+
* wire (there is no inbound link to infer it from).
|
|
1334
|
+
*/
|
|
1335
|
+
_injectCancel(target, hubId, reason) {
|
|
1336
|
+
const params = {
|
|
1337
|
+
requestId: Number(hubId),
|
|
1338
|
+
dir: StreamDir.toCallee,
|
|
1339
|
+
control: {
|
|
1340
|
+
type: StreamControlType.cancel,
|
|
1341
|
+
reason
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
try {
|
|
1345
|
+
Promise.resolve(target.send({
|
|
1346
|
+
jsonrpc: "2.0",
|
|
1347
|
+
method: STREAM_METHOD,
|
|
1348
|
+
params
|
|
1349
|
+
})).catch(() => void 0);
|
|
1350
|
+
} catch {}
|
|
1351
|
+
}
|
|
1352
|
+
_noTargetCode(res) {
|
|
1353
|
+
return res.kind === "malformed" ? ErrorCode.invalidRequest : ErrorCode.methodNotFound;
|
|
1354
|
+
}
|
|
1355
|
+
_noTargetMessage(res, method) {
|
|
1356
|
+
switch (res.kind) {
|
|
1357
|
+
case "malformed": return `malformed method name: ${method}`;
|
|
1358
|
+
case "loopback": return `no local root services to handle: ${method}`;
|
|
1359
|
+
case "uplink": return `no route for: ${method}`;
|
|
1360
|
+
default: return `unroutable: ${method}`;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
_replyError(to, id, code, message) {
|
|
1364
|
+
const error = {
|
|
1365
|
+
jsonrpc: "2.0",
|
|
1366
|
+
id,
|
|
1367
|
+
error: this._options.debugName !== void 0 ? {
|
|
1368
|
+
code,
|
|
1369
|
+
message,
|
|
1370
|
+
data: { hub: this._options.debugName }
|
|
1371
|
+
} : {
|
|
1372
|
+
code,
|
|
1373
|
+
message
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
to.send(error);
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
/** Read the params off a {@link STREAM_METHOD} notification. */
|
|
1380
|
+
function _streamParams(m) {
|
|
1381
|
+
const params = m.params;
|
|
1382
|
+
if (params === null || typeof params !== "object") return void 0;
|
|
1383
|
+
return params;
|
|
1384
|
+
}
|
|
1385
|
+
/** Clone a {@link STREAM_METHOD} notification with `requestId` replaced. */
|
|
1386
|
+
function _withRequestId$1(m, requestId) {
|
|
1387
|
+
const params = {
|
|
1388
|
+
...m.params,
|
|
1389
|
+
requestId
|
|
1390
|
+
};
|
|
1391
|
+
return {
|
|
1392
|
+
...m,
|
|
1393
|
+
params
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
//#endregion
|
|
1397
|
+
//#region src/hub/server/hubInspector.ts
|
|
1398
|
+
/** Publishes dynamically observed raw node transits through bounded traffic streams. */
|
|
1399
|
+
var HubInspector = class {
|
|
1400
|
+
_hub;
|
|
1401
|
+
_subscribers = /* @__PURE__ */ new Set();
|
|
1402
|
+
_trafficSources = /* @__PURE__ */ new Set();
|
|
1403
|
+
_trafficWatches = new TrafficWatchFlowTracker();
|
|
1404
|
+
_observation;
|
|
1405
|
+
constructor(_hub) {
|
|
1406
|
+
this._hub = _hub;
|
|
1407
|
+
this._observation = this._hub.observeTransits((transit) => {
|
|
1408
|
+
this._onTransit(transit);
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
get observerCount() {
|
|
1412
|
+
return this._subscribers.size;
|
|
1413
|
+
}
|
|
1414
|
+
subscribe(options, send) {
|
|
1415
|
+
if (options.trafficIgnoreKey !== void 0 && !this._trafficWatches.claim(options.trafficIgnoreKey)) throw new Error("Traffic watch request was not observed before subscription");
|
|
1416
|
+
const subscriber = new BoundedTrafficSubscription(options, send, () => {
|
|
1417
|
+
this._subscribers.delete(subscriber);
|
|
1418
|
+
if (this._subscribers.size === 0) this._stopTrafficSources();
|
|
1419
|
+
});
|
|
1420
|
+
if (this._subscribers.size === 0) this._startTrafficSources();
|
|
1421
|
+
this._subscribers.add(subscriber);
|
|
1422
|
+
return subscriber;
|
|
1423
|
+
}
|
|
1424
|
+
dispose() {
|
|
1425
|
+
for (const subscriber of [...this._subscribers]) subscriber.dispose();
|
|
1426
|
+
this._subscribers.clear();
|
|
1427
|
+
this._observation.dispose();
|
|
1428
|
+
this._trafficWatches.clear();
|
|
1429
|
+
this._stopTrafficSources();
|
|
1430
|
+
this._trafficSources.clear();
|
|
1431
|
+
}
|
|
1432
|
+
/** Add an endpoint traffic source managed by this Hub inspection service. */
|
|
1433
|
+
addTrafficSource(source) {
|
|
1434
|
+
const registered = { source };
|
|
1435
|
+
this._trafficSources.add(registered);
|
|
1436
|
+
if (this._subscribers.size !== 0) this._startTrafficSource(registered);
|
|
1437
|
+
let disposed = false;
|
|
1438
|
+
return { dispose: () => {
|
|
1439
|
+
if (disposed) return;
|
|
1440
|
+
disposed = true;
|
|
1441
|
+
this._trafficSources.delete(registered);
|
|
1442
|
+
registered.observation?.dispose();
|
|
1443
|
+
registered.observation = void 0;
|
|
1444
|
+
} };
|
|
1445
|
+
}
|
|
1446
|
+
_startTrafficSources() {
|
|
1447
|
+
for (const source of this._trafficSources) this._startTrafficSource(source);
|
|
1448
|
+
}
|
|
1449
|
+
_startTrafficSource(source) {
|
|
1450
|
+
source.observation ??= source.source.observe((transit) => this._emitTransit(transit));
|
|
1451
|
+
}
|
|
1452
|
+
_stopTrafficSources() {
|
|
1453
|
+
for (const source of this._trafficSources) {
|
|
1454
|
+
source.observation?.dispose();
|
|
1455
|
+
source.observation = void 0;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
_onTransit(transit) {
|
|
1459
|
+
const event = {
|
|
1460
|
+
type: "transit",
|
|
1461
|
+
timeMs: transit.timeMs,
|
|
1462
|
+
nodeId: transit.nodeId,
|
|
1463
|
+
...transit.in !== void 0 ? { in: normalizeEndpoint(transit.in) } : {},
|
|
1464
|
+
...transit.out !== void 0 ? { out: normalizeEndpoint(transit.out) } : {},
|
|
1465
|
+
disposition: transit.disposition,
|
|
1466
|
+
kind: transit.kind,
|
|
1467
|
+
method: transit.method,
|
|
1468
|
+
params: transit.params,
|
|
1469
|
+
result: transit.result,
|
|
1470
|
+
error: transit.error
|
|
1471
|
+
};
|
|
1472
|
+
if (this._trafficWatches.accept(event)) return;
|
|
1473
|
+
if (this._subscribers.size !== 0) this._emitTransit(event);
|
|
1474
|
+
}
|
|
1475
|
+
_emitTransit(transit) {
|
|
1476
|
+
for (const subscriber of this._subscribers) subscriber.enqueue(transit);
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
function normalizeEndpoint(endpoint) {
|
|
1480
|
+
return {
|
|
1481
|
+
edgeId: endpoint.edgeId,
|
|
1482
|
+
portId: endpoint.portId ?? endpoint.edgeId,
|
|
1483
|
+
...endpoint.requestId !== void 0 ? { requestId: endpoint.requestId } : {}
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
//#endregion
|
|
1487
|
+
//#region src/hub/server/routing/overlaySplitter.ts
|
|
1488
|
+
/** Separator for return-path tags. NUL never appears in JSON-RPC ids. */
|
|
1489
|
+
const SEP = "\0";
|
|
1490
|
+
/**
|
|
1491
|
+
* A fixed three-port message splitter — the per-participant primitive that
|
|
1492
|
+
* replaces a nested {@link import('./routingHub').Hub} inside a
|
|
1493
|
+
* {@link import('./rootOverlay').RootOverlay}.
|
|
1494
|
+
*
|
|
1495
|
+
* Ports:
|
|
1496
|
+
* - **P** — the participant (downstream connection),
|
|
1497
|
+
* - **C** — the local root-services connection (`hubServiceIdRegistry::registerServiceId`,
|
|
1498
|
+
* `hubrpc.directory`, `identity::*`, …),
|
|
1499
|
+
* - **H** — the uplink to the parent hub.
|
|
1500
|
+
*
|
|
1501
|
+
* Routing rules (verbatim to the design):
|
|
1502
|
+
* ```
|
|
1503
|
+
* P·root → C P·* → H
|
|
1504
|
+
* H·* → P H·root → P
|
|
1505
|
+
* C·* → P
|
|
1506
|
+
* ```
|
|
1507
|
+
* where `root` means a bare/interface-form method and `*` a fully-qualified
|
|
1508
|
+
* (`serviceId::…`) method. Responses follow the reverse path. `H·root → P`
|
|
1509
|
+
* lets the parent hub reach the participant's *own* root services (e.g. the
|
|
1510
|
+
* participant-served root directory) — distinct from `P·root → C`, which is
|
|
1511
|
+
* the participant *consuming* the overlay's root services; the two are
|
|
1512
|
+
* opposite directions served by different connections, so they never collide.
|
|
1513
|
+
*
|
|
1514
|
+
* ### `$stream::send` — correlated by `requestId`, not by method form
|
|
1515
|
+
*
|
|
1516
|
+
* A {@link STREAM_METHOD} (`$stream::send`) notification is *interface-form*
|
|
1517
|
+
* (`$stream::send`), so the method-form rules above would naively treat it as
|
|
1518
|
+
* `root` and send a participant's stream to **C** (and route an inbound one as
|
|
1519
|
+
* a root call to **P**). That is wrong: a stream message belongs to an in-flight
|
|
1520
|
+
* request's lifetime and must follow **that request's** path, exactly like a
|
|
1521
|
+
* response does. So `$stream::send` bypasses the method-form table and is
|
|
1522
|
+
* routed like a response instead:
|
|
1523
|
+
*
|
|
1524
|
+
* - **P → ?**: the participant streams using the `requestId` it *observed*,
|
|
1525
|
+
* which for a request it serves is the tag the splitter stamped on the
|
|
1526
|
+
* delivered request id (`H\0…` / `C\0…`). We {@link _decodeId | decode} that
|
|
1527
|
+
* tag, restore the original id, and forward to the tagged origin (H or C) —
|
|
1528
|
+
* the same demux responses get. An **untagged** `requestId` means the
|
|
1529
|
+
* participant *initiated* the request (P→H or P→C); we cannot tell which
|
|
1530
|
+
* statelessly, so we default to the **uplink** (the common case: streaming
|
|
1531
|
+
* to a fully-qualified service via the parent).
|
|
1532
|
+
* - **H/C → P**: forwarded verbatim to the participant (never dropped), as a
|
|
1533
|
+
* response would be — the parent has already restored P's original
|
|
1534
|
+
* `requestId`, symmetric to response-id rewriting.
|
|
1535
|
+
*
|
|
1536
|
+
* PARTICIPANT-INITIATED STREAMS — the one stateful case: because `requestId`
|
|
1537
|
+
* lives in `params` (not a structural top-level id the splitter rewrites in
|
|
1538
|
+
* lock-step), a stream frame for a request the participant *initiated* (`P→C`
|
|
1539
|
+
* or `P→H`) carries P's *own untagged* id, which alone cannot say whether the
|
|
1540
|
+
* call went to the root (C) or the uplink (H). The splitter therefore keeps a
|
|
1541
|
+
* small `requestId → target` map ({@link _pInitiated}), populated when P sends
|
|
1542
|
+
* such a request and cleared when its response returns, and consults it to
|
|
1543
|
+
* route those frames. This is the splitter's *only* per-request state; every
|
|
1544
|
+
* other path still routes statelessly via id-tagging.
|
|
1545
|
+
*
|
|
1546
|
+
* ### Stateless demux via id-tagging
|
|
1547
|
+
*
|
|
1548
|
+
* P is the only port that receives requests from *two* sources (H and C),
|
|
1549
|
+
* so a response from P is ambiguous and ids can collide (`H` and `C` may
|
|
1550
|
+
* each send `id:5`). Rather than a pending-request map (with its attendant
|
|
1551
|
+
* timeout/leak problem), the splitter rewrites **only requests destined for
|
|
1552
|
+
* P**, prepending the origin and original id type to the id:
|
|
1553
|
+
*
|
|
1554
|
+
* ```
|
|
1555
|
+
* H→P: id' = "H\0n\042" (push origin tag)
|
|
1556
|
+
* C→P: id' = "C\0s\0abc"
|
|
1557
|
+
* P→?: decode id' → route to H or C, restore the original id
|
|
1558
|
+
* ```
|
|
1559
|
+
*
|
|
1560
|
+
* Every other edge (P→H, P→C and the responses coming back from H/C to P)
|
|
1561
|
+
* passes **verbatim** — those targets each have a single peer, so there is no
|
|
1562
|
+
* response ambiguity. The sole exception is *stream* frames for
|
|
1563
|
+
* participant-initiated requests, whose target is recovered from the small
|
|
1564
|
+
* {@link _pInitiated} map (see above); apart from that the id encodes its own
|
|
1565
|
+
* return path.
|
|
1566
|
+
*
|
|
1567
|
+
* NOTE: unlike the central {@link import('./routingHub').Hub}, the tag is
|
|
1568
|
+
* not authenticated. A misbehaving participant could emit a forged tagged
|
|
1569
|
+
* response toward H or C; each endpoint still drops ids it has no pending
|
|
1570
|
+
* request for, and identity/signing live in a higher layer. For the
|
|
1571
|
+
* single-participant overlay this is an acceptable trade for being fully
|
|
1572
|
+
* stateless. If unforgeable return paths are ever required here, HMAC the
|
|
1573
|
+
* tag with a splitter-private secret.
|
|
1574
|
+
*/
|
|
1575
|
+
var OverlaySplitter = class {
|
|
1576
|
+
_participant;
|
|
1577
|
+
_root;
|
|
1578
|
+
_uplink;
|
|
1579
|
+
_disposed = false;
|
|
1580
|
+
_inspect;
|
|
1581
|
+
/**
|
|
1582
|
+
* Per-request routing target (`C` or `H`) for requests the participant
|
|
1583
|
+
* *initiated*, keyed by P's own request id. Streams for those requests
|
|
1584
|
+
* carry P's untagged id, which alone can't say whether the call went to the
|
|
1585
|
+
* root (C) or the uplink (H); this map recovers it. Populated when P sends
|
|
1586
|
+
* the request, cleared when its response returns. The splitter's only
|
|
1587
|
+
* per-request state — every other path routes statelessly via id-tagging.
|
|
1588
|
+
*/
|
|
1589
|
+
_pInitiated = /* @__PURE__ */ new Map();
|
|
1590
|
+
constructor(_participant, _root, _uplink, inspection) {
|
|
1591
|
+
this._participant = _participant;
|
|
1592
|
+
this._root = _root;
|
|
1593
|
+
this._uplink = _uplink;
|
|
1594
|
+
this._inspect = inspection;
|
|
1595
|
+
this._participant.setListener((m) => this._onFromParticipant(m));
|
|
1596
|
+
this._root.setListener((m) => this._onFromUpstream(m, "C"));
|
|
1597
|
+
this._uplink.setListener((m) => this._onFromUpstream(m, "H"));
|
|
1598
|
+
}
|
|
1599
|
+
/** Detach all three ports. Does not dispose the transports themselves. */
|
|
1600
|
+
dispose() {
|
|
1601
|
+
if (this._disposed) return;
|
|
1602
|
+
this._disposed = true;
|
|
1603
|
+
this._participant.setListener(void 0);
|
|
1604
|
+
this._root.setListener(void 0);
|
|
1605
|
+
this._uplink.setListener(void 0);
|
|
1606
|
+
this._pInitiated.clear();
|
|
1607
|
+
}
|
|
1608
|
+
/** Edge id of an upstream origin port. */
|
|
1609
|
+
_originEdge(origin) {
|
|
1610
|
+
return origin === "H" ? this._inspect.edges.h : this._inspect.edges.c;
|
|
1611
|
+
}
|
|
1612
|
+
/** Emit a node transit if inspection is enabled. Zero-cost otherwise. */
|
|
1613
|
+
_emit(t) {
|
|
1614
|
+
if (this._inspect) this._inspect.onTransit(t);
|
|
1615
|
+
}
|
|
1616
|
+
_onFromParticipant(m) {
|
|
1617
|
+
if (isResponse(m)) {
|
|
1618
|
+
if (m.id === null) return;
|
|
1619
|
+
const dec = _decodeId(m.id);
|
|
1620
|
+
if (!dec) return;
|
|
1621
|
+
if (dec.origin === "H") {
|
|
1622
|
+
if (this._inspect) this._emit(this._mkResponse(this._inspect.edges.p, m.id, this._inspect.edges.h, dec.id, m));
|
|
1623
|
+
this._uplink.send({
|
|
1624
|
+
...m,
|
|
1625
|
+
id: dec.id
|
|
1626
|
+
});
|
|
1627
|
+
} else {
|
|
1628
|
+
if (this._inspect) this._emit(this._mkResponse(this._inspect.edges.p, m.id, this._inspect.edges.c, dec.id, m));
|
|
1629
|
+
this._root.send({
|
|
1630
|
+
...m,
|
|
1631
|
+
id: dec.id
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
const method = _methodOf(m);
|
|
1637
|
+
if (method === void 0) return;
|
|
1638
|
+
if (method === STREAM_METHOD) {
|
|
1639
|
+
this._routeParticipantStream(m);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
const rootForm = _isRootForm(method);
|
|
1643
|
+
if (rootForm === void 0) {
|
|
1644
|
+
if (this._inspect) this._emit(this._mkForward(this._inspect.edges.p, m, void 0, method, "dropped"));
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (rootForm) {
|
|
1648
|
+
if (this._inspect) this._emit(this._mkForward(this._inspect.edges.p, m, this._inspect.edges.c, method, "forwarded"));
|
|
1649
|
+
if (isRequest(m)) this._pInitiated.set(m.id, { target: "C" });
|
|
1650
|
+
this._root.send(m);
|
|
1651
|
+
} else {
|
|
1652
|
+
if (this._inspect) this._emit(this._mkForward(this._inspect.edges.p, m, this._inspect.edges.h, method, "forwarded"));
|
|
1653
|
+
if (isRequest(m)) this._pInitiated.set(m.id, { target: "H" });
|
|
1654
|
+
this._uplink.send(m);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Route a participant-emitted `$stream::send` by its `requestId` tag,
|
|
1659
|
+
* mirroring how a {@link _onFromParticipant | response} is demuxed. The
|
|
1660
|
+
* participant streams with the id it *observed*; for a request it serves
|
|
1661
|
+
* that id carries the origin tag the splitter stamped, so decoding it
|
|
1662
|
+
* yields the return port and the original id. Untagged → a request the
|
|
1663
|
+
* participant *initiated*; recover its target (C or H) from {@link
|
|
1664
|
+
* _pInitiated}, defaulting to the uplink.
|
|
1665
|
+
*/
|
|
1666
|
+
_routeParticipantStream(m) {
|
|
1667
|
+
const requestId = _streamRequestId(m);
|
|
1668
|
+
const dec = requestId !== void 0 ? _decodeId(requestId) : void 0;
|
|
1669
|
+
if (dec) {
|
|
1670
|
+
const restored = _withRequestId(m, dec.id);
|
|
1671
|
+
if (dec.origin === "H") this._uplink.send(restored);
|
|
1672
|
+
else this._root.send(restored);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
if ((requestId !== void 0 ? this._pInitiated.get(requestId) : void 0)?.target === "C") {
|
|
1676
|
+
this._root.send(m);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
this._uplink.send(m);
|
|
1680
|
+
}
|
|
1681
|
+
_onFromUpstream(m, origin) {
|
|
1682
|
+
if (isResponse(m)) {
|
|
1683
|
+
m.id === null || this._pInitiated.get(m.id);
|
|
1684
|
+
if (m.id !== null) this._pInitiated.delete(m.id);
|
|
1685
|
+
if (this._inspect) this._emit(this._mkResponse(this._originEdge(origin), m.id, this._inspect.edges.p, m.id, m));
|
|
1686
|
+
this._participant.send(m);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
const method = _methodOf(m);
|
|
1690
|
+
if (method === void 0) return;
|
|
1691
|
+
if (method === STREAM_METHOD) {
|
|
1692
|
+
if (_streamDir(m) === StreamDir.toCallee) {
|
|
1693
|
+
const requestId = _streamRequestId(m);
|
|
1694
|
+
if (requestId !== void 0) {
|
|
1695
|
+
this._participant.send(_withRequestId(m, _encodeId(origin, requestId)));
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
this._participant.send(m);
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (_isRootForm(method) === void 0) {
|
|
1703
|
+
if (this._inspect) this._emit(this._mkForward(this._originEdge(origin), m, void 0, method, "dropped"));
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
if (isRequest(m)) {
|
|
1707
|
+
const encodedId = _encodeId(origin, m.id);
|
|
1708
|
+
if (this._inspect) this._emit(this._mkForward(this._originEdge(origin), m, this._inspect.edges.p, method, "forwarded", encodedId));
|
|
1709
|
+
this._participant.send({
|
|
1710
|
+
...m,
|
|
1711
|
+
id: encodedId
|
|
1712
|
+
});
|
|
1713
|
+
} else if (isNotification(m)) {
|
|
1714
|
+
if (this._inspect) this._emit(this._mkForward(this._originEdge(origin), m, this._inspect.edges.p, method, "forwarded"));
|
|
1715
|
+
this._participant.send(m);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
/** Build a request/notification transit (`out` absent ⇒ dropped here). */
|
|
1719
|
+
_mkForward(inEdge, m, outEdge, method, disposition, outRequestId) {
|
|
1720
|
+
const id = m.id;
|
|
1721
|
+
const isReq = isRequest(m);
|
|
1722
|
+
const inEp = {
|
|
1723
|
+
edgeId: inEdge,
|
|
1724
|
+
...isReq && id !== void 0 ? { requestId: id } : {}
|
|
1725
|
+
};
|
|
1726
|
+
const outEp = outEdge === void 0 ? void 0 : {
|
|
1727
|
+
edgeId: outEdge,
|
|
1728
|
+
...isReq ? { requestId: outRequestId ?? id } : {}
|
|
1729
|
+
};
|
|
1730
|
+
return {
|
|
1731
|
+
timeMs: Date.now(),
|
|
1732
|
+
nodeId: this._inspect.nodeId,
|
|
1733
|
+
in: inEp,
|
|
1734
|
+
out: outEp,
|
|
1735
|
+
disposition,
|
|
1736
|
+
kind: isReq ? "request" : "notification",
|
|
1737
|
+
method,
|
|
1738
|
+
params: m.params
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
/** Build a response transit crossing from `inEdge`/`inId` to `outEdge`/`outId`. */
|
|
1742
|
+
_mkResponse(inEdge, inId, outEdge, outId, m) {
|
|
1743
|
+
return {
|
|
1744
|
+
timeMs: Date.now(),
|
|
1745
|
+
nodeId: this._inspect.nodeId,
|
|
1746
|
+
in: {
|
|
1747
|
+
edgeId: inEdge,
|
|
1748
|
+
...inId !== null ? { requestId: inId } : {}
|
|
1749
|
+
},
|
|
1750
|
+
out: {
|
|
1751
|
+
edgeId: outEdge,
|
|
1752
|
+
...outId !== null ? { requestId: outId } : {}
|
|
1753
|
+
},
|
|
1754
|
+
disposition: "forwarded",
|
|
1755
|
+
kind: "response",
|
|
1756
|
+
result: m.result,
|
|
1757
|
+
error: m.error
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
function _methodOf(m) {
|
|
1762
|
+
const method = m.method;
|
|
1763
|
+
return typeof method === "string" ? method : void 0;
|
|
1764
|
+
}
|
|
1765
|
+
/** Read the `requestId` correlator off a `$stream::send` notification. */
|
|
1766
|
+
function _streamRequestId(m) {
|
|
1767
|
+
const params = m.params;
|
|
1768
|
+
if (params === null || typeof params !== "object") return void 0;
|
|
1769
|
+
const requestId = params.requestId;
|
|
1770
|
+
return typeof requestId === "number" || typeof requestId === "string" ? requestId : void 0;
|
|
1771
|
+
}
|
|
1772
|
+
/** Read the `dir` discriminator off a `$stream::send` notification. */
|
|
1773
|
+
function _streamDir(m) {
|
|
1774
|
+
const params = m.params;
|
|
1775
|
+
if (params === null || typeof params !== "object") return void 0;
|
|
1776
|
+
const dir = params.dir;
|
|
1777
|
+
return dir === StreamDir.toCaller || dir === StreamDir.toCallee ? dir : void 0;
|
|
1778
|
+
}
|
|
1779
|
+
/** Clone a `$stream::send` notification with `requestId` replaced (id restore). */
|
|
1780
|
+
function _withRequestId(m, requestId) {
|
|
1781
|
+
const params = {
|
|
1782
|
+
...m.params,
|
|
1783
|
+
requestId
|
|
1784
|
+
};
|
|
1785
|
+
return {
|
|
1786
|
+
...m,
|
|
1787
|
+
params
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
/** `true` for bare/interface form, `false` for fully-qualified, `undefined` if malformed. */
|
|
1791
|
+
function _isRootForm(method) {
|
|
1792
|
+
const p = parseMethodName(method);
|
|
1793
|
+
if (!p) return void 0;
|
|
1794
|
+
return p.kind === "bare" || p.kind === "interface";
|
|
1795
|
+
}
|
|
1796
|
+
function _encodeId(origin, id) {
|
|
1797
|
+
return `${origin}${SEP}${typeof id === "number" ? "n" : "s"}${SEP}${String(id)}`;
|
|
1798
|
+
}
|
|
1799
|
+
function _decodeId(id) {
|
|
1800
|
+
if (typeof id !== "string") return void 0;
|
|
1801
|
+
const i1 = id.indexOf(SEP);
|
|
1802
|
+
if (i1 < 0) return void 0;
|
|
1803
|
+
const origin = id.slice(0, i1);
|
|
1804
|
+
if (origin !== "H" && origin !== "C") return void 0;
|
|
1805
|
+
const i2 = id.indexOf(SEP, i1 + 1);
|
|
1806
|
+
if (i2 < 0) return void 0;
|
|
1807
|
+
const type = id.slice(i1 + 1, i2);
|
|
1808
|
+
const raw = id.slice(i2 + 1);
|
|
1809
|
+
return {
|
|
1810
|
+
origin,
|
|
1811
|
+
id: type === "n" ? Number(raw) : raw
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
//#endregion
|
|
1815
|
+
//#region src/hub/server/routing/rootOverlay.ts
|
|
1816
|
+
/**
|
|
1817
|
+
* A **RootOverlay** is the per-participant front door. It is a pure
|
|
1818
|
+
* {@link OverlaySplitter} wiring with no routing table, no nested hub, and —
|
|
1819
|
+
* deliberately — **no knowledge of which services it serves**. It exposes:
|
|
1820
|
+
*
|
|
1821
|
+
* - {@link root} — the connection serving this participant's root services.
|
|
1822
|
+
* Install bootstrap/identity modules onto it with `registerHubServices` /
|
|
1823
|
+
* `registerIdentityServices` *before or after* connecting the participant.
|
|
1824
|
+
* - {@link connectParticipant} — wires the downstream participant through the
|
|
1825
|
+
* splitter (`P·root → root`, `P·* → uplink`, `uplink·* → P`).
|
|
1826
|
+
*
|
|
1827
|
+
* Because an overlay represents exactly one participant, prefix claims happen
|
|
1828
|
+
* only on the parent (via the {@link import('./routingHub').AttachedLink} that
|
|
1829
|
+
* `registerHubServices` was given); there is no local table to write.
|
|
1830
|
+
*
|
|
1831
|
+
* The overlay is generic over the per-call `TContext` its {@link root}
|
|
1832
|
+
* connection sees. Pass a context-stamping {@link ChannelTransport} to
|
|
1833
|
+
* {@link connectParticipant} to attach per-call context. Most callers connect a
|
|
1834
|
+
* plain (or signature-verifying) transport and leave `TContext` as `undefined`
|
|
1835
|
+
* (context-free).
|
|
1836
|
+
*/
|
|
1837
|
+
var RootOverlay = class {
|
|
1838
|
+
/**
|
|
1839
|
+
* The per-participant root connection. Register the overlay's root
|
|
1840
|
+
* services here — e.g. `registerHubServices(overlay.root, upstream)` and
|
|
1841
|
+
* `registerIdentityServices(overlay.root, { resolveIdentity })`. Reachable
|
|
1842
|
+
* only by this overlay's participant (root-addressed calls land here).
|
|
1843
|
+
*
|
|
1844
|
+
* Its inbound transport carries the overlay's `TContext`, so identity-aware
|
|
1845
|
+
* front doors — notably the consent `hubAccess` service — can be served here
|
|
1846
|
+
* for the interface-form calls a participant addresses to its own root.
|
|
1847
|
+
*/
|
|
1848
|
+
root;
|
|
1849
|
+
nodeId = `overlay:${randomUUID()}`;
|
|
1850
|
+
participantPortId = `port:${randomUUID()}`;
|
|
1851
|
+
rootPortId = `port:${randomUUID()}`;
|
|
1852
|
+
rootNodeId = `endpoint:${randomUUID()}`;
|
|
1853
|
+
uplinkPortId;
|
|
1854
|
+
_uplink;
|
|
1855
|
+
_rootPair;
|
|
1856
|
+
_inspection;
|
|
1857
|
+
_splitter;
|
|
1858
|
+
_disposed = false;
|
|
1859
|
+
constructor(options) {
|
|
1860
|
+
this._uplink = options.uplink;
|
|
1861
|
+
this._inspection = options.inspection;
|
|
1862
|
+
this.uplinkPortId = options.uplinkPortId ?? `port:${randomUUID()}`;
|
|
1863
|
+
this._rootPair = new TransportPair();
|
|
1864
|
+
this.root = LinkRpcConnection.fromTransport(this._rootPair.b);
|
|
1865
|
+
this.root.register(nodeInterface$1, { getNodeId: () => ({
|
|
1866
|
+
nodeId: this.nodeId,
|
|
1867
|
+
portId: this.participantPortId
|
|
1868
|
+
}) });
|
|
1869
|
+
}
|
|
1870
|
+
managedTopology(edgeLabel, peerTransport) {
|
|
1871
|
+
return {
|
|
1872
|
+
node: {
|
|
1873
|
+
nodeId: this.nodeId,
|
|
1874
|
+
kind: "hub",
|
|
1875
|
+
label: "Root overlay",
|
|
1876
|
+
ports: [
|
|
1877
|
+
{
|
|
1878
|
+
portId: this.participantPortId,
|
|
1879
|
+
label: "participant"
|
|
1880
|
+
},
|
|
1881
|
+
{
|
|
1882
|
+
portId: this.rootPortId,
|
|
1883
|
+
label: "root"
|
|
1884
|
+
},
|
|
1885
|
+
{
|
|
1886
|
+
portId: this.uplinkPortId,
|
|
1887
|
+
label: "uplink"
|
|
1888
|
+
}
|
|
1889
|
+
]
|
|
1890
|
+
},
|
|
1891
|
+
hubLinks: [{
|
|
1892
|
+
hubPortId: this.uplinkPortId,
|
|
1893
|
+
nodePortId: this.uplinkPortId,
|
|
1894
|
+
...edgeLabel !== void 0 ? { label: edgeLabel } : {}
|
|
1895
|
+
}],
|
|
1896
|
+
peerPortId: this.participantPortId,
|
|
1897
|
+
...peerTransport !== void 0 ? { peerTransport } : {},
|
|
1898
|
+
adjacentNodes: [{
|
|
1899
|
+
nodeId: this.rootNodeId,
|
|
1900
|
+
kind: "endpoint",
|
|
1901
|
+
label: "Root services",
|
|
1902
|
+
ports: [{ portId: this.rootPortId }]
|
|
1903
|
+
}],
|
|
1904
|
+
adjacentLinks: [{
|
|
1905
|
+
from: {
|
|
1906
|
+
nodeId: this.nodeId,
|
|
1907
|
+
portId: this.rootPortId
|
|
1908
|
+
},
|
|
1909
|
+
to: {
|
|
1910
|
+
nodeId: this.rootNodeId,
|
|
1911
|
+
portId: this.rootPortId
|
|
1912
|
+
}
|
|
1913
|
+
}]
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Connect the downstream participant. Root-addressed calls from it hit the
|
|
1918
|
+
* {@link root} services; prefixed calls forward via the uplink. May be
|
|
1919
|
+
* called once.
|
|
1920
|
+
*
|
|
1921
|
+
* Connect a context-stamping {@link ChannelTransport} when `TContext` is
|
|
1922
|
+
* concrete; otherwise connect the raw participant transport directly.
|
|
1923
|
+
*/
|
|
1924
|
+
connectParticipant(participant) {
|
|
1925
|
+
if (this._splitter) throw new Error("RootOverlay: participant already connected");
|
|
1926
|
+
this._splitter = new OverlaySplitter(participant, this._rootPair.a, this._uplink, this._inspection);
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* Tear the overlay down: detach the splitter and dispose the internal root
|
|
1930
|
+
* transport pair. The caller owns the uplink and the participant transport
|
|
1931
|
+
* and disposes them separately (disposing the uplink's
|
|
1932
|
+
* {@link import('./routingHub').AttachedLink} releases any claimed prefixes
|
|
1933
|
+
* on the parent). Idempotent.
|
|
1934
|
+
*/
|
|
1935
|
+
dispose() {
|
|
1936
|
+
if (this._disposed) return;
|
|
1937
|
+
this._disposed = true;
|
|
1938
|
+
this._splitter?.dispose();
|
|
1939
|
+
this._rootPair.a.dispose();
|
|
1940
|
+
this._rootPair.b.dispose();
|
|
1941
|
+
}
|
|
1942
|
+
};
|
|
1943
|
+
//#endregion
|
|
1944
|
+
//#region src/hub/server/rootServices.ts
|
|
1945
|
+
/**
|
|
1946
|
+
* Install a participant's **connection root services** onto its overlay root
|
|
1947
|
+
* connection `root`:
|
|
1948
|
+
*
|
|
1949
|
+
* - `hubGrantedServiceId::get` — the connection round-trip: reports the
|
|
1950
|
+
* `grantedServiceIdNamespace` this connection may claim freely.
|
|
1951
|
+
* - `hubGrantedServiceId::register` — claims a prefix on
|
|
1952
|
+
* `upstream` (the parent hub link) within the connection's granted namespace,
|
|
1953
|
+
* gated by `authorizeClaim` (default: namespace membership). Because an
|
|
1954
|
+
* overlay represents a single participant, *one* upstream claim suffices: all
|
|
1955
|
+
* prefixed traffic the parent routes to this overlay is for this participant,
|
|
1956
|
+
* so the {@link OverlaySplitter} delivers it without any local table.
|
|
1957
|
+
* - `hubrpc.directory::list` — by default a **referral** (own root interfaces
|
|
1958
|
+
* plus one row pointing at `<hubServiceId>::hubrpc.directory`); an
|
|
1959
|
+
* implementation is free to aggregate the parent's listing instead.
|
|
1960
|
+
* - `hubrpc.schemas::get` — schemas for this overlay's own root interfaces.
|
|
1961
|
+
*
|
|
1962
|
+
* The hub's consent front door (`hubAccess::*`) is installed separately at the
|
|
1963
|
+
* overlay root by {@link registerHubAccessService}; it needs no capability of
|
|
1964
|
+
* its own because the root is never forwarded.
|
|
1965
|
+
*
|
|
1966
|
+
* The overlay itself stays unaware of which services it serves; this is the
|
|
1967
|
+
* routing module that gives it its front door.
|
|
1968
|
+
*/
|
|
1969
|
+
function registerHubServices(root, upstream, options = {}) {
|
|
1970
|
+
const hubServiceId = options.hubServiceId ?? "hub";
|
|
1971
|
+
const grantedServiceIdNamespace = options.grantedServiceIdNamespace ?? "";
|
|
1972
|
+
const authorizeClaim = options.authorizeClaim;
|
|
1973
|
+
root.register(hubGrantedServiceIdInterface, {
|
|
1974
|
+
get: () => {
|
|
1975
|
+
return { grantedServiceIdNamespace };
|
|
1976
|
+
},
|
|
1977
|
+
getHubServiceId: () => {
|
|
1978
|
+
return { hubServiceId };
|
|
1979
|
+
},
|
|
1980
|
+
register: ({ serviceId }) => {
|
|
1981
|
+
const verdict = authorizeClaim ? authorizeClaim(serviceId) : grantedServiceIdNamespace !== "" && isServiceIdUnder(serviceId, grantedServiceIdNamespace) ? { ok: true } : {
|
|
1982
|
+
ok: false,
|
|
1983
|
+
reason: `"${serviceId}" is outside this connection's granted namespace "${grantedServiceIdNamespace}"`
|
|
1984
|
+
};
|
|
1985
|
+
if (!verdict.ok) throw new RpcError(`claim denied: ${verdict.reason}`, ErrorCode.invalidRequest);
|
|
1986
|
+
upstream.addPrefixRoute(serviceId);
|
|
1987
|
+
return {};
|
|
1988
|
+
}
|
|
1989
|
+
});
|
|
1990
|
+
root.register(directoryInterface, {
|
|
1991
|
+
list: ({ interfaceId, interfaceIdPrefix, serviceId, serviceIdScopes }) => {
|
|
1992
|
+
const referral = {
|
|
1993
|
+
serviceId: hubServiceId,
|
|
1994
|
+
interfaceId: directoryInterface.info.id,
|
|
1995
|
+
interfaceHash: directoryInterface.schemaHash,
|
|
1996
|
+
reachableServiceIds: [{ prefix: "" }]
|
|
1997
|
+
};
|
|
1998
|
+
return { items: [...root.listRegisteredInterfaces(), referral].filter((it) => interfaceId === void 0 || it.interfaceId === interfaceId).filter((it) => interfaceIdPrefix === void 0 || it.interfaceId.startsWith(interfaceIdPrefix)).filter((it) => serviceId === void 0 || it.serviceId === serviceId).filter((it) => serviceIdMatchesScopes(it.serviceId, serviceIdScopes)).map((it) => {
|
|
1999
|
+
const entry = {
|
|
2000
|
+
serviceId: it.serviceId,
|
|
2001
|
+
interfaceId: it.interfaceId,
|
|
2002
|
+
interfaceHash: it.interfaceHash
|
|
2003
|
+
};
|
|
2004
|
+
const desc = it.serviceDescription;
|
|
2005
|
+
if (desc !== void 0) entry.serviceDescription = desc;
|
|
2006
|
+
const sets = it.rootPrincipalSets;
|
|
2007
|
+
if (sets !== void 0) entry.rootPrincipalSets = sets.map((s) => [...s]);
|
|
2008
|
+
const reachable = it.reachableServiceIds;
|
|
2009
|
+
if (reachable !== void 0) entry.reachableServiceIds = [...reachable];
|
|
2010
|
+
return entry;
|
|
2011
|
+
}) };
|
|
2012
|
+
},
|
|
2013
|
+
watch: (_params, _ctx, stream) => new Promise((resolve) => {
|
|
2014
|
+
if (stream.signal.aborted) {
|
|
2015
|
+
resolve({});
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
let pending = false;
|
|
2019
|
+
const flush = () => {
|
|
2020
|
+
pending = false;
|
|
2021
|
+
if (!stream.signal.aborted) stream.send({}).catch(() => void 0);
|
|
2022
|
+
};
|
|
2023
|
+
const unsubscribe = root.onDidChangeDirectory(() => {
|
|
2024
|
+
if (pending) return;
|
|
2025
|
+
pending = true;
|
|
2026
|
+
queueMicrotask(flush);
|
|
2027
|
+
});
|
|
2028
|
+
stream.signal.addEventListener("abort", () => {
|
|
2029
|
+
unsubscribe();
|
|
2030
|
+
resolve({});
|
|
2031
|
+
}, { once: true });
|
|
2032
|
+
})
|
|
2033
|
+
});
|
|
2034
|
+
root.register(schemasInterface, { get: ({ interfaceId, hash }) => {
|
|
2035
|
+
const iface = root.findRegisteredInterface(interfaceId, hash);
|
|
2036
|
+
if (!iface) throw new RpcError("Interface not found", ErrorCode.methodNotFound, {
|
|
2037
|
+
reason: "unknown-interface",
|
|
2038
|
+
interfaceId,
|
|
2039
|
+
hash
|
|
2040
|
+
});
|
|
2041
|
+
return { schema: iface.toSchema() };
|
|
2042
|
+
} });
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Install `identity::*` (and optionally `identity.storage::*`) onto a
|
|
2046
|
+
* participant's overlay root connection. The identity is resolved lazily on
|
|
2047
|
+
* first use — see {@link registerLazyIdentityOnOverlay}.
|
|
2048
|
+
*
|
|
2049
|
+
* This is the identity counterpart to {@link registerHubServices}: a small,
|
|
2050
|
+
* composable module the overlay does not need to know about.
|
|
2051
|
+
*/
|
|
2052
|
+
function registerIdentityServices(root, options) {
|
|
2053
|
+
registerLazyIdentityOnOverlay(root, options.resolveIdentity, options.storage);
|
|
2054
|
+
}
|
|
2055
|
+
//#endregion
|
|
2056
|
+
//#region src/hub/server/hubRegister.ts
|
|
2057
|
+
/**
|
|
2058
|
+
* Annotate an inbound transport so every request carries its wire id in a
|
|
2059
|
+
* {@link RegisterCallContext}. Synchronous and signature-agnostic: it neither
|
|
2060
|
+
* verifies signatures nor checks capabilities (that has already happened on the
|
|
2061
|
+
* forwarded path — see {@link registerHubServiceIdRegistry}). It exists only so
|
|
2062
|
+
* a hub-hosted handler can recover the request id the typed channel would
|
|
2063
|
+
* otherwise strip.
|
|
2064
|
+
*/
|
|
2065
|
+
function withRequestIdContext(link) {
|
|
2066
|
+
return {
|
|
2067
|
+
send: (message) => link.send(message),
|
|
2068
|
+
setListener: (listener) => {
|
|
2069
|
+
if (listener === void 0) {
|
|
2070
|
+
link.setListener(void 0);
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
link.setListener((message) => {
|
|
2074
|
+
const requestId = isRequest(message) ? message.id : void 0;
|
|
2075
|
+
const contextual = {
|
|
2076
|
+
...message,
|
|
2077
|
+
context: { requestId }
|
|
2078
|
+
};
|
|
2079
|
+
Object.defineProperty(contextual, "context", {
|
|
2080
|
+
enumerable: false,
|
|
2081
|
+
writable: false
|
|
2082
|
+
});
|
|
2083
|
+
listener(contextual);
|
|
2084
|
+
});
|
|
2085
|
+
},
|
|
2086
|
+
dispose: () => link.dispose()
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Install the hub's privileged **claim front door** — the typed
|
|
2091
|
+
* `hubServiceIdRegistry::registerServiceId` handler — onto the hub services
|
|
2092
|
+
* `connection` (mounted under `hubServiceId`).
|
|
2093
|
+
*
|
|
2094
|
+
* Unlike the per-overlay `registerHubServices` front door — which only lets a
|
|
2095
|
+
* participant claim *within its provenance-granted namespace* — this endpoint
|
|
2096
|
+
* is the path for claiming a prefix **outside** that namespace.
|
|
2097
|
+
*
|
|
2098
|
+
* The handler is a **pure side effect**. Authentication (signature) and
|
|
2099
|
+
* authorization (an admin-rooted capability that permits
|
|
2100
|
+
* `hub::hubServiceIdRegistry::registerServiceId` for the requested
|
|
2101
|
+
* `requestedPrefix`) are enforced *before* the call ever reaches here, by the
|
|
2102
|
+
* {@link import('./forwardedCallGate').withForwardedCallGate forwarded-call
|
|
2103
|
+
* gate} every untrusted participant sits behind. By the time the request lands,
|
|
2104
|
+
* it is already known to be authentic and authorized, so the handler only:
|
|
2105
|
+
*
|
|
2106
|
+
* 1. validates the prefix is well-formed;
|
|
2107
|
+
* 2. resolves the link the request was forwarded from via
|
|
2108
|
+
* {@link Hub.getSourceTransport} (keyed by {@link RegisterCallContext.requestId});
|
|
2109
|
+
* and
|
|
2110
|
+
* 3. binds the prefix to that link via {@link Hub.claimPrefix}.
|
|
2111
|
+
*
|
|
2112
|
+
* The source transport answers only *where* to route the prefix, never
|
|
2113
|
+
* *whether* the claim is allowed.
|
|
2114
|
+
*/
|
|
2115
|
+
function registerHubServiceIdRegistry(connection, options) {
|
|
2116
|
+
const { hub, hubServiceId } = options;
|
|
2117
|
+
connection.register(hubServiceIdRegistryInterface, { registerServiceId: async ({ requestedPrefix }, ctx) => {
|
|
2118
|
+
const prefixError = validatePrefix(requestedPrefix);
|
|
2119
|
+
if (prefixError) throw new RpcError(prefixError, ErrorCode.invalidParams);
|
|
2120
|
+
const origin = ctx.requestId !== void 0 ? hub.getSourceTransport(ctx.requestId) : void 0;
|
|
2121
|
+
if (origin === void 0) throw new RpcError("no source link for claim", ErrorCode.invalidRequest);
|
|
2122
|
+
try {
|
|
2123
|
+
hub.claimPrefix(origin, requestedPrefix);
|
|
2124
|
+
} catch (e) {
|
|
2125
|
+
throw new RpcError(e instanceof Error ? e.message : String(e), ErrorCode.invalidRequest, { prefix: requestedPrefix });
|
|
2126
|
+
}
|
|
2127
|
+
return {};
|
|
2128
|
+
} }, { serviceId: hubServiceId });
|
|
2129
|
+
}
|
|
2130
|
+
//#endregion
|
|
2131
|
+
//#region src/hub/server/hubServices.ts
|
|
2132
|
+
function createHubServiceInterfaces(hub, options = {}) {
|
|
2133
|
+
const hubServiceId = options.hubServiceId ?? "hub";
|
|
2134
|
+
const link = hub.attachOut({
|
|
2135
|
+
exclusiveHubRootHandler: true,
|
|
2136
|
+
routePrefixes: [hubServiceId]
|
|
2137
|
+
});
|
|
2138
|
+
hub.markInspectionService(link, hubServiceId);
|
|
2139
|
+
const connection = LinkRpcConnection.fromTransport(withRequestIdContext(link.transport));
|
|
2140
|
+
const inspector = new HubInspector(hub);
|
|
2141
|
+
registerInspectionInterfaces(connection, hub, inspector, hubServiceId, link.portId, options.descriptors);
|
|
2142
|
+
registerHubReflection(connection, hub, hubServiceId, options.beforeDirectoryQuery);
|
|
2143
|
+
registerHubServiceIdRegistry(connection, {
|
|
2144
|
+
hub,
|
|
2145
|
+
hubServiceId
|
|
2146
|
+
});
|
|
2147
|
+
return {
|
|
2148
|
+
connection,
|
|
2149
|
+
hubServiceId,
|
|
2150
|
+
inspector,
|
|
2151
|
+
dispose: () => {
|
|
2152
|
+
inspector.dispose();
|
|
2153
|
+
connection.close();
|
|
2154
|
+
link.dispose();
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
function registerInspectionInterfaces(connection, hub, inspector, hubServiceId, portId, descriptors) {
|
|
2159
|
+
connection.register(nodeInterface, { getNodeId: () => ({
|
|
2160
|
+
nodeId: hub.nodeId,
|
|
2161
|
+
portId,
|
|
2162
|
+
...descriptors !== void 0 ? { descriptors: [...descriptors] } : {}
|
|
2163
|
+
}) }, { serviceId: hubServiceId });
|
|
2164
|
+
connection.register(topologyInterface, {
|
|
2165
|
+
getGraph: () => hub.getTopologyGraph(hubServiceId),
|
|
2166
|
+
watchGraph: (_params, _ctx, stream) => new Promise((resolve) => {
|
|
2167
|
+
if (stream.signal.aborted) {
|
|
2168
|
+
resolve({});
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
let pending = false;
|
|
2172
|
+
const flush = () => {
|
|
2173
|
+
pending = false;
|
|
2174
|
+
if (!stream.signal.aborted) stream.send({}).catch(() => void 0);
|
|
2175
|
+
};
|
|
2176
|
+
const unsubscribe = hub.onDidChangeTopology(() => {
|
|
2177
|
+
if (pending) return;
|
|
2178
|
+
pending = true;
|
|
2179
|
+
queueMicrotask(flush);
|
|
2180
|
+
});
|
|
2181
|
+
stream.signal.addEventListener("abort", () => {
|
|
2182
|
+
unsubscribe();
|
|
2183
|
+
resolve({});
|
|
2184
|
+
}, { once: true });
|
|
2185
|
+
})
|
|
2186
|
+
}, { serviceId: hubServiceId });
|
|
2187
|
+
connection.register(trafficInterface, {
|
|
2188
|
+
watch: ({ methodPrefix, trafficIgnoreKey, focusRequest }, _ctx, stream) => runTrafficWatch(inspector, {
|
|
2189
|
+
methodPrefix,
|
|
2190
|
+
trafficIgnoreKey,
|
|
2191
|
+
focusRequest
|
|
2192
|
+
}, stream),
|
|
2193
|
+
watchWithPayloads: ({ methodPrefix, maxPayloadBytes, trafficIgnoreKey, focusRequest }, _ctx, stream) => runTrafficWatch(inspector, {
|
|
2194
|
+
methodPrefix,
|
|
2195
|
+
maxPayloadBytes,
|
|
2196
|
+
trafficIgnoreKey,
|
|
2197
|
+
focusRequest
|
|
2198
|
+
}, stream)
|
|
2199
|
+
}, { serviceId: hubServiceId });
|
|
2200
|
+
}
|
|
2201
|
+
async function runTrafficWatch(inspector, options, stream) {
|
|
2202
|
+
const subscription = inspector.subscribe(options, (event) => stream.send(event));
|
|
2203
|
+
const dispose = () => subscription.dispose();
|
|
2204
|
+
if (stream.signal.aborted) dispose();
|
|
2205
|
+
else stream.signal.addEventListener("abort", dispose, { once: true });
|
|
2206
|
+
try {
|
|
2207
|
+
return await subscription.closed;
|
|
2208
|
+
} finally {
|
|
2209
|
+
stream.signal.removeEventListener("abort", dispose);
|
|
2210
|
+
subscription.dispose();
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Register the hub's **reflection** surface — `hubrpc.directory::list` /
|
|
2215
|
+
* `hubrpc.schemas::get` — on `connection`, mounted under `hubServiceId`.
|
|
2216
|
+
*
|
|
2217
|
+
* This is the always-on half of {@link createHubServiceInterfaces}: the
|
|
2218
|
+
* destination every {@link RootOverlay} directory *refers* world-view queries
|
|
2219
|
+
* to, independent of whether the signed, capability-gated claim front door
|
|
2220
|
+
* ({@link registerHubServiceIdRegistry}) is enabled.
|
|
2221
|
+
*
|
|
2222
|
+
* `directory.list` returns the hub's own services plus the referrals explicitly
|
|
2223
|
+
* returned by participant connection-root directories. Routing claims do not
|
|
2224
|
+
* create directory entries: a routable service that is not explicitly listed
|
|
2225
|
+
* remains callable when already known but is not discoverable.
|
|
2226
|
+
*/
|
|
2227
|
+
function registerHubReflection(connection, hub, hubServiceId, beforeDirectoryQuery) {
|
|
2228
|
+
connection.register(directoryInterface, {
|
|
2229
|
+
list: ({ interfaceId, interfaceIdPrefix, serviceId, serviceIdScopes }) => {
|
|
2230
|
+
return { items: [{
|
|
2231
|
+
serviceId: hubServiceId,
|
|
2232
|
+
interfaceId: directoryInterface.info.id,
|
|
2233
|
+
interfaceHash: directoryInterface.schemaHash,
|
|
2234
|
+
reachableServiceIds: [{ prefix: hubServiceId }]
|
|
2235
|
+
}].filter((it) => interfaceId === void 0 || it.interfaceId === interfaceId).filter((it) => interfaceIdPrefix === void 0 || it.interfaceId.startsWith(interfaceIdPrefix)).filter((it) => serviceId === void 0 || it.serviceId === serviceId).filter((it) => serviceIdMatchesScopes(it.serviceId, serviceIdScopes)) };
|
|
2236
|
+
},
|
|
2237
|
+
watch: directoryWatchNever
|
|
2238
|
+
});
|
|
2239
|
+
connection.register(schemasInterface, { get: ({ interfaceId, hash }) => {
|
|
2240
|
+
const iface = connection.findRegisteredInterface(interfaceId, hash);
|
|
2241
|
+
if (!iface) throw new RpcError("Interface not found", ErrorCode.methodNotFound, {
|
|
2242
|
+
reason: "unknown-interface",
|
|
2243
|
+
interfaceId,
|
|
2244
|
+
hash
|
|
2245
|
+
});
|
|
2246
|
+
return { schema: iface.toSchema() };
|
|
2247
|
+
} });
|
|
2248
|
+
connection.register(directoryInterface, {
|
|
2249
|
+
list: async ({ interfaceId, interfaceIdPrefix, serviceId, serviceIdScopes }) => {
|
|
2250
|
+
await beforeDirectoryQuery?.();
|
|
2251
|
+
const items = [];
|
|
2252
|
+
for (const r of connection.listRegisteredInterfaces()) items.push(r);
|
|
2253
|
+
const rootListings = await hub.queryParticipantRoots(`${directoryInterface.info.id}::list`, { interfaceId: directoryInterface.info.id }, hubServiceId);
|
|
2254
|
+
for (const raw of rootListings) {
|
|
2255
|
+
const page = raw;
|
|
2256
|
+
if (!page || !Array.isArray(page.items)) continue;
|
|
2257
|
+
for (const it of page.items) {
|
|
2258
|
+
if (it.serviceId === "") continue;
|
|
2259
|
+
items.push(it);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
const deduplicated = /* @__PURE__ */ new Map();
|
|
2263
|
+
for (const item of items) {
|
|
2264
|
+
const key = `${item.serviceId}\0${item.interfaceId}\0${item.interfaceHash}`;
|
|
2265
|
+
const previous = deduplicated.get(key);
|
|
2266
|
+
if (previous === void 0) {
|
|
2267
|
+
deduplicated.set(key, item);
|
|
2268
|
+
continue;
|
|
2269
|
+
}
|
|
2270
|
+
const preferred = item.serviceDescription !== void 0 || item.rootPrincipalSets !== void 0 ? item : previous;
|
|
2271
|
+
if (item.interfaceId !== directoryInterface.info.id) {
|
|
2272
|
+
deduplicated.set(key, preferred);
|
|
2273
|
+
continue;
|
|
2274
|
+
}
|
|
2275
|
+
const previousScopes = previous.reachableServiceIds ?? [{ prefix: previous.serviceId }];
|
|
2276
|
+
const itemScopes = item.reachableServiceIds ?? [{ prefix: item.serviceId }];
|
|
2277
|
+
deduplicated.set(key, {
|
|
2278
|
+
...preferred,
|
|
2279
|
+
reachableServiceIds: normalizeServiceIdScopes([...previousScopes, ...itemScopes])
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
return { items: [...deduplicated.values()].filter((it) => interfaceId === void 0 || it.interfaceId === interfaceId).filter((it) => interfaceIdPrefix === void 0 || it.interfaceId.startsWith(interfaceIdPrefix)).filter((it) => serviceId === void 0 || it.serviceId === serviceId).filter((it) => serviceIdMatchesScopes(it.serviceId, serviceIdScopes)).map((it) => ({
|
|
2283
|
+
serviceId: it.serviceId,
|
|
2284
|
+
interfaceId: it.interfaceId,
|
|
2285
|
+
interfaceHash: it.interfaceHash,
|
|
2286
|
+
...it.serviceDescription !== void 0 ? { serviceDescription: it.serviceDescription } : {},
|
|
2287
|
+
...it.rootPrincipalSets !== void 0 ? { rootPrincipalSets: it.rootPrincipalSets.map((s) => [...s]) } : {},
|
|
2288
|
+
...it.reachableServiceIds !== void 0 ? { reachableServiceIds: [...it.reachableServiceIds] } : {}
|
|
2289
|
+
})) };
|
|
2290
|
+
},
|
|
2291
|
+
watch: async (params, _ctx, stream) => {
|
|
2292
|
+
await beforeDirectoryQuery?.();
|
|
2293
|
+
return new Promise((resolve) => {
|
|
2294
|
+
if (stream.signal.aborted) {
|
|
2295
|
+
resolve({});
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
let pending = false;
|
|
2299
|
+
const flush = () => {
|
|
2300
|
+
pending = false;
|
|
2301
|
+
if (stream.signal.aborted) return;
|
|
2302
|
+
stream.send({}).catch(() => void 0);
|
|
2303
|
+
};
|
|
2304
|
+
const notify = () => {
|
|
2305
|
+
if (pending) return;
|
|
2306
|
+
pending = true;
|
|
2307
|
+
queueMicrotask(flush);
|
|
2308
|
+
};
|
|
2309
|
+
const unsubscribeRouting = hub.onDidChangeRouting(notify);
|
|
2310
|
+
const unsubscribeLocal = connection.onDidChangeDirectory(notify);
|
|
2311
|
+
const participantWatches = hub.watchParticipantRoots(`${directoryInterface.info.id}::watch`, params, notify, hubServiceId);
|
|
2312
|
+
stream.signal.addEventListener("abort", () => {
|
|
2313
|
+
unsubscribeRouting();
|
|
2314
|
+
unsubscribeLocal();
|
|
2315
|
+
participantWatches.dispose();
|
|
2316
|
+
resolve({});
|
|
2317
|
+
}, { once: true });
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
}, { serviceId: hubServiceId });
|
|
2321
|
+
connection.register(schemasInterface, { get: ({ interfaceId, hash }) => {
|
|
2322
|
+
const iface = connection.findRegisteredInterface(interfaceId, hash);
|
|
2323
|
+
if (!iface) throw new RpcError("Interface not found", ErrorCode.methodNotFound, {
|
|
2324
|
+
reason: "unknown-interface",
|
|
2325
|
+
interfaceId,
|
|
2326
|
+
hash
|
|
2327
|
+
});
|
|
2328
|
+
return { schema: iface.toSchema() };
|
|
2329
|
+
} }, { serviceId: hubServiceId });
|
|
2330
|
+
}
|
|
2331
|
+
//#endregion
|
|
2332
|
+
//#region src/hub/server/connectionTokenBinderService.ts
|
|
2333
|
+
function assertUnderPrefix(value, prefixes, field, configuredPrefix) {
|
|
2334
|
+
if (!prefixes.some((p) => value.startsWith(p))) throw new RpcError(`${field} "${value}" is outside this binder's granted ${configuredPrefix} ${JSON.stringify(prefixes)}`, ErrorCode.invalidRequest);
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Install `connectionTokenBinder::bindConnectionToken` on `connection`.
|
|
2338
|
+
*
|
|
2339
|
+
* Two forms, selected by {@link RegisterConnectionTokenBinderOptions.serviceId}:
|
|
2340
|
+
*
|
|
2341
|
+
* - **root form** (no `serviceId`): installed at a participant's overlay root
|
|
2342
|
+
* (root form, never forwarded → never gated). Safe because only the specific
|
|
2343
|
+
* trusted connection the acceptor installed it on can reach it.
|
|
2344
|
+
* - **forwarded form** (`serviceId` set): mounted as a routable hub service so
|
|
2345
|
+
* any participant can discover and call it. Safe only because forwarded calls
|
|
2346
|
+
* go through the hub's forwarded-call gate; the same prefix checks still bound
|
|
2347
|
+
* what any caller may mint.
|
|
2348
|
+
*
|
|
2349
|
+
* Each call mints a single-use token binding the requested `identitySlot` and/or
|
|
2350
|
+
* `serviceIdNamespace`, provided each requested field falls under the matching
|
|
2351
|
+
* configured prefix. Omitting a field leaves that axis unbound.
|
|
2352
|
+
*/
|
|
2353
|
+
function registerConnectionTokenBinderService(connection, options) {
|
|
2354
|
+
const { store, identitySlotPrefixes, serviceIdPrefixes, ttlMs, serviceId } = options;
|
|
2355
|
+
connection.register(connectionTokenBinderInterface, { bindConnectionToken: ({ identitySlot, serviceIdNamespace }) => {
|
|
2356
|
+
const binding = {};
|
|
2357
|
+
if (identitySlot !== void 0) {
|
|
2358
|
+
assertUnderPrefix(identitySlot, identitySlotPrefixes, "identitySlot", "identitySlotPrefix");
|
|
2359
|
+
binding.identitySlot = identitySlot;
|
|
2360
|
+
}
|
|
2361
|
+
if (serviceIdNamespace !== void 0) {
|
|
2362
|
+
assertUnderPrefix(serviceIdNamespace, serviceIdPrefixes, "serviceIdNamespace", "serviceIdPrefix");
|
|
2363
|
+
binding.grantedServiceIdNamespace = serviceIdNamespace;
|
|
2364
|
+
}
|
|
2365
|
+
const minted = ttlMs !== void 0 ? store.mint(binding, ttlMs) : store.mint(binding);
|
|
2366
|
+
return {
|
|
2367
|
+
token: minted.token,
|
|
2368
|
+
expiresAt: minted.expiresAt
|
|
2369
|
+
};
|
|
2370
|
+
} }, serviceId !== void 0 ? { serviceId } : {});
|
|
2371
|
+
}
|
|
2372
|
+
//#endregion
|
|
2373
|
+
//#region src/hub/server/connectionHandler.ts
|
|
2374
|
+
/**
|
|
2375
|
+
* Walk `factories` in order; return the first handler that claims `token`, or
|
|
2376
|
+
* `undefined` if none do (the caller drops the connection).
|
|
2377
|
+
*/
|
|
2378
|
+
function resolveConnectionHandler(factories, token) {
|
|
2379
|
+
for (const factory of factories) {
|
|
2380
|
+
const handler = factory.handle(token);
|
|
2381
|
+
if (handler !== void 0) return handler;
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Install a participant's root services from a resolved {@link RootProvision}:
|
|
2386
|
+
* the always-on claim/directory front door and consent surface, plus (when
|
|
2387
|
+
* present) the minting front door and lazy managed identity. This is the single
|
|
2388
|
+
* shared installer every built-in handler funnels through.
|
|
2389
|
+
*/
|
|
2390
|
+
function provisionRoot(ctx, provision) {
|
|
2391
|
+
registerHubServices(ctx.root, ctx.upstream, {
|
|
2392
|
+
hubServiceId: ctx.hubServiceId,
|
|
2393
|
+
...provision.grantedServiceIdNamespace !== void 0 ? { grantedServiceIdNamespace: provision.grantedServiceIdNamespace } : {},
|
|
2394
|
+
...ctx.authorizeClaim !== void 0 ? { authorizeClaim: ctx.authorizeClaim } : {}
|
|
2395
|
+
});
|
|
2396
|
+
ctx.installHubAccess?.(ctx.root);
|
|
2397
|
+
if (provision.connectionTokenBinder !== void 0) registerConnectionTokenBinderService(ctx.root, provision.connectionTokenBinder);
|
|
2398
|
+
if (provision.resolveIdentity !== void 0) registerIdentityServices(ctx.root, {
|
|
2399
|
+
resolveIdentity: provision.resolveIdentity,
|
|
2400
|
+
...provision.storage !== void 0 ? { storage: provision.storage } : {}
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* A factory that claims **any** connection (optionally gated by `claims`) and
|
|
2405
|
+
* provisions a fixed root. Used for the `anonymous` handler (claims all), the
|
|
2406
|
+
* `static` handler (claims a matching token), and dial-in endpoints (claims all,
|
|
2407
|
+
* no token).
|
|
2408
|
+
*/
|
|
2409
|
+
function fixedProvisionHandler(provision, claims = () => true) {
|
|
2410
|
+
return { handle: (token) => claims(token) ? (ctx) => provisionRoot(ctx, provision) : void 0 };
|
|
2411
|
+
}
|
|
2412
|
+
/** The `anonymous` handler: claims every connection, provisioning `provision`. */
|
|
2413
|
+
function anonymousHandler(provision) {
|
|
2414
|
+
return fixedProvisionHandler(provision);
|
|
2415
|
+
}
|
|
2416
|
+
/** The `static` handler: claims connections whose token equals `value`. */
|
|
2417
|
+
function staticTokenHandler(value, provision) {
|
|
2418
|
+
return fixedProvisionHandler(provision, (token) => token === value);
|
|
2419
|
+
}
|
|
2420
|
+
/**
|
|
2421
|
+
* The `bound` handler: claims connections whose token is live in `store`, and on
|
|
2422
|
+
* claim **redeems** it (single-use) to derive the provisioned identity slot
|
|
2423
|
+
* and/or granted serviceId namespace. `resolveSlot` turns a redeemed slot into
|
|
2424
|
+
* a lazy identity resolver plus its per-identity storage (injected so this stays
|
|
2425
|
+
* node-agnostic).
|
|
2426
|
+
*/
|
|
2427
|
+
function boundTokenHandler(store, resolveSlot) {
|
|
2428
|
+
return { handle: (token) => {
|
|
2429
|
+
if (!store.peek(token)) return void 0;
|
|
2430
|
+
return (ctx) => {
|
|
2431
|
+
const binding = store.redeem(token);
|
|
2432
|
+
if (binding === void 0) throw new Error("bound token was consumed between accept and redeem");
|
|
2433
|
+
provisionRoot(ctx, {
|
|
2434
|
+
...binding.grantedServiceIdNamespace !== void 0 ? { grantedServiceIdNamespace: binding.grantedServiceIdNamespace } : {},
|
|
2435
|
+
...binding.identitySlot !== void 0 ? resolveSlot(binding.identitySlot) : {}
|
|
2436
|
+
});
|
|
2437
|
+
};
|
|
2438
|
+
} };
|
|
2439
|
+
}
|
|
2440
|
+
//#endregion
|
|
2441
|
+
//#region src/hub/server/verifiedSignature.ts
|
|
2442
|
+
/**
|
|
2443
|
+
* Wrap an inbound transport so every request's `$hubrpc` signature is verified
|
|
2444
|
+
* **eagerly, at the door** — a pure authenticity gate with no capability check:
|
|
2445
|
+
*
|
|
2446
|
+
* - a **valid** signature → the request is delivered unchanged;
|
|
2447
|
+
* - an **invalid** signature → the request is **rejected** here (an error
|
|
2448
|
+
* response is sent and the message is never delivered);
|
|
2449
|
+
* - an **unsigned** call (no envelope) → delivered unchanged, so keyless
|
|
2450
|
+
* connections still work.
|
|
2451
|
+
*
|
|
2452
|
+
* This is the signature half of the hub's trust model. *Authorization*
|
|
2453
|
+
* (capability chains) is **not** this wrapper's job — that is enforced
|
|
2454
|
+
* separately by {@link import('./forwardedCallGate').withForwardedCallGate} on
|
|
2455
|
+
* the hub-facing (forwarded) path. The root overlay uses this wrapper on its own
|
|
2456
|
+
* root-form calls (consent / identity front doors), which never pass through the
|
|
2457
|
+
* forwarded-call gate and so need their authenticity established here.
|
|
2458
|
+
*
|
|
2459
|
+
* When `verifySignatures` is not `true`, the transport is returned unchanged (no
|
|
2460
|
+
* verification, every message passes verbatim).
|
|
2461
|
+
*
|
|
2462
|
+
* Verification is async but in-order delivery is preserved via a per-link
|
|
2463
|
+
* promise chain.
|
|
2464
|
+
*/
|
|
2465
|
+
function withVerifiedSignature(link, options = {}) {
|
|
2466
|
+
if (options.verifySignatures !== true) return link;
|
|
2467
|
+
return {
|
|
2468
|
+
send: (message) => link.send(message),
|
|
2469
|
+
setListener: (listener) => {
|
|
2470
|
+
if (listener === void 0) {
|
|
2471
|
+
link.setListener(void 0);
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
let chain = Promise.resolve();
|
|
2475
|
+
link.setListener((message) => {
|
|
2476
|
+
chain = chain.then(async () => {
|
|
2477
|
+
if (await _rejectedBadSignature(link, message)) return;
|
|
2478
|
+
listener(message);
|
|
2479
|
+
}).catch((e) => {
|
|
2480
|
+
console.error("Error in inbound signature verification:", e);
|
|
2481
|
+
});
|
|
2482
|
+
});
|
|
2483
|
+
},
|
|
2484
|
+
dispose: () => link.dispose()
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Verify a single inbound request's signature. Returns `true` (and sends an
|
|
2489
|
+
* error response over `link`) when the signature is present but invalid;
|
|
2490
|
+
* returns `false` — the message should be delivered — for a valid signature, an
|
|
2491
|
+
* unsigned call, or a non-request.
|
|
2492
|
+
*/
|
|
2493
|
+
async function _rejectedBadSignature(link, message) {
|
|
2494
|
+
if (!isRequest(message)) return false;
|
|
2495
|
+
const res = await verifyRpcCall({
|
|
2496
|
+
wireMethod: message.method,
|
|
2497
|
+
wireParams: message.params
|
|
2498
|
+
});
|
|
2499
|
+
if (!res.ok) {
|
|
2500
|
+
link.send({
|
|
2501
|
+
jsonrpc: "2.0",
|
|
2502
|
+
id: message.id,
|
|
2503
|
+
error: {
|
|
2504
|
+
code: ErrorCode.invalidRequest,
|
|
2505
|
+
message: res.reason
|
|
2506
|
+
}
|
|
2507
|
+
});
|
|
2508
|
+
return true;
|
|
2509
|
+
}
|
|
2510
|
+
return false;
|
|
2511
|
+
}
|
|
2512
|
+
//#endregion
|
|
2513
|
+
//#region src/hub/server/forwardedCallGate.ts
|
|
2514
|
+
/**
|
|
2515
|
+
* A **signature front door** for fully-qualified calls. Wrap an incoming
|
|
2516
|
+
* transport with this before attaching it to a hub or constructing the serving
|
|
2517
|
+
* connection, so every `serviceId::interfaceId::member` call must carry a
|
|
2518
|
+
* valid `$hubrpc` signature.
|
|
2519
|
+
* Unsigned or tampered calls are rejected with an error response and never
|
|
2520
|
+
* reach the downstream hub or connection.
|
|
2521
|
+
*
|
|
2522
|
+
* What passes **verbatim** (ungated):
|
|
2523
|
+
* - responses;
|
|
2524
|
+
* - root-addressed requests (`interfaceId::member`) and bare requests
|
|
2525
|
+
* (`member`) — these always terminate at the connection root and are never
|
|
2526
|
+
* forwarded; and
|
|
2527
|
+
* - requests targeting an {@link ForwardedCallGateOptions.exemptPrefixes exempt
|
|
2528
|
+
* prefix} (the hub's own services, which gate themselves).
|
|
2529
|
+
*
|
|
2530
|
+
* The gate **does not strip** the envelope: signed wire params keep the real
|
|
2531
|
+
* call params at the top level alongside `$hubrpc`/`$hubrpcUnsigned`, so the
|
|
2532
|
+
* target's typed handler recovers them via schema-stripping while a target that
|
|
2533
|
+
* cares may re-verify as defense-in-depth. Authorization is *not* this gate's
|
|
2534
|
+
* job (unless {@link ForwardedCallGateOptions.requireCapability} is set): it
|
|
2535
|
+
* proves *who* is calling, leaving *whether they may* to the capability layer.
|
|
2536
|
+
*
|
|
2537
|
+
* In-order delivery toward the hub is preserved across the async verification:
|
|
2538
|
+
* inbound messages are processed through a single serial queue.
|
|
2539
|
+
*/
|
|
2540
|
+
function withForwardedCallGate(inner, options) {
|
|
2541
|
+
return withFullyQualifiedCallGate(inner, options);
|
|
2542
|
+
}
|
|
2543
|
+
/** Explicitly named alias for {@link withForwardedCallGate}. */
|
|
2544
|
+
function withFullyQualifiedCallGate(inner, options) {
|
|
2545
|
+
return new ForwardedCallGate(inner, options);
|
|
2546
|
+
}
|
|
2547
|
+
var ForwardedCallGate = class {
|
|
2548
|
+
_inner;
|
|
2549
|
+
_options;
|
|
2550
|
+
_exempt;
|
|
2551
|
+
/**
|
|
2552
|
+
* Per-call nonces already admitted on this link. A call replaying a
|
|
2553
|
+
* nonce is rejected — this is the single replay ledger, and it makes any
|
|
2554
|
+
* `callBind`-scoped capability single-use for free.
|
|
2555
|
+
*/
|
|
2556
|
+
_seenNonces = /* @__PURE__ */ new Set();
|
|
2557
|
+
_downstream;
|
|
2558
|
+
/** Serializes inbound processing so async verification never reorders the stream. */
|
|
2559
|
+
_tail = Promise.resolve();
|
|
2560
|
+
constructor(_inner, _options) {
|
|
2561
|
+
this._inner = _inner;
|
|
2562
|
+
this._options = _options;
|
|
2563
|
+
this._exempt = new Set(_options.exemptPrefixes ?? []);
|
|
2564
|
+
}
|
|
2565
|
+
/** hub → participant: verbatim, never gated. */
|
|
2566
|
+
send(message) {
|
|
2567
|
+
this._inner.send(message);
|
|
2568
|
+
}
|
|
2569
|
+
setListener(listener) {
|
|
2570
|
+
this._downstream = listener;
|
|
2571
|
+
this._inner.setListener(listener === void 0 ? void 0 : (message) => {
|
|
2572
|
+
this._tail = this._tail.then(() => this._process(message));
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
dispose() {
|
|
2576
|
+
this._inner.dispose();
|
|
2577
|
+
}
|
|
2578
|
+
async _process(m) {
|
|
2579
|
+
if (!isRequest(m) && !isNotification(m)) {
|
|
2580
|
+
this._downstream?.(m);
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
const parsed = parseMethodName(m.method);
|
|
2584
|
+
if (!parsed || parsed.kind !== "full" || this._exempt.has(parsed.serviceId)) {
|
|
2585
|
+
this._downstream?.(m);
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
await this._gate(m);
|
|
2589
|
+
}
|
|
2590
|
+
async _gate(call) {
|
|
2591
|
+
const nowMs = this._options.nowMs?.();
|
|
2592
|
+
const res = await verifyCall({
|
|
2593
|
+
method: call.method,
|
|
2594
|
+
params: call.params,
|
|
2595
|
+
parseMethod: methodNameToTarget,
|
|
2596
|
+
requireCapability: this._options.requireCapability === true,
|
|
2597
|
+
...nowMs !== void 0 ? { nowMs } : {}
|
|
2598
|
+
});
|
|
2599
|
+
if (!res.ok) {
|
|
2600
|
+
const code = res.kind === "capability" ? ErrorCode.permissionRequired : ErrorCode.invalidRequest;
|
|
2601
|
+
this._reject(call, code, res.reason);
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
if (this._seenNonces.has(res.nonce)) {
|
|
2605
|
+
this._reject(call, ErrorCode.invalidRequest, "replayed request nonce");
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
if (this._options.requireCapability === true) {
|
|
2609
|
+
const verdict = await permits(res.call, res.capabilities, (serviceId) => this._acceptedRootIssuers(serviceId), nowMs ?? Date.now());
|
|
2610
|
+
if (!verdict.ok) {
|
|
2611
|
+
this._reject(call, ErrorCode.permissionRequired, verdict.reason);
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
this._seenNonces.add(res.nonce);
|
|
2616
|
+
this._downstream?.(call);
|
|
2617
|
+
}
|
|
2618
|
+
_reject(call, code, message) {
|
|
2619
|
+
if (!isRequest(call)) return;
|
|
2620
|
+
this._inner.send({
|
|
2621
|
+
jsonrpc: "2.0",
|
|
2622
|
+
id: call.id,
|
|
2623
|
+
error: {
|
|
2624
|
+
code,
|
|
2625
|
+
message
|
|
2626
|
+
}
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
_acceptedRootIssuers(serviceId) {
|
|
2630
|
+
if (this._options.requireCapability !== true) return [];
|
|
2631
|
+
if (this._options.trustedRoots !== void 0) return this._options.trustedRoots.map(({ principal }) => ({
|
|
2632
|
+
principal,
|
|
2633
|
+
isPublic: true
|
|
2634
|
+
}));
|
|
2635
|
+
return this._options.acceptedRootIssuers(serviceId);
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
//#endregion
|
|
2639
|
+
//#region src/hub/server/hubConnectionAcceptor.ts
|
|
2640
|
+
/**
|
|
2641
|
+
* Bridges accepted transports onto the hubv2 graph via a pluggable chain of
|
|
2642
|
+
* {@link ConnectionHandlerFactory connection handlers}. For each transport it:
|
|
2643
|
+
*
|
|
2644
|
+
* 1. attaches an uplink to the central hub and builds a {@link RootOverlay};
|
|
2645
|
+
* 2. resolves the first handler whose token check claims the connection and
|
|
2646
|
+
* lets it install the participant's root services (claim/directory front
|
|
2647
|
+
* door + consent, and optionally identity, granted namespace, minting) —
|
|
2648
|
+
* dropping the connection if none claim; and
|
|
2649
|
+
* 3. connects the participant, disposing the overlay and detaching the uplink
|
|
2650
|
+
* (which releases its claimed prefixes) when the transport closes.
|
|
2651
|
+
*
|
|
2652
|
+
* It never names a backend transport type — hand it any {@link ITransportServer}.
|
|
2653
|
+
*/
|
|
2654
|
+
var HubConnectionAcceptor = class {
|
|
2655
|
+
_options;
|
|
2656
|
+
_accepted = /* @__PURE__ */ new Set();
|
|
2657
|
+
_disposed = false;
|
|
2658
|
+
constructor(_options) {
|
|
2659
|
+
this._options = _options;
|
|
2660
|
+
this._options.server.setConnectionHandler((t) => {
|
|
2661
|
+
this._accept(t);
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
_accept(transport) {
|
|
2665
|
+
if (this._disposed) {
|
|
2666
|
+
transport.dispose();
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
let accepted;
|
|
2670
|
+
try {
|
|
2671
|
+
accepted = this._wire(transport);
|
|
2672
|
+
} catch (e) {
|
|
2673
|
+
this._options.onError?.(e instanceof Error ? e : new Error(String(e)));
|
|
2674
|
+
transport.dispose();
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
this._accepted.add(accepted);
|
|
2678
|
+
transport.onDidClose(() => {
|
|
2679
|
+
accepted.overlay.dispose();
|
|
2680
|
+
accepted.topology.dispose();
|
|
2681
|
+
accepted.upstream.dispose();
|
|
2682
|
+
this._accepted.delete(accepted);
|
|
2683
|
+
});
|
|
2684
|
+
this._options.onAttached?.({ overlay: accepted.overlay });
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2687
|
+
* Wrap the hub-facing link per the {@link ForwardCheckingPolicy}. The
|
|
2688
|
+
* option union guarantees that capability mode always carries its
|
|
2689
|
+
* `adminIds` trust anchors, so this can never silently fail closed by
|
|
2690
|
+
* forgetting them.
|
|
2691
|
+
*/
|
|
2692
|
+
_gateHubFacing(inner) {
|
|
2693
|
+
if (this._options.verifyForwardedCalls !== true) return inner;
|
|
2694
|
+
if (this._options.requireForwardedCapability === true) {
|
|
2695
|
+
const adminIds = [...this._options.adminIds];
|
|
2696
|
+
return withForwardedCallGate(inner, {
|
|
2697
|
+
requireCapability: true,
|
|
2698
|
+
acceptedRootIssuers: () => adminIds.map((nodeId) => ({
|
|
2699
|
+
principal: nodeId,
|
|
2700
|
+
isPublic: true
|
|
2701
|
+
}))
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
const adminIds = this._options.adminIds !== void 0 ? [...this._options.adminIds] : void 0;
|
|
2705
|
+
return withForwardedCallGate(inner, { ...adminIds !== void 0 ? { acceptedRootIssuers: () => adminIds.map((nodeId) => ({
|
|
2706
|
+
principal: nodeId,
|
|
2707
|
+
isPublic: true
|
|
2708
|
+
})) } : {} });
|
|
2709
|
+
}
|
|
2710
|
+
_wire(transport) {
|
|
2711
|
+
const token = transport.initializeToken;
|
|
2712
|
+
const handler = resolveConnectionHandler(this._options.handlers, token);
|
|
2713
|
+
if (handler === void 0) throw new Error("no connection handler accepted the presented token");
|
|
2714
|
+
const pair = new TransportPair();
|
|
2715
|
+
const hubFacing = this._gateHubFacing(pair.b);
|
|
2716
|
+
const upstream = this._options.hub.attach(hubFacing);
|
|
2717
|
+
const overlay = new RootOverlay({
|
|
2718
|
+
uplink: pair.a,
|
|
2719
|
+
uplinkPortId: upstream.portId
|
|
2720
|
+
});
|
|
2721
|
+
const topology = this._options.hub.registerManagedRoutingTopology(upstream, overlay.managedTopology(upstream.edgeId, transport.topologyInfo));
|
|
2722
|
+
const policy = this._options.policy;
|
|
2723
|
+
const ctx = {
|
|
2724
|
+
root: overlay.root,
|
|
2725
|
+
upstream,
|
|
2726
|
+
hub: this._options.hub,
|
|
2727
|
+
transport,
|
|
2728
|
+
token,
|
|
2729
|
+
hubServiceId: this._options.hubServiceId ?? "hub",
|
|
2730
|
+
...policy !== void 0 ? { authorizeClaim: (requestedPrefix) => policy.authorizeClaim({
|
|
2731
|
+
principal: void 0,
|
|
2732
|
+
transport,
|
|
2733
|
+
requestedPrefix
|
|
2734
|
+
}) } : {},
|
|
2735
|
+
...this._options.installHubAccess !== void 0 ? { installHubAccess: this._options.installHubAccess } : {}
|
|
2736
|
+
};
|
|
2737
|
+
try {
|
|
2738
|
+
handler(ctx);
|
|
2739
|
+
const verifySignatures = this._options.verifyForwardedCalls === true;
|
|
2740
|
+
overlay.connectParticipant(withVerifiedSignature(transport, { verifySignatures }));
|
|
2741
|
+
return {
|
|
2742
|
+
overlay,
|
|
2743
|
+
upstream,
|
|
2744
|
+
topology
|
|
2745
|
+
};
|
|
2746
|
+
} catch (error) {
|
|
2747
|
+
overlay.dispose();
|
|
2748
|
+
topology.dispose();
|
|
2749
|
+
upstream.dispose();
|
|
2750
|
+
throw error;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
dispose() {
|
|
2754
|
+
if (this._disposed) return;
|
|
2755
|
+
this._disposed = true;
|
|
2756
|
+
for (const accepted of this._accepted) {
|
|
2757
|
+
accepted.overlay.dispose();
|
|
2758
|
+
accepted.topology.dispose();
|
|
2759
|
+
accepted.upstream.dispose();
|
|
2760
|
+
}
|
|
2761
|
+
this._accepted.clear();
|
|
2762
|
+
this._options.server.dispose();
|
|
2763
|
+
}
|
|
2764
|
+
};
|
|
2765
|
+
//#endregion
|
|
2766
|
+
export { validatePrefix as S, RootOverlay as _, anonymousHandler as a, Hub as b, provisionRoot as c, registerConnectionTokenBinderService as d, createHubServiceInterfaces as f, registerIdentityServices as g, registerHubServices as h, withVerifiedSignature as i, resolveConnectionHandler as l, withRequestIdContext as m, withForwardedCallGate as n, boundTokenHandler as o, registerHubServiceIdRegistry as p, withFullyQualifiedCallGate as r, fixedProvisionHandler as s, HubConnectionAcceptor as t, staticTokenHandler as u, OverlaySplitter as v, ForwardingTable as x, HubInspector as y };
|
|
2767
|
+
|
|
2768
|
+
//# sourceMappingURL=hubConnectionAcceptor-B5-8JsFY.js.map
|