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