@source-repo/rpc-cli 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1131 -0
  3. package/dist/bench.d.ts +66 -0
  4. package/dist/bench.d.ts.map +1 -0
  5. package/dist/bench.js +109 -0
  6. package/dist/bench.js.map +1 -0
  7. package/dist/broker.d.ts +61 -0
  8. package/dist/broker.d.ts.map +1 -0
  9. package/dist/broker.js +56 -0
  10. package/dist/broker.js.map +1 -0
  11. package/dist/bus.d.ts +142 -0
  12. package/dist/bus.d.ts.map +1 -0
  13. package/dist/bus.js +272 -0
  14. package/dist/bus.js.map +1 -0
  15. package/dist/bus.types.json +269 -0
  16. package/dist/conform.d.ts +75 -0
  17. package/dist/conform.d.ts.map +1 -0
  18. package/dist/conform.js +152 -0
  19. package/dist/conform.js.map +1 -0
  20. package/dist/console.d.ts +285 -0
  21. package/dist/console.d.ts.map +1 -0
  22. package/dist/console.js +686 -0
  23. package/dist/console.js.map +1 -0
  24. package/dist/console.types.json +1730 -0
  25. package/dist/extract.d.ts +37 -0
  26. package/dist/extract.d.ts.map +1 -0
  27. package/dist/extract.js +272 -0
  28. package/dist/extract.js.map +1 -0
  29. package/dist/fake.d.ts +58 -0
  30. package/dist/fake.d.ts.map +1 -0
  31. package/dist/fake.js +164 -0
  32. package/dist/fake.js.map +1 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +905 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/mcp.d.ts +15 -0
  38. package/dist/mcp.d.ts.map +1 -0
  39. package/dist/mcp.js +507 -0
  40. package/dist/mcp.js.map +1 -0
  41. package/dist/network.d.ts +58 -0
  42. package/dist/network.d.ts.map +1 -0
  43. package/dist/network.js +64 -0
  44. package/dist/network.js.map +1 -0
  45. package/dist/record.d.ts +74 -0
  46. package/dist/record.d.ts.map +1 -0
  47. package/dist/record.js +221 -0
  48. package/dist/record.js.map +1 -0
  49. package/dist/tapping.d.ts +11 -0
  50. package/dist/tapping.d.ts.map +1 -0
  51. package/dist/tapping.js +71 -0
  52. package/dist/tapping.js.map +1 -0
  53. package/dist/verbs.d.ts +59 -0
  54. package/dist/verbs.d.ts.map +1 -0
  55. package/dist/verbs.js +324 -0
  56. package/dist/verbs.js.map +1 -0
  57. package/dist/web/app.css +1 -0
  58. package/dist/web/app.js +2063 -0
  59. package/dist/web/app.js.map +1 -0
  60. package/dist/web/index.html +13 -0
  61. package/package.json +65 -0
