@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
package/dist/index.js ADDED
@@ -0,0 +1,905 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { createHmacSigner, createHmacVerifier, createTokenAuthenticator, defaultSecureWebPort, defaultSecureWebSocketPort, defaultWebPort, defaultWebSocketPort, namespaceProblems, readableNameFor } from '@source-repo/rpc';
5
+ import { extractSchema } from './extract.js';
6
+ import { startConsole } from './console.js';
7
+ import { startBroker } from './broker.js';
8
+ import { startMcp } from './mcp.js';
9
+ import { processOutput, runCall, runDescribe, runPeers, runWatch } from './verbs.js';
10
+ import { startFake } from './fake.js';
11
+ import { replaySession, startRecording } from './record.js';
12
+ import { checkPeer, diffPeers } from './conform.js';
13
+ import { bench, benchArguments } from './bench.js';
14
+ /**
15
+ * msgrpc extract - read the contract out of TypeScript source and write it to a file
16
+ * msgrpc check - compare the source against a written contract and report breaking changes
17
+ *
18
+ * check is the one worth wiring into CI. It uses the same comparison the server uses at runtime,
19
+ * so a change that would refuse an older peer is caught before it ships rather than when that peer
20
+ * next calls.
21
+ */
22
+ const usage = `source-rpc <command> [options]
23
+
24
+ extract write the contract described by the source to a file
25
+ check compare the source against a written contract and fail on a breaking change
26
+ console browse a live network in a browser: peers, what they expose, calls and events
27
+ broker run a WebSocket bus: relays between the peers that connect to it, until Ctrl-C
28
+ mcp serve the network to an MCP client over stdio: list peers, describe them, call them
29
+
30
+ bench call one method over and over and report what it cost
31
+ diff compare what two live peers expose, when one of them behaves differently
32
+ serve stand a peer up from a contract: answers every method, refuses what it would refuse
33
+ record write what the network is carrying to a file, until Ctrl-C
34
+ replay send a recording's calls at a peer and compare the answers
35
+
36
+ peers who is on the network right now
37
+ describe <peer> what one peer exposes
38
+ call <peer> <ns.method> [a…] call it, and exit 1 if it refuses
39
+ watch <peer> <ns.event> stream its events as jsonl until Ctrl-C
40
+
41
+ extract / check
42
+ --project <tsconfig.json> default ./tsconfig.json
43
+ --out <file> default ./msgrpc.types.json (extract)
44
+ --against <file> default ./msgrpc.types.json (check)
45
+ --keep-history move the previous contract into history before writing
46
+ --peer <name> (check) ask a live peer what it serves instead of reading source
47
+ needs --broker or --hub
48
+
49
+ bench <peer> <ns.method> [args…]
50
+ --rate <n> calls per second to aim for, default 10
51
+ --for <ms> how long to keep going, default 10000
52
+ --concurrency <n> calls outstanding at once before the rest count as fallen behind
53
+ default 50
54
+ --json machine-readable report
55
+ exits 1 if any call failed
56
+
57
+ diff <peerA> <peerB>
58
+ --broker / --hub / --prefix / --timeout / --name / --sign as above
59
+ --json machine-readable output
60
+
61
+ peers / describe / call / watch
62
+ --broker <url> an MQTT network, e.g. mqtt://localhost:1883
63
+ --hub <url> a socket.io network, e.g. http://hub:7843
64
+ one of --broker and --hub is required; both watches both
65
+ --prefix <topic> topic namespace, default the transport's own
66
+ --timeout <ms> call timeout, default 10000
67
+ --wait <ms> how long to wait for the peer to appear, default 5000
68
+ --name <peer> how it identifies itself, default cli-<three words>
69
+ --sign <keyfile> HMAC keys, for a signed network
70
+ --insecure-tls accept any certificate on an https/wss/mqtts link
71
+ unsafe by design: for a development bus, never a plant
72
+ --json machine-readable output
73
+ --args <json> (call) the whole argument list as a JSON array, instead of words
74
+ --idempotency-key <key> (call) names the command, so calling twice with one key is two
75
+ attempts at one command rather than two commands
76
+
77
+ console
78
+ --broker <url> an MQTT network, e.g. mqtt://localhost:1883
79
+ --hub <url> a socket.io network, e.g. http://hub:7843
80
+ one of --broker and --hub is required; both watches both
81
+ --prefix <topic> topic namespace, default the transport's own
82
+ --port <n> default 7844, or 8844 with --cert
83
+ --host <address> default 127.0.0.1 - see the warning it prints before widening this
84
+ --cert <file> --key <file> serve HTTPS, and WSS with it; moves the default port to 8844
85
+ --base-path <path> publish under a path, for a reverse proxy that forwards the prefix
86
+ instead of stripping it; not needed for the ordinary rule
87
+ --timeout <ms> call timeout, default 10000
88
+ --name <peer> how the console identifies itself, default console-<three words>
89
+ --sign <keyfile> HMAC keys, so the console can talk to a signed network
90
+ --insecure-tls accept any certificate on an https/wss/mqtts link
91
+ unsafe by design: for a development bus, never a plant
92
+
93
+ mcp
94
+ --broker <url> an MQTT network
95
+ --hub <url> a socket.io network
96
+ one of --broker and --hub is required; both watches both
97
+ --prefix <topic> topic namespace, default the transport's own
98
+ --timeout <ms> call timeout, default 10000
99
+ --name <peer> how it identifies itself, default mcp-<three words>
100
+ --sign <keyfile> HMAC keys, for a signed network
101
+ --insecure-tls accept any certificate on an https/wss/mqtts link
102
+ unsafe by design: for a development bus, never a plant
103
+ --contracts <dir> let it save and load contracts here; without it those tools
104
+ are not offered at all
105
+ stdio carries the protocol, so it is not for interactive use
106
+
107
+ serve
108
+ --contract <file> the contract to serve; every namespace in it is exposed
109
+ --script <file> canned returns, deliberate failures and events on a timer
110
+ --fail <ns.method=Code> answer with that RPC error code, repeatable
111
+ Timeout is the special one: the call is never answered at all
112
+ --broker / --hub / --prefix / --timeout / --name / --sign as above
113
+
114
+ record
115
+ --out <file> where to write the recording, as jsonl
116
+ --peer <name> only frames this peer sent or received
117
+ --namespace <name> only this namespace
118
+ --no-payloads leave arguments and results out
119
+ --for <ms> stop after this long, instead of waiting for Ctrl-C
120
+
121
+ replay <file>
122
+ --against <peer> send every call here, instead of to its original addressee
123
+ --speed <n> higher is faster, default 1; 0 sends with no waiting
124
+ --json machine-readable summary
125
+ exits 1 if any answer differed or any call failed
126
+
127
+ broker
128
+ --port <n> default 7843, or 8843 with --cert; on every interface
129
+ --cert <file> --key <file> serve WSS rather than WS; moves the default port to 8843
130
+ --name <peer> how the broker identifies itself, default broker-<three words>
131
+ --upstream <url> join another broker, repeatable; the two become one network
132
+ --auth <file> tokens to accept, and the one to present upstream; without it the
133
+ bus relays for anyone that can reach the port
134
+ --quiet do not log peers arriving and leaving
135
+
136
+ ports
137
+ 7843 rpc 7844 console the plaintext pair
138
+ 8843 rpc-tls 8844 console-tls the same two with a certificate
139
+ --cert and --key move a server to its encrypted port on their own,
140
+ so the convention holds without anyone remembering the number
141
+
142
+ --auth <file> bearer tokens, for every command above
143
+ { "token": "…", presented when this command dials a hub that authenticates
144
+ "tokens": { accepted when this command is the bus: token -> the peer it admits
145
+ "…": "plantServer",
146
+ "…": { "name": "hmi", "roles": ["operator"] } } }
147
+ SOURCE_RPC_TOKEN the same "token", for a container
148
+ SOURCE_RPC_TOKENS the same "tokens" as JSON, for a container
149
+ never a flag: ps is readable by everyone on the box
150
+ `;
151
+ const argument = (argv, flag, fallback) => {
152
+ const index = argv.indexOf(flag);
153
+ return index === -1 ? fallback : (argv[index + 1] ?? fallback);
154
+ };
155
+ /** Every occurrence of a repeatable flag, so --upstream can be given more than once. */
156
+ const argumentList = (argv, flag) => argv.map((value, index) => (value === flag ? argv[index + 1] : undefined)).filter((value) => !!value);
157
+ const DIAGNOSTIC_LIMIT = 25;
158
+ const reportDiagnostics = (diagnostics) => {
159
+ for (const diagnostic of diagnostics.slice(0, DIAGNOSTIC_LIMIT)) {
160
+ const at = diagnostic.file ? ` (${diagnostic.file}:${diagnostic.line})` : '';
161
+ process.stderr.write(` ${diagnostic.where} ${diagnostic.reason}${at}\n`);
162
+ }
163
+ // Named rather than silently dropped, so nobody reads a truncated list as the whole story.
164
+ if (diagnostics.length > DIAGNOSTIC_LIMIT)
165
+ process.stderr.write(` … and ${diagnostics.length - DIAGNOSTIC_LIMIT} more\n`);
166
+ };
167
+ const readSchema = (path) => JSON.parse(readFileSync(path, 'utf8'));
168
+ /** Rolls the stored contract into history, so a later run can tell what changed since. */
169
+ const withHistory = (next, previous) => {
170
+ if (!previous)
171
+ return next;
172
+ for (const [name, namespace] of Object.entries(next.namespaces)) {
173
+ const before = previous.namespaces[name];
174
+ if (!before?.version || before.version === namespace.version)
175
+ continue;
176
+ const { history: _dropped, ...snapshot } = before;
177
+ namespace.history = { ...(before.history ?? {}), ...namespace.history, [before.version]: snapshot };
178
+ }
179
+ return next;
180
+ };
181
+ const readSigningKeys = (path, command) => {
182
+ let keys;
183
+ try {
184
+ keys = JSON.parse(readFileSync(path, 'utf8'));
185
+ }
186
+ catch (e) {
187
+ process.stderr.write(`source-rpc ${command}: cannot read keys from ${path}: ${e.message}\n`);
188
+ process.exit(1);
189
+ }
190
+ if (typeof keys.secret !== 'string' || !keys.secret) {
191
+ process.stderr.write(`source-rpc ${command}: ${path} has no "secret"\n`);
192
+ process.exit(1);
193
+ }
194
+ try {
195
+ // Worth saying out loud: this file is the console's identity on the network.
196
+ if (statSync(path).mode & 0o077)
197
+ process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
198
+ }
199
+ catch {
200
+ // Not worth failing over if the mode cannot be read.
201
+ }
202
+ const sign = createHmacSigner(keys.secret);
203
+ const verify = keys.peers ? createHmacVerifier((peer) => keys.peers?.[peer]) : undefined;
204
+ return { keys, sign, verify };
205
+ };
206
+ /**
207
+ * Certificate and key for a server this command opens, or undefined for plain HTTP.
208
+ *
209
+ * The material is what asks for TLS - there is no `--tls` switch, because a switch without a
210
+ * certificate opens a port that listens and then fails every handshake, which is the shape the
211
+ * library refused when `{ https: true }` was removed. Paths rather than contents: a PEM on the
212
+ * command line would be in `ps` and in the shell history, and both halves of a key pair belong in
213
+ * the same place as each other.
214
+ */
215
+ const readTls = (argv, command) => {
216
+ const cert = argument(argv, '--cert', '');
217
+ const key = argument(argv, '--key', '');
218
+ if (!cert && !key)
219
+ return undefined;
220
+ if (!cert || !key) {
221
+ process.stderr.write(`source-rpc ${command}: --cert and --key go together; got only ${cert ? '--cert' : '--key'}\n`);
222
+ process.exit(1);
223
+ }
224
+ try {
225
+ return { cert: readFileSync(cert), key: readFileSync(key) };
226
+ }
227
+ catch (e) {
228
+ process.stderr.write(`source-rpc ${command}: cannot read the certificate or key: ${e.message}\n`);
229
+ process.exit(1);
230
+ }
231
+ };
232
+ /**
233
+ * The port to listen on: what was asked for, or the default for what is being served.
234
+ *
235
+ * A certificate moves the default from 7843/7844 to 8843/8844, so the convention holds without
236
+ * anyone having to remember it - `--cert`/`--key` is enough to be found where a TLS peer would look.
237
+ * An explicit `--port` always wins, since a plant with its own numbering has the last word.
238
+ */
239
+ const listeningPort = (argv, secure, plain, encrypted) => Number(argument(argv, '--port', String(secure ? encrypted : plain)));
240
+ const readAuth = (argv, command) => {
241
+ const path = argument(argv, '--auth', '');
242
+ if (!path) {
243
+ const environmentTokens = process.env.SOURCE_RPC_TOKENS;
244
+ let tokens;
245
+ if (environmentTokens) {
246
+ try {
247
+ tokens = JSON.parse(environmentTokens);
248
+ }
249
+ catch (e) {
250
+ process.stderr.write(`source-rpc ${command}: SOURCE_RPC_TOKENS is not JSON: ${e.message}\n`);
251
+ process.exit(1);
252
+ }
253
+ }
254
+ return {
255
+ ...(process.env.SOURCE_RPC_TOKEN ? { token: process.env.SOURCE_RPC_TOKEN } : {}),
256
+ ...(tokens ? { tokens } : {})
257
+ };
258
+ }
259
+ let auth;
260
+ try {
261
+ auth = JSON.parse(readFileSync(path, 'utf8'));
262
+ }
263
+ catch (e) {
264
+ process.stderr.write(`source-rpc ${command}: cannot read tokens from ${path}: ${e.message}\n`);
265
+ process.exit(1);
266
+ }
267
+ if (!auth.token && !auth.tokens) {
268
+ // An empty file is the failure that looks like success: the command starts, and the bus it
269
+ // meant to gate is open. Better to refuse than to run unauthenticated on request.
270
+ process.stderr.write(`source-rpc ${command}: ${path} has neither "token" nor "tokens"\n`);
271
+ process.exit(1);
272
+ }
273
+ try {
274
+ // Worth saying out loud: whoever can read this file can be these peers.
275
+ if (statSync(path).mode & 0o077)
276
+ process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
277
+ }
278
+ catch {
279
+ // Not worth failing over if the mode cannot be read.
280
+ }
281
+ return auth;
282
+ };
283
+ /**
284
+ * The flags every command that joins a network takes, read once.
285
+ *
286
+ * console, mcp and the one-shot verbs all need the same six, and the two checks that go with them:
287
+ * that there is something to join at all, and that a --name does not contradict the name the key
288
+ * file belongs to. A signed frame is checked against the key held for the name it claims, so a
289
+ * process signing with one peer's key while calling itself another is refused - and refused as a
290
+ * timeout, with nothing to say why. Better to stop here than to let that happen on a plant network.
291
+ */
292
+ const resolveNetworkFlags = (argv, command, defaultNamePrefix) => {
293
+ const broker = argument(argv, '--broker', '');
294
+ const hub = argument(argv, '--hub', '');
295
+ if (!broker && !hub) {
296
+ process.stderr.write(`source-rpc ${command}: give it --broker, --hub, or both\n`);
297
+ process.exit(1);
298
+ }
299
+ const prefix = argument(argv, '--prefix', '');
300
+ const keyFile = argument(argv, '--sign', '');
301
+ const signing = keyFile ? readSigningKeys(keyFile, command) : undefined;
302
+ const requestedName = argument(argv, '--name', '');
303
+ if (signing?.keys.name && requestedName && signing.keys.name !== requestedName) {
304
+ process.stderr.write(`source-rpc ${command}: --name ${requestedName} does not match "${signing.keys.name}" in ${keyFile}\n`);
305
+ process.exit(1);
306
+ }
307
+ // A token is presented to a hub, never to a broker: MQTT authenticates at the broker, with
308
+ // credentials the broker was configured with, and this has no say in it.
309
+ const { token } = readAuth(argv, command);
310
+ return {
311
+ ...(broker ? { broker } : {}),
312
+ ...(hub ? { hub } : {}),
313
+ ...(prefix ? { prefix } : {}),
314
+ name: requestedName || signing?.keys.name || readableNameFor(defaultNamePrefix),
315
+ callTimeout: Number(argument(argv, '--timeout', '10000')),
316
+ ...(argv.includes('--insecure-tls') ? { insecureTls: true } : {}),
317
+ ...(signing ? { sign: signing.sign, ...(signing.verify ? { verify: signing.verify } : {}) } : {}),
318
+ ...(token ? { hubCredentials: { token } } : {}),
319
+ signing
320
+ };
321
+ };
322
+ /**
323
+ * The words a command was given, with the flags and their values taken out.
324
+ *
325
+ * `source-rpc call plant plant.setpoint 1200 --hub http://bus --json` has to yield exactly
326
+ * ['plant', 'plant.setpoint', '1200'], which means knowing which flags consume the word after them.
327
+ */
328
+ const VALUE_FLAGS = new Set([
329
+ '--broker',
330
+ '--hub',
331
+ '--prefix',
332
+ '--timeout',
333
+ '--wait',
334
+ '--name',
335
+ '--sign',
336
+ '--auth',
337
+ '--base-path',
338
+ '--args',
339
+ '--project',
340
+ '--out',
341
+ '--against',
342
+ '--port',
343
+ '--host',
344
+ '--upstream',
345
+ '--contract',
346
+ '--script',
347
+ '--fail',
348
+ '--out',
349
+ '--peer',
350
+ '--namespace',
351
+ '--for',
352
+ '--against',
353
+ '--speed',
354
+ '--contracts',
355
+ '--rate',
356
+ '--concurrency',
357
+ '--idempotency-key',
358
+ '--cert',
359
+ '--key'
360
+ ]);
361
+ const positionals = (argv) => {
362
+ const words = [];
363
+ for (let index = 0; index < argv.length; index++) {
364
+ const word = argv[index];
365
+ if (word.startsWith('--')) {
366
+ if (VALUE_FLAGS.has(word))
367
+ index++;
368
+ continue;
369
+ }
370
+ words.push(word);
371
+ }
372
+ return words;
373
+ };
374
+ /**
375
+ * peers, describe, call and watch: the console's verbs for a shell rather than a browser.
376
+ *
377
+ * The exit code is the product. `source-rpc call` returning 1 when a device refuses is what lets a
378
+ * smoke test be a line in a CI file rather than a program that parses output.
379
+ */
380
+ const runVerb = async (command, argv) => {
381
+ const flags = resolveNetworkFlags(argv, command, 'cli');
382
+ const options = {
383
+ ...flags,
384
+ json: argv.includes('--json'),
385
+ wait: Number(argument(argv, '--wait', '5000')),
386
+ ...(argument(argv, '--idempotency-key', '') ? { idempotencyKey: argument(argv, '--idempotency-key', '') } : {})
387
+ };
388
+ // The command itself is the first word, and every verb takes at least a peer after it.
389
+ const [, peer, target] = positionals(argv);
390
+ if (command === 'peers')
391
+ return await runPeers(options);
392
+ if (!peer) {
393
+ process.stderr.write(`source-rpc ${command}: which peer? Run 'source-rpc peers' to see who is there.\n`);
394
+ return 1;
395
+ }
396
+ if (command === 'describe')
397
+ return await runDescribe(peer, options);
398
+ if (!target) {
399
+ process.stderr.write(`source-rpc ${command}: give it <namespace>.<${command === 'watch' ? 'event' : 'method'}>, e.g. plant.${command === 'watch' ? 'alarm' : 'writeSetpoint'}\n`);
400
+ return 1;
401
+ }
402
+ if (command === 'watch') {
403
+ // Ctrl-C is how this one ends, and it has to end tidily: the subscription on the far side
404
+ // outlives this process otherwise.
405
+ const stopped = new Promise((resolve) => {
406
+ process.on('SIGINT', () => resolve());
407
+ process.on('SIGTERM', () => resolve());
408
+ });
409
+ return await runWatch(peer, target, options, processOutput, stopped);
410
+ }
411
+ const rawArgs = argv.includes('--args') ? argument(argv, '--args', '[]') : undefined;
412
+ return await runCall(peer, target, positionals(argv).slice(3), { ...options, ...(rawArgs !== undefined ? { rawArgs } : {}) });
413
+ };
414
+ /**
415
+ * A stand-in built from a contract, so an HMI has something to talk to and a test has a device
416
+ * willing to fail on request - which a real one is not.
417
+ */
418
+ const runFake = async (argv) => {
419
+ const contractPath = argument(argv, '--contract', '');
420
+ if (!contractPath) {
421
+ process.stderr.write('source-rpc serve: give it --contract <file>\n');
422
+ process.exit(1);
423
+ }
424
+ let schema;
425
+ try {
426
+ schema = readSchema(resolve(contractPath));
427
+ }
428
+ catch (e) {
429
+ process.stderr.write(`source-rpc serve: cannot read ${contractPath}: ${e.message}\n`);
430
+ process.exit(1);
431
+ }
432
+ const scriptPath = argument(argv, '--script', '');
433
+ let script = {};
434
+ if (scriptPath) {
435
+ try {
436
+ script = JSON.parse(readFileSync(resolve(scriptPath), 'utf8'));
437
+ }
438
+ catch (e) {
439
+ process.stderr.write(`source-rpc serve: cannot read ${scriptPath}: ${e.message}\n`);
440
+ process.exit(1);
441
+ }
442
+ }
443
+ // The shorthand for the same thing, since staging one failure is the common case and does not
444
+ // deserve a file.
445
+ for (const pair of argumentList(argv, '--fail')) {
446
+ const equals = pair.indexOf('=');
447
+ if (equals <= 0) {
448
+ process.stderr.write(`source-rpc serve: --fail wants <namespace>.<method>=<Code>, got '${pair}'\n`);
449
+ process.exit(1);
450
+ }
451
+ script = { ...script, fails: { ...script.fails, [pair.slice(0, equals)]: pair.slice(equals + 1) } };
452
+ }
453
+ const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'serve', 'fake');
454
+ const running = await startFake({ ...network, schema, ...(Object.keys(script).length ? { script } : {}) });
455
+ process.stdout.write(`source-rpc serve: ${network.name} answering ${running.namespaces.join(', ')} from ${contractPath}\n`);
456
+ // Anything calling this is talking to a stand-in. Worth one line, since a fake that is mistaken
457
+ // for the device is worse than no fake at all.
458
+ process.stderr.write('source-rpc serve: this is a fake. It answers from the contract, not from a device.\n');
459
+ const stop = () => void running
460
+ .close()
461
+ .then(() => process.exit(0))
462
+ .catch(() => process.exit(1));
463
+ process.on('SIGINT', stop);
464
+ process.on('SIGTERM', stop);
465
+ await new Promise(() => { });
466
+ };
467
+ /** Writes what the network is carrying to a file, so it can be replayed at something else later. */
468
+ const runRecord = async (argv) => {
469
+ const out = argument(argv, '--out', '');
470
+ if (!out) {
471
+ process.stderr.write('source-rpc record: give it --out <file>\n');
472
+ process.exit(1);
473
+ }
474
+ const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'record', 'recorder');
475
+ const peerFilter = argument(argv, '--peer', '');
476
+ const namespaceFilter = argument(argv, '--namespace', '');
477
+ // On by default here, where the tap has them off: a recording without arguments and results
478
+ // cannot be replayed, which is the only reason to make one.
479
+ const payloads = !argv.includes('--no-payloads');
480
+ const running = await startRecording({
481
+ ...network,
482
+ out: resolve(out),
483
+ filter: { payloads, ...(peerFilter ? { peer: peerFilter } : {}), ...(namespaceFilter ? { namespace: namespaceFilter } : {}), ttl: 3600 }
484
+ });
485
+ if (!running.sources.length) {
486
+ process.stderr.write('source-rpc record: nothing here can watch traffic - no broker exposing a bus, and no --broker link.\n');
487
+ await running.close();
488
+ process.exit(1);
489
+ }
490
+ process.stdout.write(`source-rpc record: writing ${out}, watching via ${running.sources.join(', ')}\n`);
491
+ if (payloads)
492
+ process.stderr.write('source-rpc record: arguments and results are being written to the file. Use --no-payloads to leave them out.\n');
493
+ const stop = () => void running
494
+ .close()
495
+ .then(() => {
496
+ process.stderr.write(`source-rpc record: ${running.frames()} frames\n`);
497
+ process.exit(0);
498
+ })
499
+ .catch(() => process.exit(1));
500
+ process.on('SIGINT', stop);
501
+ process.on('SIGTERM', stop);
502
+ const forMs = Number(argument(argv, '--for', '0'));
503
+ if (forMs > 0)
504
+ setTimeout(stop, forMs);
505
+ await new Promise(() => { });
506
+ };
507
+ /** Sends a recording's calls at a peer and compares the answers with the ones that were recorded. */
508
+ const runReplay = async (argv) => {
509
+ const file = positionals(argv)[1];
510
+ if (!file) {
511
+ process.stderr.write('source-rpc replay: which recording?\n');
512
+ return 1;
513
+ }
514
+ const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'replay', 'replayer');
515
+ const json = argv.includes('--json');
516
+ const against = argument(argv, '--against', '');
517
+ let summary;
518
+ try {
519
+ summary = await replaySession({ ...network, file: resolve(file), speed: Number(argument(argv, '--speed', '1')), ...(against ? { against } : {}) }, json
520
+ ? undefined
521
+ : (call) => {
522
+ if (call.outcome === 'matched')
523
+ return;
524
+ const where = `${call.target} ${call.namespace}.${call.method}`;
525
+ if (call.outcome === 'failed')
526
+ process.stdout.write(` ✗ ${where}: ${call.error}\n`);
527
+ else if (call.outcome === 'sent')
528
+ process.stdout.write(` · ${where}: sent, nothing recorded to compare\n`);
529
+ else
530
+ process.stdout.write(` ≠ ${where}: expected ${JSON.stringify(call.expected)}, got ${JSON.stringify(call.got)}\n`);
531
+ });
532
+ }
533
+ catch (e) {
534
+ process.stderr.write(`source-rpc replay: ${e instanceof Error ? e.message : String(e)}\n`);
535
+ return 1;
536
+ }
537
+ if (json)
538
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
539
+ else
540
+ process.stdout.write(`source-rpc replay: ${summary.calls.length} call${summary.calls.length === 1 ? '' : 's'}, ` +
541
+ `${summary.matched} matched, ${summary.differed} differed, ${summary.failed} failed, ${summary.sent} uncompared\n`);
542
+ // An answer that differed is the finding this exists to produce, so it fails the command.
543
+ return summary.differed || summary.failed ? 1 : 0;
544
+ };
545
+ /**
546
+ * The build-time check pointed at a device: is the box on the wall running the contract its callers
547
+ * were built against?
548
+ */
549
+ const runCheckPeer = async (argv, peer) => {
550
+ const against = resolve(argument(argv, '--against', 'msgrpc.types.json'));
551
+ let stored;
552
+ try {
553
+ stored = readSchema(against);
554
+ }
555
+ catch {
556
+ process.stderr.write(`source-rpc check: cannot read ${against}\n`);
557
+ return 1;
558
+ }
559
+ const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'check', 'cli');
560
+ let report;
561
+ try {
562
+ report = await checkPeer({ ...network, peer, stored, wait: Number(argument(argv, '--wait', '5000')) });
563
+ }
564
+ catch (e) {
565
+ process.stderr.write(`source-rpc check: ${e instanceof Error ? e.message : String(e)}\n`);
566
+ return 1;
567
+ }
568
+ if (argv.includes('--json')) {
569
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
570
+ return report.problems.length || report.missing.length ? 1 : 0;
571
+ }
572
+ for (const name of report.missing)
573
+ process.stderr.write(` ${name} is not served by ${peer}\n`);
574
+ for (const problem of report.problems)
575
+ process.stderr.write(` ${problem.namespace}.${problem.where} ${problem.reason}\n`);
576
+ // Said, and not counted as a pass: a peer running without a schema cannot be checked, and
577
+ // reporting "no breaking changes" about one would be a lie of the most useful-sounding kind.
578
+ for (const name of report.undescribed)
579
+ process.stderr.write(` ${name} is served without a contract, so nothing about it was checked\n`);
580
+ const count = report.problems.length + report.missing.length;
581
+ if (count) {
582
+ process.stderr.write(`source-rpc: ${count} breaking change${count === 1 ? '' : 's'} between ${against} and ${peer}\n`);
583
+ return 1;
584
+ }
585
+ process.stdout.write(`source-rpc: ${peer} serves ${report.checked.length ? report.checked.join(', ') : 'nothing'} compatibly with ${against}\n`);
586
+ return 0;
587
+ };
588
+ /** What two live peers offer differently, for when one cell behaves unlike the next. */
589
+ const runDiff = async (argv) => {
590
+ const [, left, right] = positionals(argv);
591
+ if (!left || !right) {
592
+ process.stderr.write('source-rpc diff: give it two peers\n');
593
+ return 1;
594
+ }
595
+ const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'diff', 'cli');
596
+ let report;
597
+ try {
598
+ report = await diffPeers({ ...network, left, right, wait: Number(argument(argv, '--wait', '5000')) });
599
+ }
600
+ catch (e) {
601
+ process.stderr.write(`source-rpc diff: ${e instanceof Error ? e.message : String(e)}\n`);
602
+ return 1;
603
+ }
604
+ if (argv.includes('--json')) {
605
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
606
+ return report.differences.length ? 1 : 0;
607
+ }
608
+ if (!report.differences.length) {
609
+ process.stdout.write(`source-rpc diff: ${left} and ${right} expose the same thing\n`);
610
+ return 0;
611
+ }
612
+ process.stdout.write(`${left} vs ${right}\n`);
613
+ for (const difference of report.differences) {
614
+ // Dotted for a method, spaced for the rest: `plant.read` is how you would say it, and
615
+ // `plant.contract version` is not.
616
+ const identifier = difference.member && /^[A-Za-z_$][\w$]*$/.test(difference.member);
617
+ const what = difference.member ? `${difference.namespace}${identifier ? '.' : ' '}${difference.member}` : difference.namespace;
618
+ process.stdout.write(`\n ${what}\n ${left}: ${difference.left ?? '—'}\n ${right}: ${difference.right ?? '—'}\n`);
619
+ }
620
+ // A difference is the finding, not a failure of the command - but an exit code lets a script
621
+ // assert that two cells match.
622
+ return 1;
623
+ };
624
+ /** One method, over and over, with percentiles - the script everybody writes, written once. */
625
+ const runBench = async (argv) => {
626
+ const words = positionals(argv);
627
+ const peer = words[1];
628
+ const target = words[2];
629
+ if (!peer || !target) {
630
+ process.stderr.write('source-rpc bench: give it a peer and <namespace>.<method>\n');
631
+ return 1;
632
+ }
633
+ const dot = target.lastIndexOf('.');
634
+ if (dot <= 0 || dot === target.length - 1) {
635
+ process.stderr.write(`source-rpc bench: '${target}' should be <namespace>.<method>\n`);
636
+ return 1;
637
+ }
638
+ const namespace = target.slice(0, dot);
639
+ const method = target.slice(dot + 1);
640
+ const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'bench', 'bench');
641
+ let args;
642
+ try {
643
+ args = await benchArguments({ ...network, peer, namespace, method, texts: words.slice(3) });
644
+ }
645
+ catch (e) {
646
+ process.stderr.write(`source-rpc bench: ${e instanceof Error ? e.message : String(e)}\n`);
647
+ return 1;
648
+ }
649
+ let report;
650
+ try {
651
+ report = await bench({
652
+ ...network,
653
+ peer,
654
+ namespace,
655
+ method,
656
+ args,
657
+ rate: Number(argument(argv, '--rate', '10')),
658
+ forMs: Number(argument(argv, '--for', '10000')),
659
+ concurrency: Number(argument(argv, '--concurrency', '50')),
660
+ wait: Number(argument(argv, '--wait', '5000'))
661
+ });
662
+ }
663
+ catch (e) {
664
+ process.stderr.write(`source-rpc bench: ${e instanceof Error ? e.message : String(e)}\n`);
665
+ return 1;
666
+ }
667
+ if (argv.includes('--json'))
668
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
669
+ else {
670
+ process.stdout.write(`${report.peer} ${report.method} ${report.calls} calls in ${(report.ranForMs / 1000).toFixed(1)}s at ${report.rate.achieved}/s\n`);
671
+ process.stdout.write(` ms min ${report.ms.min} p50 ${report.ms.p50} p90 ${report.ms.p90} p95 ${report.ms.p95} p99 ${report.ms.p99} max ${report.ms.max}\n`);
672
+ process.stdout.write(` ok ${report.ok} failed ${report.failed}${report.behind ? ` fell behind ${report.behind}` : ''}\n`);
673
+ for (const [code, count] of Object.entries(report.codes))
674
+ process.stdout.write(` ${code}: ${count}\n`);
675
+ }
676
+ // Errors under load are the finding, so they fail the command.
677
+ return report.failed ? 1 : 0;
678
+ };
679
+ const runBroker = async (argv) => {
680
+ const tls = readTls(argv, 'broker');
681
+ const port = listeningPort(argv, !!tls, defaultWebSocketPort, defaultSecureWebSocketPort);
682
+ const upstream = argumentList(argv, '--upstream');
683
+ const quiet = argv.includes('--quiet');
684
+ const name = argument(argv, '--name', readableNameFor('broker'));
685
+ const auth = readAuth(argv, 'broker');
686
+ let authenticate;
687
+ try {
688
+ authenticate = auth.tokens ? createTokenAuthenticator(auth.tokens) : undefined;
689
+ }
690
+ catch (e) {
691
+ // Every way of getting this wrong - a blank token, a grant with no name, an empty map -
692
+ // would otherwise start a bus that admits more than the operator meant it to.
693
+ process.stderr.write(`source-rpc broker: ${e.message}\n`);
694
+ process.exit(1);
695
+ }
696
+ const running = await startBroker({
697
+ port,
698
+ name,
699
+ ...(tls ? { tls } : {}),
700
+ ...(upstream.length ? { upstream } : {}),
701
+ ...(authenticate ? { authenticate } : {}),
702
+ ...(auth.token ? { upstreamCredentials: { token: auth.token } } : {}),
703
+ ...(quiet ? {} : { onPeer: (peer, state, where) => process.stdout.write(` ${state === 'online' ? '+' : '-'} ${peer} (${where})\n`) })
704
+ }).catch((e) => {
705
+ // A port already taken is the ordinary way this fails, and it deserves a sentence.
706
+ process.stderr.write(`source-rpc broker: cannot start on port ${port}: ${e.message}\n`);
707
+ process.exit(1);
708
+ });
709
+ process.stdout.write(`source-rpc broker ${name} on ${tls ? 'wss' : 'ws'} port ${port}${authenticate ? ', authenticating' : ''}${upstream.length ? `, joined to ${upstream.join(', ')}` : ''}\n`);
710
+ if (!authenticate) {
711
+ // It listens on every interface and forwards for whoever connects, without checking who they
712
+ // are. Worth saying plainly rather than leaving to be discovered.
713
+ process.stderr.write('source-rpc broker: relaying for any peer that connects, on every interface. Put it behind a network you trust, or give it --auth.\n');
714
+ // And now it will also show them everything it relays, if they ask. They could always have read
715
+ // it by impersonating a peer; this is merely one call. Said out loud for the same reason.
716
+ process.stderr.write('source-rpc broker: bus.tap() mirrors every frame crossing this broker to whoever calls it. --auth is what gates that.\n');
717
+ }
718
+ // Catching matters most here: a shutdown that fails would otherwise reject unhandled, and the
719
+ // process would die on that instead of exiting cleanly - and print nothing about why.
720
+ const stop = () => void running
721
+ .close()
722
+ .then(() => process.exit(0))
723
+ .catch((e) => {
724
+ process.stderr.write(`source-rpc: shutdown failed: ${e instanceof Error ? e.message : String(e)}\n`);
725
+ process.exit(1);
726
+ });
727
+ process.on('SIGINT', stop);
728
+ process.on('SIGTERM', stop);
729
+ // Nothing else keeps this process alive; the listener does.
730
+ await new Promise(() => { });
731
+ };
732
+ const runMcp = async (argv) => {
733
+ const { signing: _keys, ...network } = resolveNetworkFlags(argv, 'mcp', 'mcp');
734
+ const contracts = argument(argv, '--contracts', '');
735
+ const running = await startMcp({ ...network, ...(contracts ? { contracts: resolve(contracts) } : {}) });
736
+ // Nothing is written to stdout here: it carries the protocol. See mcp.ts.
737
+ const stop = () => void running
738
+ .close()
739
+ .then(() => process.exit(0))
740
+ .catch(() => process.exit(1));
741
+ process.on('SIGINT', stop);
742
+ process.on('SIGTERM', stop);
743
+ // The client closing the pipe is the ordinary way this ends.
744
+ process.stdin.on('end', stop);
745
+ };
746
+ const runConsole = async (argv) => {
747
+ const { signing, ...network } = resolveNetworkFlags(argv, 'console', 'console');
748
+ const host = argument(argv, '--host', '127.0.0.1');
749
+ const basePath = argument(argv, '--base-path', '');
750
+ const tls = readTls(argv, 'console');
751
+ const running = await startConsole({
752
+ ...network,
753
+ port: listeningPort(argv, !!tls, defaultWebPort, defaultSecureWebPort),
754
+ host,
755
+ ...(tls ? { tls } : {}),
756
+ ...(basePath ? { basePath } : {})
757
+ });
758
+ const watching = [network.broker, network.hub].filter(Boolean).join(' and ');
759
+ process.stdout.write(`source-rpc console on ${running.url}, watching ${watching} as ${network.name}${signing ? ', signing frames' : ''}\n`);
760
+ if (host !== '127.0.0.1' && host !== 'localhost')
761
+ // Anyone who can reach it can invoke anything the console's own credentials permit.
762
+ process.stderr.write(`source-rpc console: bound to ${host}, so it is reachable from the network. It can call any method it is allowed to.\n`);
763
+ // Catching matters most here: a shutdown that fails would otherwise reject unhandled, and the
764
+ // process would die on that instead of exiting cleanly - and print nothing about why.
765
+ const stop = () => void running
766
+ .close()
767
+ .then(() => process.exit(0))
768
+ .catch((e) => {
769
+ process.stderr.write(`source-rpc: shutdown failed: ${e instanceof Error ? e.message : String(e)}\n`);
770
+ process.exit(1);
771
+ });
772
+ process.on('SIGINT', stop);
773
+ process.on('SIGTERM', stop);
774
+ };
775
+ const main = () => {
776
+ // `source-rpc describe plantServer | head -4` closes stdout while there is still output to
777
+ // write, and Node turns that into an unhandled 'error' event: a stack trace where a command
778
+ // should simply stop. Every verb here writes to stdout, and half the documented examples are
779
+ // pipelines, so this belongs at the entry point rather than around each write.
780
+ for (const stream of [process.stdout, process.stderr])
781
+ stream.on('error', (e) => {
782
+ if (e.code === 'EPIPE')
783
+ process.exit(0);
784
+ throw e;
785
+ });
786
+ const argv = process.argv.slice(2);
787
+ const command = argv[0];
788
+ const project = resolve(argument(argv, '--project', 'tsconfig.json'));
789
+ // Both are long-running and async, so their rejections were unhandled: the process died on the
790
+ // rejection itself, with a stack trace where a sentence belonged.
791
+ const fail = (e) => {
792
+ process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
793
+ process.exit(1);
794
+ };
795
+ if (command === 'broker') {
796
+ void runBroker(argv).catch(fail);
797
+ return;
798
+ }
799
+ if (command === 'console') {
800
+ void runConsole(argv).catch(fail);
801
+ return;
802
+ }
803
+ if (command === 'mcp') {
804
+ void runMcp(argv).catch(fail);
805
+ return;
806
+ }
807
+ if (command === 'serve') {
808
+ void runFake(argv).catch(fail);
809
+ return;
810
+ }
811
+ if (command === 'bench') {
812
+ void runBench(argv)
813
+ .then((code) => process.exit(code))
814
+ .catch(fail);
815
+ return;
816
+ }
817
+ if (command === 'diff') {
818
+ void runDiff(argv)
819
+ .then((code) => process.exit(code))
820
+ .catch(fail);
821
+ return;
822
+ }
823
+ if (command === 'check' && argument(argv, '--peer', '')) {
824
+ void runCheckPeer(argv, argument(argv, '--peer', ''))
825
+ .then((code) => process.exit(code))
826
+ .catch(fail);
827
+ return;
828
+ }
829
+ if (command === 'record') {
830
+ void runRecord(argv).catch(fail);
831
+ return;
832
+ }
833
+ if (command === 'replay') {
834
+ void runReplay(argv)
835
+ .then((code) => process.exit(code))
836
+ .catch(fail);
837
+ return;
838
+ }
839
+ if (command === 'peers' || command === 'describe' || command === 'call' || command === 'watch') {
840
+ // These end, and their exit code is the answer, so the process waits for one rather than
841
+ // being kept alive by a listener the way console and broker are.
842
+ void runVerb(command, argv)
843
+ .then((code) => process.exit(code))
844
+ .catch(fail);
845
+ return;
846
+ }
847
+ if (command !== 'extract' && command !== 'check') {
848
+ process.stderr.write(usage);
849
+ process.exit(command ? 1 : 0);
850
+ }
851
+ const { schema, diagnostics } = extractSchema(project);
852
+ if (diagnostics.length) {
853
+ // Refused rather than written with holes in it: a schema that degrades to `any` on the
854
+ // parts it could not read still looks like protection while checking nothing.
855
+ process.stderr.write(`source-rpc: ${diagnostics.length} type${diagnostics.length === 1 ? '' : 's'} could not be described\n`);
856
+ reportDiagnostics(diagnostics);
857
+ process.exit(1);
858
+ }
859
+ if (command === 'extract') {
860
+ const out = resolve(argument(argv, '--out', 'msgrpc.types.json'));
861
+ let previous;
862
+ try {
863
+ previous = readSchema(out);
864
+ }
865
+ catch {
866
+ previous = undefined;
867
+ }
868
+ const written = argv.includes('--keep-history') ? withHistory(schema, previous) : schema;
869
+ writeFileSync(out, JSON.stringify(written, null, 2) + '\n');
870
+ const count = Object.keys(schema.namespaces).length;
871
+ process.stdout.write(`source-rpc: wrote ${count} namespace${count === 1 ? '' : 's'} to ${out}\n`);
872
+ return;
873
+ }
874
+ const against = resolve(argument(argv, '--against', 'msgrpc.types.json'));
875
+ let stored;
876
+ try {
877
+ stored = readSchema(against);
878
+ }
879
+ catch {
880
+ process.stderr.write(`source-rpc: cannot read ${against}\n`);
881
+ process.exit(1);
882
+ return;
883
+ }
884
+ let breaking = 0;
885
+ for (const [name, before] of Object.entries(stored.namespaces)) {
886
+ const now = schema.namespaces[name];
887
+ if (!now) {
888
+ process.stderr.write(` ${name} is no longer served\n`);
889
+ breaking++;
890
+ continue;
891
+ }
892
+ // The same comparison the server applies to a caller declaring an older version.
893
+ const problems = namespaceProblems(before, now, { ...stored.types, ...schema.types });
894
+ for (const problem of problems)
895
+ process.stderr.write(` ${name}.${problem.where} ${problem.reason}\n`);
896
+ breaking += problems.length;
897
+ }
898
+ if (breaking) {
899
+ process.stderr.write(`source-rpc: ${breaking} breaking change${breaking === 1 ? '' : 's'} against ${against}\n`);
900
+ process.exit(1);
901
+ }
902
+ process.stdout.write(`source-rpc: no breaking changes against ${against}\n`);
903
+ };
904
+ main();
905
+ //# sourceMappingURL=index.js.map