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