@@ -0,0 +1,686 @@
1
+ var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
2
+ var useValue = arguments.length > 2;
3
+ for (var i = 0; i < initializers.length; i++) {
4
+ value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
5
+ }
6
+ return useValue ? value : void 0;
7
+ };
8
+ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
9
+ function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
10
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
11
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
12
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
13
+ var _, done = false;
14
+ for (var i = decorators.length - 1; i >= 0; i--) {
15
+ var context = {};
16
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
17
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
18
+ context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
19
+ var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
20
+ if (kind === "accessor") {
21
+ if (result === void 0) continue;
22
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
23
+ if (_ = accept(result.get)) descriptor.get = _;
24
+ if (_ = accept(result.set)) descriptor.set = _;
25
+ if (_ = accept(result.init)) initializers.unshift(_);
26
+ }
27
+ else if (_ = accept(result)) {
28
+ if (kind === "field") initializers.unshift(_);
29
+ else descriptor[key] = _;
30
+ }
31
+ }
32
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
+ done = true;
34
+ };
35
+ import { createServer } from 'node:http';
36
+ import { createServer as createSecureServer } from 'node:https';
37
+ import { readFile } from 'node:fs/promises';
38
+ import { extname, join, resolve as resolvePath, sep } from 'node:path';
39
+ import { fileURLToPath } from 'node:url';
40
+ import { EventEmitter } from 'events';
41
+ import { MqttTransport, RpcServer, TransportEvent, rpc, rpcNamespace } from '@source-repo/rpc';
42
+ import { networkTransports } from './network.js';
43
+ import { BusService, DEFAULT_TAP_TTL } from './bus.js';
44
+ // The tap's own contract, merged with the console's below: one server, and a schema has to describe
45
+ // every namespace it serves or the ones it leaves out are refused their argument types.
46
+ import busContract from './bus.types.json' with { type: 'json' };
47
+ // Extracted from this file by `npm run contract`, and committed so it is reviewable and so
48
+ // `source-rpc check` can catch a change to the service that would refuse a page built against the old
49
+ // one. The console describing itself with the same machinery it shows other peers is the point:
50
+ // what it cannot describe here, nobody else can describe either.
51
+ import contract from './console.types.json' with { type: 'json' };
52
+ /**
53
+ * A browser console for a live msgrpc network: which peers are up, what each one exposes, and a
54
+ * form to call it and watch its events.
55
+ *
56
+ * Peer discovery is nearly free. Every peer publishes retained presence, so subscribing to
57
+ * <prefix>/presence/+ hands over everyone already online immediately - no scanning, no probing.
58
+ *
59
+ * The browser reaches this over msgrpc itself. The CLI runs an RpcServer on the same HTTP server
60
+ * that serves the page, so calls and the event stream both ride the library rather than a REST and
61
+ * SSE pair written for the occasion - and the console becomes the library's own first client.
62
+ */
63
+ /**
64
+ * Where the page learns which peer to address. Everything else the console offers is RPC, but a
65
+ * client has to know the name before it can call anything, and the console's name is now its name
66
+ * on the network rather than a constant - two consoles on one bus cannot both be 'msgrpc-console'.
67
+ */
68
+ export const consoleIdentityPath = '/console.json';
69
+ /**
70
+ * A mount point as `/tools/console/`, or `/` for none: one leading slash, one trailing slash.
71
+ *
72
+ * The trailing slash is the load-bearing part. Everything the page asks for is relative to its
73
+ * mount, so the app has to be reached at a path ending in one or its requests resolve a level up.
74
+ */
75
+ export const normaliseBasePath = (basePath) => {
76
+ const trimmed = (basePath ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '');
77
+ return trimmed ? `/${trimmed}/` : '/';
78
+ };
79
+ /** How much history the console keeps. These arrive unasked, so the buffers are bounded. */
80
+ const PROBLEM_HISTORY = 200;
81
+ /**
82
+ * What a description says the peer is. A broker exposes the tap and nothing else; a console has its
83
+ * own namespace; a page hosts the chat service the console app ships. Anything else answering for
84
+ * itself is a device, and one that cannot be described is worth distinguishing from one that has
85
+ * not been described yet.
86
+ */
87
+ const roleFrom = (description) => {
88
+ const namespaces = description.namespaces.map((namespace) => namespace.name);
89
+ if (namespaces.includes('bus') && !namespaces.includes('console'))
90
+ return 'broker';
91
+ if (namespaces.includes('console'))
92
+ return 'console';
93
+ if (namespaces.includes('chat'))
94
+ return 'page';
95
+ return 'device';
96
+ };
97
+ /** What a browser may ask this console to do. Everything else on the class stays local. */
98
+ let ConsoleService = (() => {
99
+ let _classDecorators = [rpcNamespace('console')];
100
+ let _classDescriptor;
101
+ let _classExtraInitializers = [];
102
+ let _classThis;
103
+ let _classSuper = EventEmitter;
104
+ let _instanceExtraInitializers = [];
105
+ let _peers_decorators;
106
+ let _problems_decorators;
107
+ let _presence_decorators;
108
+ let _describe_decorators;
109
+ let _call_decorators;
110
+ let _watch_decorators;
111
+ let _unwatch_decorators;
112
+ let _tap_decorators;
113
+ let _untap_decorators;
114
+ let _taps_decorators;
115
+ var ConsoleService = class extends _classSuper {
116
+ static { _classThis = this; }
117
+ static {
118
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
119
+ _peers_decorators = [rpc];
120
+ _problems_decorators = [rpc];
121
+ _presence_decorators = [rpc];
122
+ _describe_decorators = [rpc];
123
+ _call_decorators = [rpc];
124
+ _watch_decorators = [rpc];
125
+ _unwatch_decorators = [rpc];
126
+ _tap_decorators = [rpc];
127
+ _untap_decorators = [rpc];
128
+ _taps_decorators = [rpc];
129
+ __esDecorate(this, null, _peers_decorators, { kind: "method", name: "peers", static: false, private: false, access: { has: obj => "peers" in obj, get: obj => obj.peers }, metadata: _metadata }, null, _instanceExtraInitializers);
130
+ __esDecorate(this, null, _problems_decorators, { kind: "method", name: "problems", static: false, private: false, access: { has: obj => "problems" in obj, get: obj => obj.problems }, metadata: _metadata }, null, _instanceExtraInitializers);
131
+ __esDecorate(this, null, _presence_decorators, { kind: "method", name: "presence", static: false, private: false, access: { has: obj => "presence" in obj, get: obj => obj.presence }, metadata: _metadata }, null, _instanceExtraInitializers);
132
+ __esDecorate(this, null, _describe_decorators, { kind: "method", name: "describe", static: false, private: false, access: { has: obj => "describe" in obj, get: obj => obj.describe }, metadata: _metadata }, null, _instanceExtraInitializers);
133
+ __esDecorate(this, null, _call_decorators, { kind: "method", name: "call", static: false, private: false, access: { has: obj => "call" in obj, get: obj => obj.call }, metadata: _metadata }, null, _instanceExtraInitializers);
134
+ __esDecorate(this, null, _watch_decorators, { kind: "method", name: "watch", static: false, private: false, access: { has: obj => "watch" in obj, get: obj => obj.watch }, metadata: _metadata }, null, _instanceExtraInitializers);
135
+ __esDecorate(this, null, _unwatch_decorators, { kind: "method", name: "unwatch", static: false, private: false, access: { has: obj => "unwatch" in obj, get: obj => obj.unwatch }, metadata: _metadata }, null, _instanceExtraInitializers);
136
+ __esDecorate(this, null, _tap_decorators, { kind: "method", name: "tap", static: false, private: false, access: { has: obj => "tap" in obj, get: obj => obj.tap }, metadata: _metadata }, null, _instanceExtraInitializers);
137
+ __esDecorate(this, null, _untap_decorators, { kind: "method", name: "untap", static: false, private: false, access: { has: obj => "untap" in obj, get: obj => obj.untap }, metadata: _metadata }, null, _instanceExtraInitializers);
138
+ __esDecorate(this, null, _taps_decorators, { kind: "method", name: "taps", static: false, private: false, access: { has: obj => "taps" in obj, get: obj => obj.taps }, metadata: _metadata }, null, _instanceExtraInitializers);
139
+ __esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
140
+ ConsoleService = _classThis = _classDescriptor.value;
141
+ if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
142
+ __runInitializers(_classThis, _classExtraInitializers);
143
+ }
144
+ online = __runInitializers(this, _instanceExtraInitializers);
145
+ callTimeout;
146
+ /**
147
+ * Subscriptions this console holds on the network, keyed by peer/namespace/event. The handler
148
+ * is kept because removing a listener needs the same function reference that was registered.
149
+ */
150
+ watching = new Map();
151
+ /**
152
+ * The console's own place on the network, set once it exists. Every call the browser asks for
153
+ * goes out through this: one server holding the browser link, the broker and the hub, so a
154
+ * peer's name is enough - the registry knows which link reaches it.
155
+ */
156
+ network;
157
+ useNetwork(network) {
158
+ this.network = network;
159
+ }
160
+ /**
161
+ * This console's own tap, when it has an MQTT link. There is no broker of ours on an MQTT
162
+ * network to ask, so the console does the watching itself - see `startConsole`.
163
+ */
164
+ localBus;
165
+ useLocalTap(bus) {
166
+ this.localBus = bus;
167
+ bus.on('frame', (frame) => this.emit('frame', frame));
168
+ }
169
+ /** Which link each peer was last seen on, written by startConsole as they arrive. */
170
+ links = new Map();
171
+ /**
172
+ * The flags this console was started with, so the page can render a command line that actually
173
+ * runs. A call worth making in the browser is usually one worth putting in a script, and
174
+ * retyping `--hub http://…` from memory is where that stops happening.
175
+ */
176
+ startedWith = {};
177
+ /** What has gone wrong on the links, newest first and bounded. */
178
+ seen = [];
179
+ /**
180
+ * Peers arriving and leaving, newest first and bounded.
181
+ *
182
+ * Kept because a peer that comes and goes is one of the commonest faults on a plant and the
183
+ * hardest to catch in the act: the console showed it as a dot that changed colour and forgot,
184
+ * so a device flapping every thirty seconds looked exactly like one that was simply up.
185
+ */
186
+ comings = [];
187
+ /** What each peer turned out to be, from descriptions already made. */
188
+ roles = new Map();
189
+ notePresence(change) {
190
+ this.comings.unshift(change);
191
+ if (this.comings.length > PROBLEM_HISTORY)
192
+ this.comings.length = PROBLEM_HISTORY;
193
+ this.emit('peer', change);
194
+ }
195
+ /** Records a problem and passes it on, so a page open now sees it and one opened later still can. */
196
+ noteProblem(problem) {
197
+ this.seen.unshift(problem);
198
+ if (this.seen.length > PROBLEM_HISTORY)
199
+ this.seen.length = PROBLEM_HISTORY;
200
+ this.emit('problem', problem);
201
+ }
202
+ /** Console-side token -> the taps it opened, here and on other peers, and when they lapse. */
203
+ held = new Map();
204
+ /** Peers whose `frame` event this console has already subscribed to, so it subscribes once. */
205
+ forwarding = new Map();
206
+ tapCounter = 0;
207
+ constructor(
208
+ /** Every peer the console can see, on any of its links. */
209
+ online,
210
+ /** How long this console waits on the network, reported so the browser can wait longer. */
211
+ callTimeout) {
212
+ super();
213
+ this.online = online;
214
+ this.callTimeout = callTimeout;
215
+ }
216
+ /** Refuses early for a peer nothing has announced, rather than waiting out a call timeout. */
217
+ reach(peer) {
218
+ if (!this.network || !this.online.has(peer))
219
+ throw Object.assign(new Error(`${peer} is not a peer this console can see`), { code: 'ClassNotFound' });
220
+ return this.network;
221
+ }
222
+ async peers() {
223
+ return {
224
+ peers: [...this.online].sort(),
225
+ watching: [...this.watching.keys()],
226
+ callTimeout: this.callTimeout,
227
+ network: this.startedWith,
228
+ // Filled in as peers are described for other reasons, so this costs no extra traffic.
229
+ roles: Object.fromEntries(this.roles),
230
+ // Which link each peer was found on. The console holds the browser's, the broker's and
231
+ // the hub's at once, and on a plant where the devices are on one and the HMIs on
232
+ // another that is the first thing worth knowing about a peer.
233
+ links: Object.fromEntries(this.links)
234
+ };
235
+ }
236
+ /**
237
+ * What has gone wrong on the links, newest first.
238
+ *
239
+ * Fetched as well as streamed, because these arrive whether or not anyone is watching and the
240
+ * interesting ones are usually the ones from before you went looking.
241
+ */
242
+ async problems() {
243
+ return { problems: [...this.seen] };
244
+ }
245
+ /**
246
+ * Peers arriving and leaving, newest first - including before this page was opened, which is
247
+ * the half that matters when the question is whether something has been flapping.
248
+ */
249
+ async presence() {
250
+ return { changes: [...this.comings] };
251
+ }
252
+ async describe(peer) {
253
+ try {
254
+ const proxy = await this.reach(peer).proxy('msgrpc', peer);
255
+ const description = await proxy.remote.describe();
256
+ // Every description teaches what the peer is, whoever asked for it and why.
257
+ this.roles.set(peer, roleFrom(description));
258
+ return description;
259
+ }
260
+ catch (e) {
261
+ const failure = asFailure(e);
262
+ if (failure.code === 'ClassNotFound')
263
+ this.roles.set(peer, 'undescribed');
264
+ return failure;
265
+ }
266
+ }
267
+ async call(peer, namespace, method, args = []) {
268
+ const started = Date.now();
269
+ try {
270
+ const proxy = await this.reach(peer).proxy(namespace, peer);
271
+ return { result: await proxy.remote[method](...args), ms: Date.now() - started };
272
+ }
273
+ catch (e) {
274
+ // Reported rather than thrown: an RpcError's code is the useful part, and it would be
275
+ // flattened into a generic exception on its way back to the browser.
276
+ return { ...asFailure(e), ms: Date.now() - started };
277
+ }
278
+ }
279
+ async watch(peer, namespace, event) {
280
+ const key = `${peer}/${namespace}/${event}`;
281
+ if (this.watching.has(key))
282
+ return { watching: true, already: true };
283
+ const handler = (...args) => this.emit('event', { peer, namespace, event, args, at: Date.now() });
284
+ const proxy = await this.reach(peer).proxy(namespace, peer);
285
+ await proxy.remote.on(event, handler);
286
+ this.watching.set(key, handler);
287
+ return { watching: true, already: false };
288
+ }
289
+ async unwatch(peer, namespace, event) {
290
+ const key = `${peer}/${namespace}/${event}`;
291
+ const handler = this.watching.get(key);
292
+ if (!handler)
293
+ return { watching: false, already: true };
294
+ const proxy = await this.reach(peer).proxy(namespace, peer);
295
+ // Removes the local listener and tells the server to drop its side.
296
+ await proxy.remote.off(event, handler);
297
+ this.watching.delete(key);
298
+ return { watching: false, already: false };
299
+ }
300
+ /**
301
+ * Peers that can watch traffic: any exposing a `bus`, which in practice is the broker.
302
+ *
303
+ * Found by describing rather than configured, because the broker is a peer like any other and
304
+ * the console has no idea which of them it is. Cached: the answer changes only when a broker
305
+ * joins or leaves, and describing every peer on each tap would be a round trip per peer.
306
+ */
307
+ async busPeers() {
308
+ const asking = [...this.online].filter((peer) => !this.knownBuses.has(peer));
309
+ // Asked all at once, not one after another. A peer that is registered but no longer
310
+ // answering - a page whose tab was closed, most often - takes the whole call timeout to
311
+ // fail, and in sequence that is one timeout per stale peer before the tap starts at all.
312
+ await Promise.all(asking.map(async (peer) => {
313
+ const described = await this.describe(peer);
314
+ this.knownBuses.set(peer, !('error' in described) && described.namespaces.some((namespace) => namespace.name === 'bus'));
315
+ }));
316
+ return [...this.online].filter((peer) => this.knownBuses.get(peer));
317
+ }
318
+ knownBuses = new Map();
319
+ /** A peer that has gone is worth asking about again if it comes back under new software. */
320
+ forgetBus(peer) {
321
+ this.knownBuses.delete(peer);
322
+ this.roles.delete(peer);
323
+ }
324
+ /**
325
+ * Start watching traffic, wherever this console can watch it.
326
+ *
327
+ * A socket.io network is watched at the broker, which is the only thing that sees frames it is
328
+ * not party to; an MQTT network is watched by this console's own subscription, since there is
329
+ * no broker of ours there to ask. A console holding both links turns on both, and the frames
330
+ * arrive on one event either way - which is what keeps the page from having to know any of this.
331
+ */
332
+ async tap(filter) {
333
+ const token = `console-tap-${++this.tapCounter}`;
334
+ const opened = [];
335
+ // Asked before anything starts watching, since describing every peer is itself traffic and
336
+ // a tap that opened first would report the console looking for it as the first thing it saw.
337
+ const buses = await this.busPeers();
338
+ if (this.localBus)
339
+ opened.push({ peer: 'this console', token: (await this.localBus.tap(filter)).token });
340
+ for (const peer of buses) {
341
+ try {
342
+ const proxy = await this.reach(peer).proxy('bus', peer);
343
+ const answer = await proxy.remote.tap(filter);
344
+ opened.push({ peer, token: answer.token });
345
+ if (!this.forwarding.has(peer)) {
346
+ // Subscribed once per peer however many taps are open: the frames already say
347
+ // which taps they matched, and a second subscription would duplicate them all.
348
+ const handler = (frame) => this.emit('frame', frame);
349
+ await proxy.remote.on('frame', handler);
350
+ this.forwarding.set(peer, handler);
351
+ }
352
+ }
353
+ catch {
354
+ // A peer that has gone, or one whose bus refused. The others still work, and a tap
355
+ // that turned nothing on is reported by its empty source list rather than by
356
+ // failing - which would lose the sources that did start.
357
+ this.knownBuses.delete(peer);
358
+ }
359
+ }
360
+ // Given the same life as the taps it stands for, so a page that closed without untapping -
361
+ // a reload is enough - takes its entry with it rather than leaving one here for the life of
362
+ // the console. The taps themselves expire on their own; this is the console's side of that.
363
+ this.held.set(token, { expires: Date.now() + (filter?.ttl ?? DEFAULT_TAP_TTL) * 1000, opened });
364
+ return { token, sources: opened.map((entry) => entry.peer) };
365
+ }
366
+ async untap(token) {
367
+ const entry_ = this.held.get(token);
368
+ if (!entry_)
369
+ return { tapping: false, already: true };
370
+ this.held.delete(token);
371
+ for (const entry of entry_.opened) {
372
+ if (entry.peer === 'this console') {
373
+ await this.localBus?.untap(entry.token);
374
+ continue;
375
+ }
376
+ try {
377
+ const proxy = await this.reach(entry.peer).proxy('bus', entry.peer);
378
+ await proxy.remote.untap(entry.token);
379
+ }
380
+ catch {
381
+ // The tap expires on its own if the peer is unreachable, so there is nothing left
382
+ // to do about it here and nothing worth failing the call over.
383
+ }
384
+ }
385
+ await this.stopForwardingIfIdle();
386
+ return { tapping: false, already: false };
387
+ }
388
+ /** Everything this console is watching, and where. */
389
+ async taps() {
390
+ await this.expireTaps();
391
+ return {
392
+ taps: [...this.held.entries()].map(([token, entry]) => ({ token, sources: entry.opened.map((source) => source.peer) })),
393
+ sources: [...(this.localBus ? ['this console'] : []), ...(await this.busPeers())]
394
+ };
395
+ }
396
+ /**
397
+ * Drops taps whose life has run out, and the subscriptions they were holding.
398
+ *
399
+ * The taps themselves have already lapsed at the far end by now; this is what stops the console
400
+ * forwarding frames for them, and what keeps `held` from growing by one on every page reload.
401
+ */
402
+ async expireTaps() {
403
+ const now = Date.now();
404
+ for (const [token, entry] of this.held)
405
+ if (entry.expires <= now)
406
+ this.held.delete(token);
407
+ await this.stopForwardingIfIdle();
408
+ }
409
+ /**
410
+ * Drops the subscriptions on the peers doing the watching once nothing here wants them.
411
+ *
412
+ * Kept until then rather than per tap, since several taps share one subscription - and dropped
413
+ * when the last goes, so a broker is not left emitting frames into a console that stopped
414
+ * reading them.
415
+ */
416
+ async stopForwardingIfIdle() {
417
+ if (this.held.size)
418
+ return;
419
+ for (const [peer, handler] of this.forwarding) {
420
+ try {
421
+ const proxy = await this.reach(peer).proxy('bus', peer);
422
+ await proxy.remote.off('frame', handler);
423
+ }
424
+ catch {
425
+ // Gone, which drops its side anyway.
426
+ }
427
+ }
428
+ this.forwarding.clear();
429
+ }
430
+ /** Drops every subscription this console holds, so servers that outlive it keep no listeners. */
431
+ async releaseAll() {
432
+ for (const token of [...this.held.keys()])
433
+ await this.untap(token).catch(() => undefined);
434
+ for (const key of [...this.watching.keys()]) {
435
+ const [peer, namespace, event] = key.split('/');
436
+ await this.unwatch(peer, namespace, event).catch(() => undefined);
437
+ }
438
+ }
439
+ };
440
+ return ConsoleService = _classThis;
441
+ })();
442
+ export { ConsoleService };
443
+ const asFailure = (e) => {
444
+ const error = e;
445
+ return { error: error.message ?? String(e), code: error.code };
446
+ };
447
+ /**
448
+ * One schema for the two namespaces this server exposes.
449
+ *
450
+ * They are extracted separately because each is a contract in its own right - the broker serves
451
+ * `bus` without `console` - and merged here because a schema describes a server rather than a
452
+ * class. Named types are merged too: the two files share none, and a collision would be a bug in
453
+ * whichever one added the duplicate rather than something to resolve at runtime.
454
+ */
455
+ const consoleAndBus = {
456
+ schema: 1,
457
+ namespaces: { ...contract.namespaces, ...busContract.namespaces },
458
+ types: { ...contract.types, ...busContract.types }
459
+ };
460
+ /** The built app, sitting next to this file once the CLI is compiled. */
461
+ const webRoot = fileURLToPath(new URL('./web/', import.meta.url));
462
+ const contentTypes = {
463
+ '.html': 'text/html; charset=utf-8',
464
+ '.js': 'text/javascript; charset=utf-8',
465
+ '.css': 'text/css; charset=utf-8',
466
+ '.json': 'application/json; charset=utf-8',
467
+ '.svg': 'image/svg+xml',
468
+ '.png': 'image/png',
469
+ '.ico': 'image/x-icon',
470
+ '.woff2': 'font/woff2',
471
+ '.map': 'application/json; charset=utf-8'
472
+ };
473
+ const serveAsset = async (pathname, response, identity) => {
474
+ if (pathname === consoleIdentityPath && identity) {
475
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
476
+ response.end(JSON.stringify(identity));
477
+ return;
478
+ }
479
+ const requested = decodeURIComponent(pathname).replace(/^\/+/, '') || 'index.html';
480
+ const file = resolvePath(join(webRoot, requested));
481
+ // The path comes from a URL, so it has to be proven to stay inside the asset directory rather
482
+ // than assumed to: `..` segments survive both the join and the decode.
483
+ const inside = file === resolvePath(webRoot) || file.startsWith(resolvePath(webRoot) + sep);
484
+ try {
485
+ if (!inside)
486
+ throw Object.assign(new Error('outside'), { code: 'ENOENT' });
487
+ const body = await readFile(file);
488
+ response.writeHead(200, { 'content-type': contentTypes[extname(file)] ?? 'application/octet-stream' });
489
+ response.end(body);
490
+ }
491
+ catch {
492
+ // One page, client-side state: an unknown path is a route, not a missing file.
493
+ try {
494
+ const index = await readFile(join(webRoot, 'index.html'));
495
+ response.writeHead(200, { 'content-type': contentTypes['.html'] });
496
+ response.end(index);
497
+ }
498
+ catch {
499
+ response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
500
+ response.end('The console app is not built. Run `npm run build` in @source-repo/rpc-cli.\n');
501
+ }
502
+ }
503
+ };
504
+ export const startConsole = async (options) => {
505
+ if (!options.broker && !options.hub)
506
+ throw new Error('startConsole: give it a broker, a hub, or both');
507
+ /** Every peer the console can see, on any of its links. */
508
+ const online = new Set();
509
+ const service = new ConsoleService(online, options.callTimeout);
510
+ /**
511
+ * The console's own tap on an MQTT network, opened when someone asks and closed when the last
512
+ * of them stops.
513
+ *
514
+ * A second connection rather than a wildcard added to the console's own: a client subscribed to
515
+ * both its own topic and the wildcard covering it has overlapping subscriptions, and a broker
516
+ * may deliver a matching message once per subscription - which for a request means the method
517
+ * runs twice. It also means nothing is subscribed until it is wanted, so an idle console costs
518
+ * a plant broker nothing.
519
+ */
520
+ let tapLink;
521
+ const localBus = options.broker ? new BusService(options.name) : undefined;
522
+ if (localBus) {
523
+ localBus.onDemand = {
524
+ start: async () => {
525
+ if (tapLink)
526
+ return;
527
+ tapLink = new MqttTransport(`${options.name}-tap`, options.broker, {
528
+ ...(options.prefix ? { prefix: options.prefix } : {}),
529
+ tap: true,
530
+ // It watches; it is not a peer anyone should call or wait for.
531
+ presence: false
532
+ });
533
+ tapLink.on(TransportEvent.relayed, (relayed) => localBus.observe(relayed));
534
+ await tapLink.open();
535
+ await tapLink.ready();
536
+ },
537
+ stop: async () => {
538
+ const closing = tapLink;
539
+ tapLink = undefined;
540
+ await closing?.close();
541
+ }
542
+ };
543
+ service.useLocalTap(localBus);
544
+ }
545
+ const base = normaliseBasePath(options.basePath);
546
+ // One handler either way: an https.Server is an http.Server with a certificate in front, and
547
+ // socket.io attaches to it identically.
548
+ const serve = (request, response) => {
549
+ const pathname = new URL(request.url ?? '/', 'http://console').pathname;
550
+ if (base !== '/') {
551
+ // The mount point without its trailing slash. Redirected rather than served, because
552
+ // the page that would come back resolves its assets, console.json and socket.io path
553
+ // relative to where it was served - and from `/tools/console` that is `/tools/`, so
554
+ // every one of them would miss. This is the only place that can be put right.
555
+ if (pathname === base.slice(0, -1)) {
556
+ response.writeHead(301, { location: base });
557
+ response.end();
558
+ return;
559
+ }
560
+ if (!pathname.startsWith(base)) {
561
+ // Not a route of this app. Answered plainly rather than with the index, which would
562
+ // claim the whole origin from whatever else the proxy publishes beside it.
563
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
564
+ response.end(`The console is published under ${base}\n`);
565
+ return;
566
+ }
567
+ }
568
+ // serveAsset handles its own failures, so reaching this catch means the response itself
569
+ // could not be written. Answering is still better than rejecting into nowhere.
570
+ void serveAsset(base === '/' ? pathname : `/${pathname.slice(base.length)}`, response, { name: options.name }).catch(() => {
571
+ if (!response.headersSent)
572
+ response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
573
+ response.end('The console could not serve this request.\n');
574
+ });
575
+ };
576
+ const http = options.tls ? createSecureServer(options.tls, serve) : createServer(serve);
577
+ // One server, one graph. The browsers, the broker and the hub are transports of the same
578
+ // RpcServer, so its peer registry spans all of them: a page is a peer of the network rather
579
+ // than something behind a separate client, and the console relays between the two the way any
580
+ // server does. That is what lets a service hosted in a page be reached from the plant, and it
581
+ // is why the console can call anything with one proxy() rather than a client per network.
582
+ const network = new RpcServer({
583
+ name: options.name,
584
+ callTimeout: options.callTimeout,
585
+ readyTimeout: 15000,
586
+ // So another console can describe this one and get argument fields rather than `call(…)`.
587
+ // Both namespaces, since one server serves both and a schema that named only `console`
588
+ // would leave `bus` to be described as `tap(…)` with no argument types at all.
589
+ schema: consoleAndBus,
590
+ exposeIntrospection: true,
591
+ transports: [
592
+ // socket.io attaches to the same http server and answers /socket.io before the static
593
+ // handler sees it, so the console is one port: page and RPC over the same origin. Under
594
+ // a base path it moves with the rest of the app, which is what the page will ask for -
595
+ // it derives the same path from where the document was served.
596
+ { server: http, ...(base === '/' ? {} : { path: `${base}socket.io` }) },
597
+ ...networkTransports(options)
598
+ ]
599
+ });
600
+ service.useNetwork(network);
601
+ service.startedWith = {
602
+ ...(options.broker ? { broker: options.broker } : {}),
603
+ ...(options.hub ? { hub: options.hub } : {}),
604
+ ...(options.prefix ? { prefix: options.prefix } : {})
605
+ };
606
+ network.exposeClassInstance(service);
607
+ // The console's own tap, exposed like any other so `source-rpc call <console> bus.tap` works and
608
+ // another console can watch this one's MQTT link.
609
+ if (localBus)
610
+ network.exposeClassInstance(localBus);
611
+ // After ready(): transports are built asynchronously now, so before it there is nothing to
612
+ // listen to. Whoever announced themselves during startup is already in the registry, so the
613
+ // list is seeded from there rather than waiting for them to arrive twice.
614
+ await network.ready();
615
+ // In the order the transports were built above, so index 0 is the link the browser arrives on.
616
+ // A peer's link is worth naming by where it is rather than what the transport calls itself,
617
+ // which for the MQTT one is this console's own name.
618
+ const linkNames = ['this console', ...(options.broker ? [options.broker] : []), ...(options.hub ? [options.hub] : [])];
619
+ const linkOf = (peer) => {
620
+ // The registry knows which module carries a peer, which is how a peer already present at
621
+ // startup gets a link at all: it announced itself before there was a listener to hear it.
622
+ const carrier = network.peers.get(peer);
623
+ const index = network.transports.findIndex((transport) => transport === carrier);
624
+ return index === -1 ? undefined : linkNames[index];
625
+ };
626
+ for (const peer of network.peers.names()) {
627
+ if (peer === options.name)
628
+ continue;
629
+ online.add(peer);
630
+ const link = linkOf(peer);
631
+ if (link)
632
+ service.links.set(peer, link);
633
+ }
634
+ network.transports.forEach((transport, index) => {
635
+ const link = linkNames[index] ?? transport.getName();
636
+ transport.on(TransportEvent.peerOnline, (peer) => {
637
+ if (peer === options.name)
638
+ return;
639
+ service.links.set(peer, link);
640
+ if (online.has(peer))
641
+ return;
642
+ online.add(peer);
643
+ service.notePresence({ peer, state: 'online', at: Date.now(), link });
644
+ });
645
+ transport.on(TransportEvent.peerGone, (peer) => {
646
+ // Asked again if it returns: a broker restarted with a tap is a different answer.
647
+ service.forgetBus(peer);
648
+ service.links.delete(peer);
649
+ if (!online.delete(peer))
650
+ return;
651
+ service.notePresence({ peer, state: 'offline', at: Date.now(), link });
652
+ });
653
+ // The four the transports have always emitted and nothing ever listened to. Between them
654
+ // they cover every way a call disappears without an answer: refused before the RPC layer,
655
+ // undeliverable, answered to whichever connection claimed the name last, or a link that
656
+ // failed underneath.
657
+ transport.on(TransportEvent.rejected, (report) => service.noteProblem({ at: Date.now(), kind: 'rejected', link, ...(report?.source ? { peer: report.source } : {}), ...(report?.reason ? { reason: report.reason } : {}) }));
658
+ transport.on(TransportEvent.unroutable, (report) => service.noteProblem({
659
+ at: Date.now(),
660
+ kind: 'unroutable',
661
+ link,
662
+ ...(report?.source ? { peer: report.source } : {}),
663
+ ...(report?.target ? { target: report.target } : {}),
664
+ reason: report?.reason ?? 'no route to the target'
665
+ }));
666
+ // A bare name rather than a report: two peers are answering to it, and calls to either
667
+ // reach whichever connection arrived last.
668
+ transport.on(TransportEvent.peerDisplaced, (peer) => service.noteProblem({ at: Date.now(), kind: 'peerDisplaced', link, peer: String(peer), reason: 'another connection claimed this name' }));
669
+ transport.on(TransportEvent.transportError, (e) => service.noteProblem({ at: Date.now(), kind: 'transportError', link, reason: e instanceof Error ? e.message : String(e) }));
670
+ });
671
+ await new Promise((resolve) => http.listen(options.port, options.host, resolve));
672
+ return {
673
+ url: `${options.tls ? 'https' : 'http'}://${options.host}:${options.port}${base === '/' ? '' : base}`,
674
+ service,
675
+ close: async () => {
676
+ await service.releaseAll();
677
+ // After releaseAll, which is what drops the last tap and closes this with it. Closed
678
+ // again here in case a tap expired mid-flight and left the link behind.
679
+ await localBus?.releaseAll();
680
+ await tapLink?.close().catch(() => undefined);
681
+ await network.close();
682
+ await new Promise((resolve) => http.close(() => resolve()));
683
+ }
684
+ };
685
+ };
686
+ //# sourceMappingURL=console.js.map