@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/README.md ADDED
@@ -0,0 +1,1131 @@
1
+ ```
2
+ ███████╗ ██████╗ ██╗ ██╗██████╗ ██████╗███████╗
3
+ ██╔════╝██╔═══██╗██║ ██║██╔══██╗██╔════╝██╔════╝
4
+ ███████╗██║ ██║██║ ██║██████╔╝██║ █████╗
5
+ ╚════██║██║ ██║██║ ██║██╔══██╗██║ ██╔══╝
6
+ ███████║╚██████╔╝╚██████╔╝██║ ██║╚██████╗███████╗
7
+ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝
8
+ ██████╗ ██████╗ ██████╗ ██████╗██╗ ██╗
9
+ ██╔══██╗██╔══██╗██╔════╝ ██╔════╝██║ ██║
10
+ ██████╔╝██████╔╝██║ ████╗██║ ██║ ██║
11
+ ██╔══██╗██╔═══╝ ██║ ╚═══╝██║ ██║ ██║
12
+ ██║ ██║██║ ╚██████╗ ╚██████╗███████╗██║
13
+ ╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝
14
+ ```
15
+
16
+ # @source-repo/rpc-cli
17
+
18
+ Tooling for [Source RPC](https://www.npmjs.com/package/@source-repo/rpc): read a contract out of TypeScript source, fail a build when it
19
+ changes in a way that would break a deployed peer, browse a live network in a browser, and hand that
20
+ same network to an AI assistant over [MCP](https://modelcontextprotocol.io) — list the peers,
21
+ describe them, call them, stand a fake one up, watch what they say to each other.
22
+
23
+ ```
24
+ npm install --save-dev @source-repo/rpc-cli
25
+ ```
26
+
27
+ The command is `source-rpc`. It was `msgrpc` before 3.0.
28
+
29
+ ESM only, Node 18.17 or later.
30
+
31
+ ## Commands
32
+
33
+ ```
34
+ source-rpc extract write the contract described by the source to a file
35
+ source-rpc check compare the source against a written contract, exit 1 on a breaking change
36
+ source-rpc console browse a live network: peers, what they expose, calls and events
37
+ source-rpc broker run a WebSocket bus for peers with no MQTT broker to share, with a traffic tap
38
+ source-rpc mcp serve the network to an MCP client over stdio
39
+ source-rpc serve stand a peer up from a contract, for an HMI with no plant to talk to
40
+ source-rpc bench call one method over and over and report what it cost
41
+ source-rpc diff compare what two live peers expose
42
+ source-rpc record write what the network is carrying to a file
43
+ source-rpc replay send a recording's calls at a peer and compare the answers
44
+
45
+ source-rpc peers who is on the network right now
46
+ source-rpc describe what one peer exposes
47
+ source-rpc call call a method, and exit 1 if the peer refuses
48
+ source-rpc watch stream a peer's events as jsonl until Ctrl-C
49
+ ```
50
+
51
+ ### The bus in a container
52
+
53
+ The same image is every command above, because the entrypoint is the CLI. `broker` is the default,
54
+ since a bus is infrastructure rather than something somebody is holding:
55
+
56
+ ```
57
+ docker run -d -p 7843:7843 ghcr.io/source-repo/rpc-cli:3 broker --name bus
58
+ docker run --rm -i ghcr.io/source-repo/rpc-cli:3 mcp --hub http://bus:7843 # stdio, for an MCP client
59
+ ```
60
+
61
+ [`docker-compose/network.yml`](https://github.com/source-repo/rpc/blob/main/docker-compose/network.yml)
62
+ runs a whole network — an MQTT broker, the bus, and the console watching both — and
63
+ [In a container](#in-a-container) below covers tokens, ports and what to be careful about when
64
+ publishing the console. Ports are `7843` for the bus and `7844` for the console, or `8843`/`8844`
65
+ when they are given a certificate.
66
+
67
+ | flag | commands | default | meaning |
68
+ | --- | --- | --- | --- |
69
+ | `--project <tsconfig.json>` | extract, check | `tsconfig.json` | the project to read |
70
+ | `--out <file>` | extract | `msgrpc.types.json` | where to write the contract |
71
+ | `--against <file>` | check | `msgrpc.types.json` | the contract to compare against |
72
+ | `--peer <name>` | check | — | ask a live peer what it serves instead of reading source; needs `--broker`/`--hub` |
73
+ | `--keep-history` | extract | off | move the previous contract into `history` when the version changed |
74
+ | `--broker <url>` | console, mcp, verbs | — | an MQTT network, e.g. `mqtt://localhost:1883` |
75
+ | `--hub <url>` | console, mcp, verbs | — | a socket.io network, e.g. `http://hub:7843`. One of `--broker`/`--hub` is required; both watches both |
76
+ | `--prefix <topic>` | console, mcp, verbs | the transport's own | must match the network you are watching |
77
+ | `--port <n>` | console | `7844`, or `8844` with `--cert` | |
78
+ | `--host <address>` | console | `127.0.0.1` | see the warning it prints before widening this |
79
+ | `--base-path <path>` | console | `/` | publish under a path, for a reverse proxy that forwards the prefix instead of stripping it. See [Behind a reverse proxy](#behind-a-reverse-proxy) |
80
+ | `--timeout <ms>` | console, mcp, verbs | `10000` | call timeout |
81
+ | `--name <peer>` | console | `console-<three words>` | how the console identifies itself to the network |
82
+ | `--sign <keyfile>` | console, mcp, verbs, serve | — | HMAC keys, so it can talk to a signed network |
83
+ | `--insecure-tls` | console, mcp, verbs, serve | off | accept any certificate on an `https`/`wss`/`mqtts` link. Unsafe by design: for a development bus with a self-signed certificate, never a plant |
84
+ | `--cert <file>` `--key <file>` | console, broker | — | serve TLS. Together they make the console HTTPS and the bus WSS, and move the default port to 8844 / 8843 |
85
+ | `--contracts <dir>` | mcp | — | let it save and load contracts here; without it those tools are not offered |
86
+ | `--contract <file>` | serve | — | the contract to serve; every namespace in it is exposed |
87
+ | `--script <file>` | serve | — | canned returns, deliberate failures and events on a timer |
88
+ | `--fail <ns.method=Code>` | serve | — | answer with that RPC error code; repeatable. `Timeout` never answers |
89
+ | `--out <file>` | record | — | where to write the recording, as jsonl |
90
+ | `--peer <name>` | record | — | only frames this peer sent or received |
91
+ | `--namespace <name>` | record | — | only this namespace |
92
+ | `--no-payloads` | record | off | leave arguments and results out |
93
+ | `--for <ms>` | record | — | stop after this long, instead of waiting for Ctrl-C |
94
+ | `--against <peer>` | replay | the original addressee | send every call here instead |
95
+ | `--speed <n>` | replay | `1` | higher is faster; `0` sends with no waiting |
96
+ | `--rate <n>` | bench | `10` | calls per second to aim for |
97
+ | `--for <ms>` | bench | `10000` | how long to keep going |
98
+ | `--concurrency <n>` | bench | `50` | calls outstanding before the rest count as fallen behind |
99
+ | `--name <peer>` | mcp | `mcp-<three words>` | how it identifies itself to the network |
100
+ | `--name <peer>` | verbs | `cli-<three words>` | how it identifies itself to the network |
101
+ | `--wait <ms>` | verbs | `5000` | how long to wait for the peer to appear before giving up |
102
+ | `--json` | verbs | off | machine-readable output |
103
+ | `--args <json>` | call | — | the whole argument list as a JSON array, instead of words |
104
+ | `--port <n>` | broker | `7843`, or `8843` with `--cert` | listens on every interface |
105
+ | `--name <peer>` | broker | `broker-<three words>` | how the broker identifies itself |
106
+ | `--upstream <url>` | broker | — | join another broker; repeatable |
107
+ | `--auth <file>` | broker, console, mcp, verbs, serve | — | bearer tokens: which to accept, and which to present. See [Authenticating the bus](#authenticating-the-bus) |
108
+ | `--quiet` | broker | off | stop logging peers arriving and leaving |
109
+
110
+ ## Ports
111
+
112
+ `7843` is the Source RPC port and `7844` is the web port. Both are defaults, so neither has to be
113
+ typed. They are deliberately not in the 80xx range, which is where everything else on a developer's
114
+ machine already is — a default that collides with whatever is on 8080 today is a default nobody
115
+ keeps.
116
+
117
+ | | |
118
+ | --- | --- |
119
+ | `1883` | MQTT |
120
+ | `8083` | MQTT over WebSocket |
121
+ | `7843` | `source-rpc broker` |
122
+ | `7844` | `source-rpc console` |
123
+
124
+ **A process needs only one of them.** The console serves its page and its RPC on the same listener —
125
+ socket.io answers `/socket.io` and everything else is the static app — so `7844` is one port, not a
126
+ pair. The second number exists because a bus and a console usually run on the same host, not because
127
+ either needs two.
128
+
129
+ ## Declaring the contract
130
+
131
+ The namespace is declared in the source, because static analysis cannot see the name a class is
132
+ eventually exposed under at some `exposeClassInstance` call elsewhere. Methods opt in with `@rpc`,
133
+ so the contract is the allow-list rather than everything on the prototype chain.
134
+
135
+ ```typescript
136
+ import { rpc, rpcNamespace } from '@source-repo/rpc'
137
+
138
+ @rpcNamespace('plant', { version: '2' })
139
+ export class Plant {
140
+ declare rpcEvents: { alarm: [message: string, severity: number] }
141
+
142
+ @rpc async writeSetpoint(value: number, mode?: 'auto' | 'manual') { ... }
143
+ async internalOnly() { ... } // unmarked, so absent from the contract
144
+ }
145
+ ```
146
+
147
+ Events are declared as a property type rather than inferred from `emit()` calls, which cannot be
148
+ read statically with any confidence.
149
+
150
+ ## extract
151
+
152
+ ```
153
+ source-rpc extract --project tsconfig.json --out msgrpc.types.json
154
+ ```
155
+
156
+ It describes **the files your tsconfig includes**, not everything they import. A decorated class in
157
+ a dependency belongs to that dependency's contract, not yours.
158
+
159
+ Nothing is executed: the decorators are read from the syntax tree, so `extract` never runs your
160
+ code.
161
+
162
+ ### What it refuses to describe
163
+
164
+ Anything the type language cannot represent is **reported, never emitted as `any`**, and a run with
165
+ any diagnostic writes no file. A schema that quietly degrades on the parts it could not read still
166
+ looks like protection while checking nothing.
167
+
168
+ ```
169
+ msgrpc: 3 types could not be described
170
+ plant.fetch return is generic (T), which has no runtime type to check (src/plant.ts:6)
171
+ plant.subscribe argument 0 is a function, which cannot be checked on the wire (src/plant.ts:12)
172
+ plant.lookup return is a Map, which MsgPack does not carry; use an object or an array (src/plant.ts:18)
173
+ ```
174
+
175
+ So far: generics, function parameters, `Map` and `Set`, and a type that is part dictionary and part
176
+ declared shape — `{ name: string; [tag: string]: unknown }` — which would need describing both
177
+ halves at once. Dropping either one produces a contract that looks checked and is not.
178
+
179
+ At most 25 diagnostics are printed, followed by a count of the rest.
180
+
181
+ ### What it can and cannot see
182
+
183
+ `Date` and `Uint8Array` come through as values rather than encodings of them, because MsgPack
184
+ carries both. Recursive types become named references. `Promise<T>` is unwrapped.
185
+
186
+ An index signature becomes a `record`, so `{ [tag: string]: Reading }` is described by its value
187
+ type with the keys left open, and a wrong reading is still caught. `{ [id: number]: string }` gets a
188
+ key pattern instead of a numeric key type, because a JS object key is always a string on the wire.
189
+
190
+ A generic instantiation is inlined rather than named: `Record<string, number>` and
191
+ `Record<string, string>` share the symbol `Record`, so keying both under it would quietly make the
192
+ second a reference to the first's value type.
193
+
194
+ What it cannot see is anything the type system does not carry. `value: number` becomes
195
+ `{ kind: 'number' }` — a range like `0..2000` is a runtime invariant, invisible to TypeScript.
196
+ Extraction gives you shape checking: types, arity, whether an argument is required. Bounds have to
197
+ be added to the schema afterwards or expressed in the type.
198
+
199
+ ## check
200
+
201
+ ```
202
+ source-rpc check --project tsconfig.json --against msgrpc.types.json
203
+ ```
204
+
205
+ Compares the source against a stored contract using the **same comparison the server applies at
206
+ runtime** to a caller declaring an older version, so a change that would refuse a deployed peer
207
+ fails the build instead:
208
+
209
+ ```
210
+ $ source-rpc check
211
+ plant.writeSetpoint argument 0 narrowed, so a value the caller may send is no longer accepted
212
+ msgrpc: 1 breaking change against msgrpc.types.json
213
+ $ echo $?
214
+ 1
215
+ ```
216
+
217
+ Parameters may widen and returns may narrow; the reverse breaks callers. Adding an optional argument
218
+ or field is safe, adding a required one is not. Events run the other way, since the server emits and
219
+ the caller receives.
220
+
221
+ `extract --keep-history` moves the previous contract into `history` when the version changes, which
222
+ is what lets both this check and the server recognise an older caller.
223
+
224
+ ### Checking the device rather than the build
225
+
226
+ `check` against source catches a change before it ships. What it cannot answer is the question asked
227
+ on site: the contract says this device offers `writeSetpoint(value, mode?)` — is that what the box on
228
+ the wall is actually running?
229
+
230
+ ```
231
+ $ source-rpc check --peer plantServer --against plant.types.json --hub http://bus:7843
232
+ plant.writeSetpoint argument 0 narrowed, so a value the caller may send is no longer accepted
233
+ plant.read no longer exists
234
+ plant.event alarm is no longer emitted, so a subscription to it would never fire
235
+ msgrpc: 3 breaking changes between plant.types.json and plantServer
236
+ $ echo $?
237
+ 1
238
+ ```
239
+
240
+ The peer describes itself and the answer runs through **the same comparison** the server applies to
241
+ a caller declaring an older version — so a device behind its own contract is reported in exactly the
242
+ words a stale caller would have got, and `check` in CI and `check --peer` on site agree about what
243
+ "breaking" means.
244
+
245
+ A namespace the peer does not serve at all is reported apart from one that changed. **A peer running
246
+ without a schema is reported as unchecked, not as passing**: it describes its method names and
247
+ nothing else, and calling that "no breaking changes" would be the most useful-sounding lie available.
248
+
249
+ ## diff
250
+
251
+ Why does cell 3 behave differently from cell 2? Usually because one of them is running last season's
252
+ firmware.
253
+
254
+ ```
255
+ $ source-rpc diff cell2 plantServer --hub http://bus:7843
256
+ cell2 vs plantServer
257
+
258
+ plant contract version
259
+ cell2: 3
260
+ plantServer: 4
261
+
262
+ plant.read
263
+ cell2: read(): { celsius: number(0..100), bar: number(0..10) }
264
+ plantServer: —
265
+
266
+ plant.writeSetpoint
267
+ cell2: writeSetpoint(value: number(0..2000), mode?: "auto" | "manual"): boolean
268
+ plantServer: writeSetpoint(value: number(0..500), mode?: "auto" | "manual"): boolean
269
+
270
+ plant event alarm
271
+ cell2: emitted
272
+ plantServer: —
273
+ ```
274
+
275
+ Signatures are compared as they read rather than structurally, because the answer is going to be
276
+ read by a person standing in front of two cabinets. It exits 1 when anything differs, so a script
277
+ can assert that two cells match; `--json` gives the same as data.
278
+
279
+ ## broker
280
+
281
+ ```
282
+ source-rpc broker --port 7843
283
+ ```
284
+
285
+ A bus for networks that have no MQTT broker to share. It runs until Ctrl-C, relaying between the
286
+ peers that connect to it and telling each of them who else is there — which is what MQTT gives you
287
+ through retained presence and per-peer topics, over one WebSocket port instead.
288
+
289
+ ```
290
+ source-rpc broker plantBus on port 7843
291
+ + cellBus (:7843)
292
+ + panel1 (:7843)
293
+ + hmi (:7843)
294
+ ```
295
+
296
+ Peers join it by dialling out, which is also the only thing a browser page can do:
297
+
298
+ ```typescript
299
+ const panel = new RpcServer({ name: 'panel1', transports: [{ connect: 'http://bus:7843' }] })
300
+ panel.exposeClassInstance(new Panel(), 'panel') // now callable by anything else on the bus
301
+ ```
302
+
303
+ There is no separate broker implementation and there should not be: this is an `RpcServer` that
304
+ exposes nothing. A peer addressing the broker by name gets `ClassNotFound`, which is the truth —
305
+ it is a switchboard, not a service.
306
+
307
+ ### Joining two brokers
308
+
309
+ `--upstream` dials another broker, and the two become one network. Each side's peers are advertised
310
+ to the other, and a call crosses without either end knowing there was a hop:
311
+
312
+ ```
313
+ source-rpc broker --port 7843 --name plantBus
314
+ source-rpc broker --port 8086 --name cellBus --upstream http://plant:7843
315
+ ```
316
+
317
+ A peer on `cellBus` is then callable from `plantBus` and the other way round. Repeat `--upstream` to
318
+ join more than one. Loops are handled — a peer is never advertised back along the link it came from,
319
+ and frames carry a hop count and are dropped after 8 relays — so brokers dialling each other in a
320
+ ring settle rather than storm.
321
+
322
+ ### Authenticating the bus
323
+
324
+ Without `--auth` the broker relays for anything that can reach the port, and every peer name on it
325
+ is an unchecked claim. It says so on startup. `--auth` is what changes that: a file of bearer
326
+ tokens, each naming the one peer it admits.
327
+
328
+ ```json
329
+ {
330
+ "token": "the-one-this-broker-presents-upstream",
331
+ "tokens": {
332
+ "3f9a…": "plantServer",
333
+ "c710…": { "name": "hmi", "roles": ["operator"] }
334
+ }
335
+ }
336
+ ```
337
+
338
+ ```
339
+ source-rpc broker --auth /run/secrets/bus.json
340
+ source-rpc broker plantBus on port 7843, authenticating
341
+ ```
342
+
343
+ `tokens` is what this broker accepts. `token` is what it presents when it dials an `--upstream`, so
344
+ a broker joining another needs both: it is a bus to one side and a peer to the other. Every other
345
+ command takes `--auth` too, and uses the `token` to join a hub that authenticates.
346
+
347
+ **One token per peer.** A token that maps to a name is evidence of who is calling; a single token
348
+ everyone shares proves only that the caller got inside the fence. The peer presents it as its
349
+ `credentials`, and its `--name` has to be the name the token was issued for — the bus drops frames
350
+ claiming any other source, so a mismatch reads as every call timing out rather than as a refusal.
351
+
352
+ The flag names a path, never a secret, because `ps` is readable by everyone on the box. For a
353
+ container there are `SOURCE_RPC_TOKEN` and `SOURCE_RPC_TOKENS`, which say the same two things.
354
+
355
+ Two consequences worth knowing. The tap is gated with everything else, so `bus.tap()` is reachable
356
+ only by a peer this broker admits. And an upstream broker is a peer of this one, not an operator of
357
+ it: frames relay across the join as before, but a call to this broker's own `bus` namespace from
358
+ across it is refused, because a connection this broker dialled is not one it authenticated.
359
+
360
+ ### In a container
361
+
362
+ The broker is the piece of Source RPC that is infrastructure rather than a tool someone is holding,
363
+ which is what makes it worth an image. [`Dockerfile`](Dockerfile) builds one whose entrypoint is the
364
+ whole CLI, so a single image is a bus, a console, an MCP server or a recorder depending on the
365
+ command:
366
+
367
+ ```
368
+ docker run -d -p 7843:7843 \
369
+ -e SOURCE_RPC_TOKENS='{"3f9a…":"plantServer"}' \
370
+ ghcr.io/source-repo/rpc-cli:3 # no command: the default is broker
371
+
372
+ docker run --rm -e SOURCE_RPC_TOKEN=3f9a… ghcr.io/source-repo/rpc-cli:3 \
373
+ peers --hub http://bus:7843 --name plantServer # any other command, same image
374
+ ```
375
+
376
+ [`docker-compose/network.yml`](../../docker-compose/network.yml) runs the whole thing — an MQTT
377
+ broker, the bus, and a console watching both — which is the shape a plant deploys:
378
+
379
+ ```
380
+ echo "CONSOLE_TOKEN=$(openssl rand -hex 32)" > docker-compose/.env
381
+ docker compose -f docker-compose/network.yml up -d
382
+ open http://localhost:7844
383
+ ```
384
+
385
+ Note what the compose file does with the console, because it is the part that is easy to get wrong.
386
+ `--host 0.0.0.0` is needed for the page to be reachable from outside the container at all, and it
387
+ means anything that can reach the published port can call whatever the console is allowed to call —
388
+ so the port is published to `127.0.0.1` rather than to every interface. The bus is not: peers have
389
+ to reach it, and `--auth` is what makes that safe rather than the firewall.
390
+
391
+ ### The traffic tap
392
+
393
+ A console sees its own calls and the events it subscribed to, which on a real network is a small
394
+ fraction of what is happening. The broker sees everything, because it is the thing forwarding it.
395
+ `bus` is the one namespace it exposes, and it is **turned on by a call rather than a flag** — a
396
+ plant bus that has to be restarted before it can be watched will not be watched, since the run worth
397
+ looking at is the one already going wrong.
398
+
399
+ ```
400
+ $ source-rpc call plantBus bus.tap '{"peer":"plantServer","payloads":true}' --hub http://bus:7843
401
+ { "token": "tap-1", "expires": 1785272777436, "filter": { … } }
402
+
403
+ $ source-rpc watch plantBus bus.frame --hub http://bus:7843
404
+ → hmi-3 -> plantServer plant.writeSetpoint[1200,"auto"]
405
+ ⇒ plantServer -> hmi-3 plant.alarm["setpoint moved",1]
406
+ ← plantServer -> hmi-3 plant.writeSetpoint 2ms
407
+ → hmi-3 -> plantServer plant.read[]
408
+ ← plantServer -> hmi-3 plant.read 1ms
409
+ → hmi-3 -> plantServer plant.fault[]
410
+ ← plantServer -> hmi-3 plant.fault 0ms Exception: valve jammed
411
+ ```
412
+
413
+ (The arrows are `jq` over the jsonl; `watch` writes one JSON object per line.)
414
+
415
+ | method | |
416
+ | --- | --- |
417
+ | `tap(filter?)` | start watching; returns a token |
418
+ | `untap(token)` | stop watching that one |
419
+ | `taps()` | who is watching what, and how much each has seen |
420
+
421
+ Frames arrive as the `frame` event, so anything that can subscribe to an msgrpc event can read
422
+ them — the console, `source-rpc watch`, or a program of your own.
423
+
424
+ **It knows what a frame is**, which is what a topic browser pointed at the same wire cannot do. A
425
+ call and its reply share a correlation id, so the reply is reported with the method it answers and
426
+ the time it took — neither of which is in the reply itself.
427
+
428
+ | filter | |
429
+ | --- | --- |
430
+ | `peer` | only frames this peer sent or received — "mirror that device" |
431
+ | `namespace` | only this namespace; applies to replies too, since a reply is paired with its call first |
432
+ | `kinds` | any of `POST`, `SUCCESS`, `ERROR`, `EVENT` |
433
+ | `payloads` | include arguments, results and event payloads. **Off by default** |
434
+ | `ttl` | seconds before the tap drops itself. Default 300, maximum 3600 |
435
+
436
+ Payloads are off by default because the metadata is what a debugging session usually needs, and a
437
+ plant bus carries values nobody meant to hand to whoever happened to be tapping. Several taps can
438
+ run at once with different filters; each frame names the taps it matched, and payloads are carried
439
+ only if one of them asked.
440
+
441
+ Taps expire on their own, because a console that closes without untapping would otherwise leave the
442
+ broker building and emitting frames for a subscriber that is not there.
443
+
444
+ Traffic addressed *to* the broker is not tapped — only what it relays — so turning the tap on and
445
+ reading it back does not feed itself.
446
+
447
+ ### On MQTT, the console does the watching
448
+
449
+ There is no broker of ours on an MQTT network to hook, so the observation happens at the
450
+ subscription instead: `<prefix>/rpc/+` under the 3.1.1 layout, each of `<prefix>/{req,rsp,evt}/+`
451
+ under MQTT 5. A console started with `--broker` exposes the same `bus` namespace and watches for
452
+ itself.
453
+
454
+ **The tap gets its own broker connection**, opened when the first tap starts and closed after the
455
+ last one ends. A peer subscribed to both its own topic and the wildcard covering it has overlapping
456
+ subscriptions, and a broker is permitted to deliver a matching message once per subscription — which
457
+ for a request means the method runs twice. A separate connection is a separate client id and a
458
+ separate session, so the two can never overlap. It also means an idle console costs a plant broker
459
+ nothing.
460
+
461
+ Frames are reported without checking signatures: a tap holds no key for a conversation it is not
462
+ part of, and what is on the wire is what it exists to show.
463
+
464
+ Either way the answer arrives the same: ask the console, and it turns on whatever it can reach.
465
+
466
+ ```
467
+ $ source-rpc call myConsole console.tap '{"peer":"plantServer"}' --broker mqtt://localhost:1883
468
+ { "token": "console-tap-1", "sources": ["this console"] }
469
+ $ source-rpc watch myConsole console.frame --broker mqtt://localhost:1883
470
+ ```
471
+
472
+ `sources` says who is doing the watching — a broker's `bus` on socket.io, `this console` on MQTT,
473
+ or both when it holds both links.
474
+
475
+ ### In the console
476
+
477
+ The side panel has a **Traffic** tab next to Events and Chat. It is off until you press **tap**, and
478
+ the setup above it decides what to ask for: arguments and results, only the selected peer, and which
479
+ kinds. Once running it shows the source it found, a filter box, **pause**, and one row per frame —
480
+ colour-coded by kind, with the reply carrying the method it answers and the time it took.
481
+
482
+ The tab stays tapping while you look at another tab; the count on the tab label is what arrived
483
+ while you were away.
484
+
485
+ ## Presence
486
+
487
+ A peer that flaps is one of the commonest faults on a plant and the hardest to catch in the act. The
488
+ console used to show it as a dot that changed colour and then forgot, so a device dropping every
489
+ thirty seconds looked exactly like one that was simply up.
490
+
491
+ ```
492
+ flakyCell has arrived 4 times
493
+
494
+ 3:36:43 AM − flakyCell http://localhost:8090
495
+ 3:36:41 AM + flakyCell http://localhost:8090
496
+ 3:36:38 AM + polish-2
497
+ 3:36:38 AM − flakyCell http://localhost:8090
498
+ ```
499
+
500
+ Kept by the console and handed over when a page connects, so **opening the console after the trouble
501
+ still shows it** — and anything that has arrived three times or more in the window is called out by
502
+ name, because that is the fault and the rest is a Tuesday.
503
+
504
+ Each peer in the list also says **what it is** — broker, console, page, device, or served without a
505
+ contract. That is learned from descriptions the console was already making when you select a peer or
506
+ when it goes looking for a bus to tap, so the labels fill in as the network is used and an idle
507
+ console costs exactly what it did before.
508
+
509
+ ## Problems
510
+
511
+ The **Problems** tab is where a call that never comes back says why. Four things the transports have
512
+ always reported and nothing used to listen to:
513
+
514
+ | kind | what it means |
515
+ | --- | --- |
516
+ | `rejected` | the frame was refused before it reached the RPC layer — a bad signature, an unsafe name, something undecodable |
517
+ | `unroutable` | there was nowhere to deliver it: no such peer, a relay refused, or too many hops |
518
+ | `peerDisplaced` | two peers are answering to one name, so replies reach whichever connected last |
519
+ | `transportError` | the link itself failed |
520
+
521
+ ```
522
+ 1:26:44 AM peerDisplaced on this console
523
+ twin-hmi
524
+ another connection claimed this name
525
+ 1:26:42 AM unroutable on this console
526
+ lost-caller → no-such-device
527
+ no route to the target
528
+ ```
529
+
530
+ There is nothing to switch on: these cost nothing when nothing is wrong, and the ones worth reading
531
+ are usually from before anyone thought to look. The console keeps a bounded history and hands it
532
+ over when a page connects, so **opening the console after the trouble still shows it** — which is
533
+ the usual way round.
534
+
535
+ `source-rpc watch <console> console.problem` streams the same thing to a shell, and
536
+ `source-rpc call <console> console.problems` fetches the history.
537
+
538
+ Each peer in the list also now carries **the link it was found on**, which on a plant with the
539
+ devices on a broker and the HMIs on a hub is the first thing worth knowing about one.
540
+
541
+ ### What it is not
542
+
543
+ **Not a store-and-forward broker.** Nothing is queued for a peer that is not connected: a frame is
544
+ handed to a peer that is there now, or reported as unroutable. If a peer needs to receive what was
545
+ sent while it was down, that is what MQTT and `persistentSession` are for.
546
+
547
+ **Not authenticated.** It listens on every interface and relays for whoever connects, without
548
+ checking who they are, and it says so on startup. Put it behind a network you trust, or build one
549
+ from the library with `authenticate` and a `relay` rule.
550
+
551
+ **The tap is only as gated as the broker is.** Anyone who can reach an unauthenticated broker can
552
+ call `bus.tap()` and mirror everything crossing it. They could always have read the same traffic by
553
+ impersonating a peer; this is merely one call. `authenticate` and `relay` are what restrict it, and
554
+ the broker says as much on startup.
555
+
556
+ ## peers, describe, call, watch
557
+
558
+ The console's verbs for a shell rather than a browser. Same network flags as `console`, one answer
559
+ each, and an exit code:
560
+
561
+ ```
562
+ source-rpc peers --hub http://bus:7843
563
+ source-rpc describe plantServer --hub http://bus:7843
564
+ source-rpc call plantServer plant.writeSetpoint 1200 auto --hub http://bus:7843
565
+ source-rpc watch plantServer plant.alarm --hub http://bus:7843
566
+ ```
567
+
568
+ ```
569
+ $ source-rpc describe plantServer --hub http://bus:7843
570
+ plantServer (contract 3) — arguments checked
571
+
572
+ plant@3 Plant
573
+ writeSetpoint(value: number(0..2000), mode?: "auto" | "manual"): boolean
574
+ read(): { celsius: number, bar: number }
575
+ event alarm(string, number) 0 subscribers
576
+ ```
577
+
578
+ **`call` exits 1 when the peer refuses**, which is the point: a smoke test is a line in a CI file
579
+ rather than a program that parses output.
580
+
581
+ ```
582
+ $ source-rpc call plantServer plant.writeSetpoint 3000 --hub http://bus:7843
583
+ msgrpc: plantServer.plant.writeSetpoint failed: InvalidParams: argument 0 is above the maximum 2000
584
+ $ echo $?
585
+ 1
586
+ ```
587
+
588
+ ### Arguments come from the contract
589
+
590
+ A shell has only strings, so the peer is described first and **its own contract decides what each
591
+ word means**. `1200` is a number where the schema says `number` and the text `1200` where it says
592
+ `string`; `auto` matches a literal in a union; an object argument is JSON; `date` takes an ISO
593
+ string and `bytes` takes hex. Where a peer publishes no contract the rule is JSON-if-it-parses and
594
+ the literal text otherwise, so `42` is a number and `hello` is a string rather than a syntax error.
595
+
596
+ A word that cannot be what the contract asks for is refused before anything is sent, and the
597
+ argument is named rather than numbered:
598
+
599
+ ```
600
+ $ source-rpc call plantServer plant.writeSetpoint warm
601
+ msgrpc: argument 0 (value): expected a number, got 'warm'
602
+ ```
603
+
604
+ `--args '[1200, "auto"]'` skips all of that and sends the array as it parses, for a call the
605
+ contract cannot describe or a value the shell would mangle.
606
+
607
+ ### Output
608
+
609
+ `--json` on every verb. Without it the output is for reading; with it, for `jq`. Which one is wanted
610
+ is not guessed from whether stdout is a tty, because that guess is wrong exactly when it matters —
611
+ in CI.
612
+
613
+ `call` puts the result on stdout and the timing on stderr, so a pipe carries the value and nothing
614
+ else while a person still sees what it cost:
615
+
616
+ ```
617
+ $ source-rpc call plantServer plant.read --hub http://bus:7843 | jq .celsius
618
+ 84
619
+ ```
620
+
621
+ `watch` writes one event per line as JSON, since it is the verb most likely to be piped somewhere
622
+ and a stream that is pleasant to read is a stream nothing can parse:
623
+
624
+ ```
625
+ $ source-rpc watch plantServer plant.alarm --hub http://bus:7843
626
+ msgrpc: watching plantServer.plant.alarm. Ctrl-C to stop.
627
+ {"at":1749047112004,"peer":"plantServer","namespace":"plant","event":"alarm","args":["pressure high",2]}
628
+ ```
629
+
630
+ Ctrl-C drops the server's subscription as well as stopping the stream, rather than only walking
631
+ away from it — a debugging session should not leave listeners behind on a device that outlives it.
632
+
633
+ ### Waiting for a peer
634
+
635
+ `ready()` means the links are up, not that presence has arrived, and over MQTT retained presence
636
+ lands a moment after the subscription does. Each verb waits up to `--wait` (5 s) for the peer to
637
+ become addressable and then says so plainly, rather than failing intermittently for reasons nobody
638
+ can reproduce:
639
+
640
+ ```
641
+ $ source-rpc call plantServr plant.read --hub http://bus:7843
642
+ msgrpc: plantServr did not appear within 5000 ms. Run 'source-rpc peers' to see who is there.
643
+ ```
644
+
645
+ ## serve
646
+
647
+ A peer built from a contract rather than from code, so an HMI has something to talk to and a test
648
+ has a device willing to fail on request — which a real one is not.
649
+
650
+ ```
651
+ source-rpc serve --contract plant.types.json --hub http://bus:7843 --name fakePlant
652
+ ```
653
+
654
+ ```
655
+ $ source-rpc describe fakePlant --hub http://bus:7843
656
+ fakePlant — arguments checked
657
+
658
+ plant@3 Fake
659
+ halt()
660
+ read(): { celsius: number(0..100), bar: number(0..10) }
661
+ writeSetpoint(value: number(0..2000), mode?: "auto" | "manual"): boolean
662
+ event alarm(string, number(1..3)) 0 subscribers
663
+ ```
664
+
665
+ It answers every method with a value of the declared shape, and **refuses what the real peer would
666
+ refuse** — it is given the same schema, so the same validator runs:
667
+
668
+ ```
669
+ $ source-rpc call fakePlant plant.writeSetpoint 3000 --hub http://bus:7843
670
+ msgrpc: fakePlant.plant.writeSetpoint failed: InvalidParams: argument 0: 3000 is above the maximum 2000
671
+ ```
672
+
673
+ The contract is the one already extracted and committed for the deployed peer, so the stand-in
674
+ cannot drift from it: `source-rpc check` fails the build when it would.
675
+
676
+ ### What it generates
677
+
678
+ Deterministic, and inside whatever the type language carries — a fake whose readings wander is
679
+ pleasant to look at and impossible to assert on.
680
+
681
+ | the schema says | you get |
682
+ | --- | --- |
683
+ | `number` with `min`/`max` | the midpoint, rounded if `integer` |
684
+ | `string` with `minLength`/`maxLength` | `sample`, padded or trimmed to fit |
685
+ | a union of literals | the first one that is not `null` |
686
+ | an object | its **required** fields only |
687
+ | an array | one element |
688
+ | `bytes` | four bytes, or `maxBytes` of them |
689
+ | `date` | now — a device reporting the epoch reads as a broken clock |
690
+
691
+ `pattern` is the one it cannot honour; satisfying an arbitrary regular expression is a different
692
+ problem, so a constrained string comes back as the placeholder. A recursive type stops rather than
693
+ descending forever.
694
+
695
+ ### Scripting it
696
+
697
+ ```
698
+ source-rpc serve --contract plant.types.json --script fake.json --fail plant.halt=Unauthorized --hub http://bus:7843
699
+ ```
700
+
701
+ ```json
702
+ {
703
+ "returns": { "plant.read": { "celsius": 84, "bar": 3.2 } },
704
+ "fails": { "plant.writeSetpoint": "Timeout" },
705
+ "emits": [{ "event": "plant.alarm", "every": 2000 }]
706
+ }
707
+ ```
708
+
709
+ `returns` replaces the generated answer. `fails` answers with an RPC error code instead —
710
+ `Unauthorized`, `Forbidden`, `InvalidParams` and so on — and **`Timeout` is the special one: the
711
+ call is never answered at all**, so the caller's own timeout is what fires. That is the failure an
712
+ HMI handles worst and the one you otherwise stage by pulling a cable. Only the named method is
713
+ affected; the rest of the peer keeps working, so a test can break one thing rather than the device.
714
+
715
+ `emits` sends a declared event on a timer, with parameters of the declared shape unless the script
716
+ supplies them — the receiving half of an HMI otherwise has nothing to receive.
717
+
718
+ `--fail <ns.method=Code>` is the same thing without a file, and is repeatable.
719
+
720
+ **It says it is a fake** on startup and in the class name a console shows, because a stand-in
721
+ mistaken for the device is worse than no stand-in at all.
722
+
723
+ ## bench
724
+
725
+ A device is fine at one call a second. What does it do at twenty? Finding that out is ordinarily
726
+ done by writing a script, and it is always the same script.
727
+
728
+ ```
729
+ $ source-rpc bench plantServer plant.read --rate 40 --for 3000 --hub http://bus:7843
730
+ plantServer plant.read 120 calls in 3.0s at 40/s
731
+ ms min 1 p50 3 p90 4 p95 4 p99 5 max 5
732
+ ok 120 failed 0
733
+ ```
734
+
735
+ **Percentiles rather than an average**, because an average hides exactly the calls worth knowing
736
+ about: a device answering in 2 ms with one reply in four seconds averages out to something that
737
+ looks healthy.
738
+
739
+ Failures are counted by code, since a device refusing arguments and a device that stopped answering
740
+ are different findings with the same shape:
741
+
742
+ ```
743
+ $ source-rpc bench plantServer plant.writeSetpoint 9999 --rate 20 --for 1500
744
+ plantServer plant.writeSetpoint 30 calls in 1.5s at 20/s
745
+ ok 0 failed 30
746
+ InvalidParams: 30
747
+ $ echo $?
748
+ 1
749
+ ```
750
+
751
+ Errors under load are the finding, so any failure exits 1. Arguments are coerced from the peer's own
752
+ contract, exactly as `call` does.
753
+
754
+ `--concurrency` bounds how many calls may be outstanding at once; past that they are **not sent and
755
+ counted as fallen behind**. Piling calls onto a device that is already behind measures the queue
756
+ rather than the device, and a run that did it would report healthy latencies for a device that is
757
+ drowning.
758
+
759
+ ## record and replay
760
+
761
+ The question a plant asks constantly and no test framework answers: *this new device is supposed to
762
+ behave like the old one — does it?* Capture a session from the working plant, replay it at the
763
+ replacement, and compare the answers.
764
+
765
+ ```
766
+ source-rpc record --out session.jsonl --hub http://bus:7843
767
+ source-rpc replay session.jsonl --against newPlant --hub http://bus:7843
768
+ ```
769
+
770
+ `record` opens a tap wherever it can — a broker's `bus` over socket.io, its own subscription over
771
+ MQTT — and writes one frame per line:
772
+
773
+ ```
774
+ {"msgrpc":"recording","version":1,"at":1785283506726,"filter":{"payloads":true},"sources":["plantBus"]}
775
+ {"at":1785283509702,"source":"hmi-3","target":"plantServer","kind":"POST","namespace":"plant","method":"read","id":"396f…","params":[]}
776
+ {"at":1785283509705,"source":"plantServer","target":"hmi-3","kind":"SUCCESS","id":"396f…","ms":3,"result":{"celsius":84}}
777
+ ```
778
+
779
+ jsonl, so `grep`, `jq` and `wc -l` work on it. Lines are appended as they arrive, so a process
780
+ killed mid-session still leaves what it saw — a recording is most wanted from the run that ended
781
+ badly.
782
+
783
+ **Payloads are on by default here**, where the tap has them off: a recording without arguments and
784
+ results cannot be replayed, which is the only reason to make one. `--no-payloads` turns them off and
785
+ `record` says on startup that it is writing them.
786
+
787
+ ### Replaying
788
+
789
+ `replay` re-issues the recorded calls, in their original spacing, and compares each answer with the
790
+ one that was recorded:
791
+
792
+ ```
793
+ $ source-rpc replay session.jsonl --hub http://bus:7843
794
+ ≠ plantServer plant.read: expected {"celsius":84,"bar":3.2}, got {"celsius":12,"bar":3.2}
795
+ source-rpc replay: 12 calls, 9 matched, 3 differed, 0 failed, 0 uncompared
796
+ $ echo $?
797
+ 1
798
+ ```
799
+
800
+ **It exits 1 when anything differed or failed**, so a conformance check is a line in a CI file.
801
+
802
+ - `--against <peer>` sends every call to one peer instead of its original addressee, which is how a
803
+ session captured from `plantServer` is played at `plantServer-v2`.
804
+ - `--speed <n>` scales the original gaps; `0` sends with no waiting. The spacing is kept by default
805
+ because a device that only misbehaves at the rate it actually sees should be given that rate.
806
+ - **A call that failed the same way it failed when recorded is a match.** A replacement that refuses
807
+ what the old one refused is behaving, and marking that a failure would make every recording of a
808
+ real plant unusable.
809
+ - A call recorded without payloads is reported, not sent empty — calling the method with nothing and
810
+ comparing that is a worse answer than saying the recording cannot be replayed.
811
+ - Nothing recorded to compare against is counted apart as *uncompared* rather than as a pass.
812
+
813
+ `Date` and `Uint8Array` are tagged in the file (`{"$date":…}`, `{"$bytes":…}`) and restored on the
814
+ way back. JSON carries neither, and a timestamp that replayed as a string is not what the device
815
+ received — which is the same reason this library speaks MsgPack.
816
+
817
+ Events are recorded but not replayed: sending a device's own events back at it would be a different
818
+ thing entirely.
819
+
820
+ ## mcp
821
+
822
+ ```
823
+ source-rpc mcp --broker mqtt://localhost:1883
824
+ source-rpc mcp --hub http://hub:7843
825
+ ```
826
+
827
+ Serves the network to an [MCP](https://modelcontextprotocol.io) client over stdio, so a model can
828
+ look at a plant the way a person looks at the console. It takes the same network flags as `console`,
829
+ including `--sign`.
830
+
831
+ | tool | what it does |
832
+ | --- | --- |
833
+ | `list_peers` | who is on the network right now |
834
+ | `describe_peer` | one peer's namespaces, methods, argument names and types, and events |
835
+ | `call_method` | call a method, with positional arguments, and return what it returns |
836
+ | `start_fake` | stand a peer up from a contract and put it on this network |
837
+ | `stop_fake` / `list_fakes` | take one off again; what is being served here |
838
+ | `check_peer` | compare a live peer with a contract and report what would break |
839
+ | `diff_peers` | what two live peers expose differently |
840
+ | `watch_traffic` | what other peers are saying to each other, for a few seconds |
841
+ | `watch_events` | what one peer emitted, for a few seconds |
842
+ | `save_contract` / `list_contracts` | only with `--contracts <dir>` |
843
+
844
+ ### Standing something up
845
+
846
+ The awkward part of asking a model to test a device is that the device has to exist. `start_fake`
847
+ takes a contract **inline** — no file, no shell, no second terminal — and puts a peer on the network
848
+ that answers from it:
849
+
850
+ ```
851
+ start_fake { name: "fakePlant", schema: {…}, script: { returns: { "plant.read": { celsius: 84 } } } }
852
+ → fakePlant is on the network, answering plant from the contract. It is a fake: it answers from
853
+ the contract, not from a device.
854
+ ```
855
+
856
+ From there the ordinary verbs reach it, and it **refuses what the contract refuses** — so a model
857
+ can check that its caller handles `InvalidParams` without touching anything real. `script` supplies
858
+ canned returns, deliberate failures and timed events, including the `Timeout` code that never
859
+ answers at all.
860
+
861
+ **A fake will not take a name a peer already answers to.** Standing one up under a live device's
862
+ name would displace it, and calls meant for the plant would reach a stand-in that agrees with
863
+ everything. That is refused, not resolved.
864
+
865
+ Fakes run inside the MCP server rather than as spawned processes, so they stop when it does and
866
+ none are left behind.
867
+
868
+ ### Where contracts go
869
+
870
+ `--contracts <dir>` is what makes `save_contract` and `list_contracts` exist at all. Without it they
871
+ are **not in the tool list**, because a server that cannot write files should not advertise tools
872
+ claiming it can. With it, a contract is written as `<name>.types.json` in that directory and nowhere
873
+ else — a name that would climb out of it is refused rather than resolved — and what is written is
874
+ the same file `source-rpc serve --contract` and `source-rpc check --peer --against` read.
875
+
876
+ So the loop closes: a model can draft a contract, save it where the CLI will find it, stand a peer
877
+ up from it, drive that peer, and check a real device against the same file.
878
+
879
+ Not one tool per method on the network. A peer set that changes while a model is mid-conversation
880
+ would mean re-issuing the tool list on every arrival and departure; `describe_peer` hands over the
881
+ argument types instead, which is the same information in a form that does not go stale.
882
+
883
+ A call a peer refuses comes back as tool content with `isError`, carrying the reason — `InvalidParams:
884
+ argument 0: expected number, got string` — rather than as a JSON-RPC failure. A model should read
885
+ that and fix its call, which it cannot do if the transport swallows it.
886
+
887
+ To wire it into a client, give it the command and its flags:
888
+
889
+ ```json
890
+ {
891
+ "mcpServers": {
892
+ "plant": { "command": "msgrpc", "args": ["mcp", "--broker", "mqtt://localhost:1883"] }
893
+ }
894
+ }
895
+ ```
896
+
897
+ **stdout carries the protocol and nothing else**, so this is not for interactive use — startup goes
898
+ to stderr, and a stray `console.log` anywhere in the process would corrupt the stream. There is no
899
+ MCP SDK behind it: MCP is JSON-RPC 2.0 over newline-delimited stdio, which is little enough to speak
900
+ directly, and this package is about not needing a second RPC framework.
901
+
902
+ **Anything a model can reach, it can call.** The peers this lists are real, and `call_method` will
903
+ happily invoke one that opens a valve. Point it at a network where that is acceptable, or give it
904
+ credentials that restrict it: `--sign` makes it a peer with an identity, and `authorize` on the
905
+ servers decides what that identity may do.
906
+
907
+ **And it can put peers on that network.** `start_fake` adds one — it calls nothing and changes no
908
+ device, and it refuses a name already in use, but it is a peer other things can find and call. The
909
+ same `authorize` and `--sign` machinery governs what it may do once it is there. Writing files is
910
+ the one capability that stays off unless asked for: no `--contracts`, no tools that write.
911
+
912
+ ## console
913
+
914
+ ```
915
+ source-rpc console --broker mqtt://localhost:1883 # an MQTT network
916
+ source-rpc console --hub http://hub:7843 # a socket.io network
917
+ source-rpc console --broker mqtt://... --hub http://... # both at once
918
+ ```
919
+
920
+ Opens a console at `http://127.0.0.1:7844` listing every peer that is up, what each one exposes, a
921
+ form to call it, and a live stream of its events.
922
+
923
+ **Discovery costs nothing.** Every peer announces itself, so the console is handed everyone already
924
+ online the moment it connects. There is no scan, no probe and no configured list of hosts. Over MQTT
925
+ that is retained presence under `<prefix>/presence/+`; over socket.io the hub keeps the list.
926
+
927
+ With both, one list covers both networks and each peer is called over the link it was found on —
928
+ which is the useful shape when a plant runs on a broker and the HMIs are browser pages. A peer
929
+ hosted *in* a browser shows up like any other, since a page that dials a hub can serve as well as
930
+ call.
931
+
932
+ A peer only appears in detail if its server was started with `exposeIntrospection`; otherwise the
933
+ console says so rather than guessing.
934
+
935
+ **One port.** The page, `console.json` and the RPC link all arrive on 7844: socket.io answers
936
+ `/socket.io` on the same listener the static app is served from. There is no second port to open and
937
+ no CORS to configure, because the page and its server share an origin.
938
+
939
+ ### Behind a reverse proxy
940
+
941
+ The console can be published under a path. Nothing needs configuring — the page works out where it
942
+ was served from and hangs everything off that, so its assets, `console.json` and its socket all land
943
+ back on the same mount:
944
+
945
+ ```nginx
946
+ location /tools/console/ {
947
+ proxy_pass http://console:7844/; # the trailing slashes matter, on both lines
948
+ proxy_http_version 1.1;
949
+ proxy_set_header Upgrade $http_upgrade;
950
+ proxy_set_header Connection "upgrade";
951
+ }
952
+ ```
953
+
954
+ Two things that will bite otherwise. **Both paths must end in `/`** — the page resolves everything
955
+ relative to its mount point, and `/tools/console` without the slash resolves one level up, so the
956
+ app asks `/tools/` for its files. And the `Upgrade` headers are what let socket.io leave long
957
+ polling for a WebSocket; without them it still works, and quietly costs a round trip per frame.
958
+
959
+ That rule **strips** the prefix, which is what the trailing slash on `proxy_pass` does. For a proxy
960
+ that forwards it through unchanged — `proxy_pass http://console:7844;`, no slash, or an ingress that
961
+ does not rewrite — tell the console where it is published:
962
+
963
+ ```
964
+ source-rpc console --hub http://bus:7843 --base-path /tools/console
965
+ source-rpc console on http://127.0.0.1:7844/tools/console/, watching http://bus:7843 as console-…
966
+ ```
967
+
968
+ The page, its assets, `console.json` and socket.io then all answer under that path and nowhere else:
969
+ a request to `/` gets a 404 rather than the app, because the rest of that origin belongs to whatever
970
+ the proxy publishes beside it. `/tools/console` without the slash redirects to `/tools/console/`,
971
+ since that is the only place the relative paths come out right.
972
+
973
+ Both ends of the same idea: the page always asks relative to where it was served, and `--base-path`
974
+ tells the *server* to expect the prefix. Use it only when the proxy keeps the prefix — with a
975
+ stripping rule it would put the console one level deeper than the proxy is looking.
976
+
977
+ ### Calling a method
978
+
979
+ Each method folds open into a form with **one field per argument**, built from the argument's own
980
+ type rather than asking for the whole call as a JSON array:
981
+
982
+ | the schema says | you get |
983
+ | --- | --- |
984
+ | `number`, with `min`/`max` | a number input carrying those bounds |
985
+ | a union of literals | a dropdown of exactly those values |
986
+ | `boolean` | a checkbox |
987
+ | `date` | a date and time picker |
988
+ | `bytes` | a hex field |
989
+ | an object or a named type | a JSON box **pre-filled with the shape's required fields** |
990
+
991
+ Optional arguments have a checkbox that decides whether they are sent at all, so
992
+ `writeSetpoint(1200)` and `writeSetpoint(1200, 'auto')` are both reachable. Argument names come
993
+ from `paramNames` in the contract, which `extract` writes — without a contract the form falls back
994
+ to positions, since nothing else knows what argument 0 is called.
995
+
996
+ JSON has no date and no byte string, so what is typed into a JSON box is walked against the type
997
+ before it is sent: an ISO string where the schema says `date` becomes a `Date`. Otherwise every
998
+ object with a timestamp in it would be rejected by the server that asked for one.
999
+
1000
+ ### The console describes itself
1001
+
1002
+ Both services this package runs — the CLI's `console` namespace and the `chat` namespace the page
1003
+ hosts — ship a contract extracted from their own source, so pointing one console at another gives
1004
+ argument fields rather than `call(…)` and `say(…)`:
1005
+
1006
+ ```
1007
+ npm run contract # extract both, into src/console.types.json and web/src/chat.types.json
1008
+ npm run check:contract # the same comparison the server applies to an older caller
1009
+ ```
1010
+
1011
+ The files are committed, which makes them reviewable and lets `check:contract` fail a build that
1012
+ would refuse a peer built against the old one. A test asserts they still match their source, since a
1013
+ service changed without re-extracting would ship a contract describing the old shape.
1014
+
1015
+ The console's own contract was the first thing to need `record`: `describe()` returns a
1016
+ `ServerDescription`, built out of `{ [name: string]: TypeNode }` — so until the type language could
1017
+ describe a dictionary, it could not describe its own output.
1018
+
1019
+ The chat contract is the one that has to survive a bundler. `@rpc` and `@rpcNamespace` are standard
1020
+ ECMAScript decorators, and they come through Vite's build intact — which is also what keeps the
1021
+ namespace called `chat` rather than the minified class name, and what `extract` reads statically to
1022
+ write the contract in the first place.
1023
+
1024
+ ### Watching events
1025
+
1026
+ **Watch all** takes every event in a namespace in one click, which is the usual first move on an
1027
+ unfamiliar peer. The events pane has a filter, a pause and an **export** that saves what is on screen
1028
+ as jsonl — the same shape `source-rpc record` writes and `jq` reads. Pausing stops the buffer filling
1029
+ rather than only the list rendering, so a paused pane on a busy network stays as it was.
1030
+
1031
+ Arguments worth keeping get a **save** button. Presets are stored in the browser and keyed by
1032
+ namespace and method rather than by peer, so a set saved against one cell is offered on the next —
1033
+ the reason to save a setpoint sequence usually being that five more cabinets are coming. They are
1034
+ named by what they hold, so there is nothing to type.
1035
+
1036
+ Each method keeps its timings: **×20** calls it repeatedly and reports `20 calls · p50 1 ms · last
1037
+ 1 ms` next to the button, which is `source-rpc bench` in miniature for when the question is smaller than
1038
+ a benchmark. **copy as CLI** puts the equivalent `source-rpc call …` on the clipboard, complete with the
1039
+ network flags this console was started with — a call worth making in a browser is usually one worth
1040
+ putting in a script, and retyping `--hub http://…` from memory is where that stops happening.
1041
+
1042
+ The watch button toggles, and unwatching drops the server's subscription too rather than only
1043
+ silencing the browser — the subscriber count next to the event moves with it. Closing the console
1044
+ unsubscribes everything it held, so a debugging session does not leave listeners behind on servers
1045
+ that outlive it.
1046
+
1047
+ ### How it is built
1048
+
1049
+ The browser half is a React app talking to the CLI **over msgrpc itself**. The CLI runs an
1050
+ `RpcServer` on the same HTTP server that serves the page and exposes a `console` namespace
1051
+ (`peers`, `describe`, `call`, `watch`, `unwatch`) plus `event` and `peer` events. There is no REST
1052
+ API and no server-sent events, and the console is the library's own first client — a bug in event
1053
+ routing shows up here before it reaches a plant.
1054
+
1055
+ The page closes its connection on `pagehide` rather than only on unmount, because React's cleanup
1056
+ does not run when a document is torn down by a navigation - a page that did not would stay a peer in
1057
+ everyone's list until the console reaped it, and socket.io's long-polling transport means a handful
1058
+ of those exhausts the browser's per-host connection limit and stops the next page connecting at all.
1059
+ If a handshake does fail, the page tries again three times before saying so.
1060
+
1061
+ Each page takes a random readable name — `page-drink-love-spy` — kept in `sessionStorage`, so a
1062
+ reload comes back as the same peer and a second tab is simply a different one. It is not derived
1063
+ from the URL, because a name is an address: every browser pointed at one console would derive the
1064
+ same one, and then two pages answer to it and each other's replies go to whichever the console
1065
+ registered last. A page cannot detect that, since `localStorage` is per browser profile and cannot
1066
+ see the other browser. Add `?name=lab-browser` to give a page a name of its own — the page's version
1067
+ of the CLI's `--name` — for when it should be recognisable in a peer list rather than merely unique.
1068
+
1069
+ The page is an `RpcServer` too, not a client. It serves over the connection it opens to the console,
1070
+ which is the only thing a browser can do since it cannot listen, and that is what lets its `chat`
1071
+ namespace be called by another peer. The same object calls outwards with `proxy()`, so browsing the
1072
+ network and hosting a service on it share one link and one name. Chat exists to exercise exactly
1073
+ that direction: two consoles on one bus, a page on each, and a message crossing between them tests
1074
+ dial-out serving, presence propagation and relaying in a way no amount of calling the console can.
1075
+
1076
+ Everything is bundled into the CLI's `dist`: no CDN, no runtime download. A plant network usually
1077
+ has no route to the internet, and a page that fetches from one renders blank exactly where it is
1078
+ needed.
1079
+
1080
+ `npm run dev:web` in the package serves the app with hot reload against a console started
1081
+ separately on port 7844.
1082
+
1083
+ ### Signed networks
1084
+
1085
+ A server configured with `verify` drops unsigned frames before the RPC layer. Without keys the
1086
+ console still lists peers — presence is unsigned retained state — and then every call times out with
1087
+ nothing to say why. Give it keys with `--sign`:
1088
+
1089
+ ```
1090
+ source-rpc console --broker mqtt://broker:1883 --sign console-keys.json
1091
+ ```
1092
+
1093
+ ```json
1094
+ {
1095
+ "name": "console-1",
1096
+ "secret": "the console's own HMAC secret",
1097
+ "peers": { "plantServer": "that server's secret" }
1098
+ }
1099
+ ```
1100
+
1101
+ A file rather than a flag, because a secret on the command line is visible to anyone who can run
1102
+ `ps`. The console warns if the file is readable by other users.
1103
+
1104
+ `peers` is optional. Supplying it makes the console check signatures on what it receives as well,
1105
+ which means frames from an unsigned peer are then dropped.
1106
+
1107
+ The server checks a signature against the key it holds for the name the frame claims, so the
1108
+ console's name has to be the one its key belongs to. `name` in the file supplies it; passing a
1109
+ `--name` that contradicts the file is refused rather than left to surface as a timeout.
1110
+
1111
+ HMAC only. For Ed25519 or an HSM, build the console with the library's `startConsole` and pass your
1112
+ own `MessageSigner`.
1113
+
1114
+ ### Other limits
1115
+
1116
+ **It binds to `127.0.0.1` by default.** The console can invoke any method it is allowed to, so
1117
+ exposing it has to be a deliberate act: `--host 0.0.0.0` works and prints a warning saying what you
1118
+ have just done.
1119
+
1120
+ **Credentials are thin.** Broker credentials work if they fit in the url (`mqtt://user:pass@host`);
1121
+ TLS client certificates have nowhere to go yet, and neither does a private certificate authority —
1122
+ `--insecure-tls` accepts any certificate at all, which is a development answer and not a plant one.
1123
+ A hub that authenticates needs a handshake token, which has no flag for the same reason the signing
1124
+ keys do not — build the console from the library's `startConsole` and pass `hubCredentials`.
1125
+
1126
+ **`--prefix` is MQTT's.** A socket.io hub has no topic namespace, so the flag does nothing for
1127
+ `--hub`. Watching two MQTT networks at once is not possible either; it is one broker and one hub.
1128
+
1129
+ **Give it its own name on a busy network.** The default is unique per process, but a peer name maps
1130
+ to an MQTT client id and a broker allows one connection per id, so two consoles sharing a `--name`
1131
+ will disconnect each other.