@source-repo/rpc 3.0.0 → 3.1.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,44 +15,48 @@
15
15
 
16
16
  # @source-repo/rpc
17
17
 
18
- Source RPC — TypeScript RPC over socket.io and MQTT 5. A class is the contract: the server hands one live instance
19
- to `exposeClassInstance`, the client gets a typed proxy of the same class, and calling a method on
20
- the proxy runs it on that instance. No code generation and no schema files required, though there is
21
- a schema when you want arguments checked at runtime.
18
+ TypeScript RPC for a network of peers a browser tab, a Node service, and a plant full of devices — over socket.io and MQTT 5, with one programming model across all of them.
19
+
20
+ A class is the contract: the server hands one live instance to `exposeClassInstance`, the client gets a typed proxy of the same class, and calling a method on the proxy runs it on that instance. No code generation and no schema files required, though there is a schema when you want arguments checked at runtime.
22
21
 
23
22
  ```
24
23
  npm install @source-repo/rpc
25
24
  ```
26
25
 
27
- ESM only, Node 18.17 or later, and it runs in the browser. Contracts can be extracted from your
28
- source and checked for breaking changes with [`@source-repo/rpc-cli`](https://www.npmjs.com/package/@source-repo/rpc-cli), which also serves
29
- a browser console for a live network and ships the bus as a container.
26
+ ESM only, Node 18.17 or later, and it runs in the browser.
30
27
 
31
- Upgrading from 1.x? [`CHANGELOG.md`](https://github.com/source-repo/rpc/blob/main/CHANGELOG.md) lists what breaks.
28
+ **If all you need is a browser talking to a Node server, use [tRPC](https://trpc.io).** It is very good at that and far more widely used. This is for the case it does not cover: more than two parties, not all on the same kind of link, and commands where sending one twice is not free.
32
29
 
33
- ### What is in it
30
+ ## What is in it
34
31
 
35
- - **Two transports, one programming model.** socket.io for browsers and anything that dials out;
36
- MQTT 5 for the plant, with a [documented wire format](https://github.com/source-repo/rpc/blob/main/docs/mqtt5-frame-spec.md)
37
- that a plain MQTT.js peer can speak without any of this code.
32
+ - **Command semantics for machinery.** A method declares whether repeating it is free, harmless or dangerous, as part of the contract rather than as a comment; a caller can tell *did not run* from *may have run*; a durable idempotency hook makes a redelivered command run once; calls into one instance can be serialised. This is the part you will not find elsewhere — see [Commands](#commands).
33
+ - **Deadlines that mean something.** A request carries the time its caller will wait, the broker is given the same deadline, and a server refuses to run a command whose caller has already gone.
34
+ - **Two transports, one programming model.** socket.io for browsers and anything that dials out; MQTT 5 for the plant, with a [documented wire format](https://github.com/source-repo/rpc/blob/main/docs/mqtt5-frame-spec.md) that a plain MQTT.js peer can speak without any of this code.
35
+ - **Any peer can be a bus, and a browser tab can host a server.** A frame is addressed to a name, not a socket, so a server that exposes nothing and forwards everything is a switchboard — and a peer that cannot listen is still addressable. See [Connecting](#connecting).
36
+ - **Contracts checked at runtime**, and compared between versions so a change that would break a deployed caller fails a build rather than a plant.
38
37
  - **Events**, with subscriptions replayed after a reconnect so a dropped link does not go quiet.
39
- - **Contracts checked at runtime**, and compared between versions so a change that would break a
40
- deployed caller fails a build rather than a plant.
41
- - **Command semantics for machinery.** A method says whether repeating it is free, harmless or
42
- dangerous; a caller can tell *did not run* from *may have run*; a durable idempotency hook makes a
43
- redelivered command run once; calls into one instance can be serialised. See [Commands](#commands).
44
- - **Deadlines that mean something.** A request carries the time its caller will wait, the broker is
45
- given the same deadline, and a server refuses to run a command whose caller has already gone.
46
- - **Authentication both ways.** Per-connection tokens where there is a connection, per-frame
47
- signing (HMAC or Ed25519) where there is not, with replay protection and pinned peer names.
48
- - **TLS**, including trusting a plant's own certificate authority rather than switching checks off.
38
+ - **Authentication both ways.** Per-connection tokens where there is a connection, per-frame signing (HMAC or Ed25519) where there is not, with replay protection and pinned peer names. Plus TLS, including trusting a plant's own certificate authority rather than switching checks off.
49
39
  - **Introspection.** A server can describe itself — namespaces, methods, argument types, events.
50
- That is what the browser console reads, and what lets the CLI serve a whole network to an AI
51
- assistant over [MCP](https://modelcontextprotocol.io): list the peers, describe them, call them.
40
+
41
+ That last one is what the tooling is built on. [`@source-repo/rpc-cli`](https://www.npmjs.com/package/@source-repo/rpc-cli) extracts a contract from your source and fails a build on a breaking change; it also serves a browser console for a live network, taps traffic crossing a bus, records and replays a session, and hands the whole network to an AI assistant over [MCP](https://modelcontextprotocol.io).
42
+
43
+ Upgrading from 1.x? [`CHANGELOG.md`](https://github.com/source-repo/rpc/blob/main/CHANGELOG.md) lists what breaks.
44
+
45
+ ## Contents
46
+
47
+ **Getting going** — [Quick start](#quick-start) · [Connecting](#connecting) · [Exposing methods](#exposing-methods)
48
+
49
+ **Machinery** — [Commands](#commands) · [Errors](#errors) · [Events and reconnection](#events-and-reconnection)
50
+
51
+ **Contracts** — [Checking arguments](#checking-arguments) · [Describing a server](#describing-a-server)
52
+
53
+ **Security and transport** — [Authentication and authorization](#authentication-and-authorization) · [MQTT](#mqtt)
54
+
55
+ **Reference** — [Options](#options) · [Browser use](#browser-use) · [Peer routing](#peer-routing) · [Low level: modules](#low-level-modules) · [Development](#development)
52
56
 
53
57
  ## Quick start
54
58
 
55
- The class both sides share. Nothing here is msgrpc-specific — no decorators, no base class:
59
+ The class both sides share. Nothing here is Source RPC-specific — no decorators, no base class:
56
60
 
57
61
  ```typescript
58
62
  // calculator.ts
@@ -100,44 +104,25 @@ await client.close()
100
104
 
101
105
  Three things worth noticing:
102
106
 
103
- **The instance is a real, long-lived object.** It is constructed once, by you, and every call runs
104
- against that same object — which is why `add` accumulates. State lives where you would put it
105
- anyway, in fields. Nothing is constructed or discarded per call, and the instance you passed in is
106
- still yours to read and mutate locally. (A class can instead be *registered* for peers to
107
- instantiate remotely, but that is off by default and rarely what you want — see
108
- [The management surface](#the-management-surface).)
107
+ **The instance is a real, long-lived object.** It is constructed once, by you, and every call runs against that same object — which is why `add` accumulates. State lives where you would put it anyway, in fields. Nothing is constructed or discarded per call, and the instance you passed in is still yours to read and mutate locally. (A class can instead be *registered* for peers to instantiate remotely, but that is off by default and rarely what you want — see [The management surface](#the-management-surface).)
109
108
 
110
- **`import type`** on the client is the point of the whole design: the client compiles against the
111
- class but imports none of its code, so the implementation never reaches the browser bundle. In a
112
- monorepo, put the class in a package both sides depend on. If the client cannot see the class at
113
- all — a different language, a different repo — describe the surface with an `interface` instead and
114
- pass that to `proxy<T>()`.
109
+ **`import type`** on the client is the point of the whole design: the client compiles against the class but imports none of its code, so the implementation never reaches the browser bundle. In a monorepo, put the class in a package both sides depend on. If the client cannot see the class at all — a different language, a different repo — describe the surface with an `interface` instead and pass that to `proxy<T>()`.
115
110
 
116
- **`.remote!`** — `proxy()` returns a small record (`{ name, target?, remote? }`) whose `remote` is
117
- the typed proxy. The field is optional in the type because the record is assembled piece by piece;
118
- `proxy()` awaits `ready()` first, so what it hands back always has one, hence the `!`.
111
+ **`.remote!`** — `proxy()` returns a small record (`{ name, target?, remote? }`) whose `remote` is the typed proxy. The field is optional in the type because the record is assembled piece by piece; `proxy()` awaits `ready()` first, so what it hands back always has one, hence the `!`.
119
112
 
120
113
  ## Connecting
121
114
 
122
- Before the table: it is worth ten lines on what a network of these looks like, because everything
123
- below follows from one idea.
115
+ Before the table: it is worth ten lines on what a network of these looks like, because everything below follows from one idea.
124
116
 
125
- **An `RpcServer` exposes methods; an `RpcClient` calls them.** For a single link that is the whole
126
- API, and the quick start above has already shown it.
117
+ **An `RpcServer` exposes methods; an `RpcClient` calls them.** For a single link that is the whole API, and the quick start above has already shown it.
127
118
 
128
- The rest comes from this: **a peer is anything on the network with a name**, and a frame is
129
- addressed to a *name*, not to a socket. A server has a name, a client has a name. Once addressing
130
- works that way, three things follow:
119
+ The rest comes from this: **a peer is anything on the network with a name**, and a frame is addressed to a *name*, not to a socket. A server has a name, a client has a name. Once addressing works that way, three things follow:
131
120
 
132
- - **A server can call as well as answer.** `RpcServer.proxy()` is the same call as the client's, and
133
- hands back the same typed object it just travels over a link the server already has.
134
- - **A server can relay.** A frame addressed to a name it is not, but can see, is passed along
135
- instead of executed. That is what makes a peer reachable *through* another peer.
121
+ - **A server can call as well as answer.** `RpcServer.proxy()` is the same call as the client's, and hands back the same typed object — it just travels over a link the server already has.
122
+ - **A server can relay.** A frame addressed to a name it is not, but can see, is passed along instead of executed. That is what makes a peer reachable *through* another peer.
136
123
  - **A peer that only relays is a bus.** Nothing else is needed to build one.
137
124
 
138
- So a **bus** — hub, broker, switchboard, whichever word you prefer — is not a different kind of
139
- program. It is an `RpcServer` that exposes nothing and forwards everything. An MQTT broker plays
140
- exactly the same part for an MQTT network; msgrpc just does not require you to have one.
125
+ So a **bus** — hub, broker, switchboard, whichever word you prefer — is not a different kind of program. It is an `RpcServer` that exposes nothing and forwards everything. An MQTT broker plays exactly the same part for an MQTT network; msgrpc just does not require you to have one.
141
126
 
142
127
  The server's `transports` say where it listens; the client's url says where to reach it.
143
128
 
@@ -148,10 +133,7 @@ The server's `transports` say where it listens; the client's url says where to r
148
133
  | `transports: [{ server: httpServer }]` | `new RpcClient(origin)` — share an `http.Server` you already have, so the page and its RPC arrive on one port |
149
134
  | `transports: [{ brokerurl: 'mqtt://broker:1883' }]` | `new RpcClient('mqtt://broker:1883', { defaultTarget: 'plantServer' })` |
150
135
 
151
- **7843 is the Source RPC port**, exported as `defaultWebSocketPort`, and 7844 — `defaultWebPort` —
152
- is where anything serving a browser puts its HTTP port. Adjacent rather than an offset apart,
153
- because they are read together. Both are deliberately clear of the 80xx range, where a developer's
154
- other work already is.
136
+ **7843 is the Source RPC port**, exported as `defaultWebSocketPort`, and 7844 — `defaultWebPort` — is where anything serving a browser puts its HTTP port. Adjacent rather than an offset apart, because they are read together. Both are deliberately clear of the 80xx range, where a developer's other work already is.
155
137
 
156
138
  | | | |
157
139
  | --- | --- | --- |
@@ -162,23 +144,13 @@ other work already is.
162
144
  | `8843` | `rpc-tls` | the same, with a certificate |
163
145
  | `8844` | `console-tls` | |
164
146
 
165
- The encrypted pair is a thousand above rather than beside: the last two digits still match, so it is
166
- one number to remember with a rule attached, but **no range covers a plain port and an encrypted
167
- one**. `allow 7843:7846` is the firewall rule somebody writes at the end of a long day, and it must
168
- not be able to publish the clear-text bus by fencepost while meaning to open only the encrypted one.
169
- MQTT draws the same line between 1883 and 8883, so the habit transfers.
147
+ The encrypted pair is a thousand above rather than beside: the last two digits still match, so it is one number to remember with a rule attached, but **no range covers a plain port and an encrypted one**. `allow 7843:7846` is the firewall rule somebody writes at the end of a long day, and it must not be able to publish the clear-text bus by fencepost while meaning to open only the encrypted one. MQTT draws the same line between 1883 and 8883, so the habit transfers.
170
148
 
171
- These say where to *find* a service; nothing enforces them. A port carries TLS because it was given
172
- a certificate, never because of its number — and the CLI's `--cert`/`--key` move a server to its
173
- encrypted port on their own, so the convention holds without anyone having to remember it.
149
+ These say where to *find* a service; nothing enforces them. A port carries TLS because it was given a certificate, never because of its number — and the CLI's `--cert`/`--key` move a server to its encrypted port on their own, so the convention holds without anyone having to remember it.
174
150
 
175
- **One process needs one port.** `{ server: httpServer }` above is how a page and its RPC arrive
176
- together: socket.io answers `/socket.io` on that listener and your own handler serves everything
177
- else. The console is built exactly this way. The second number is for running two programs on one
178
- host.
151
+ **One process needs one port.** `{ server: httpServer }` above is how a page and its RPC arrive together: socket.io answers `/socket.io` on that listener and your own handler serves everything else. The console is built exactly this way. The second number is for running two programs on one host.
179
152
 
180
- A server may hold several at once, serving the same exposed instances to each. One server can face a
181
- browser over socket.io and a plant network over MQTT:
153
+ A server may hold several at once, serving the same exposed instances to each. One server can face a browser over socket.io and a plant network over MQTT:
182
154
 
183
155
  ```typescript
184
156
  const server = new RpcServer({
@@ -190,9 +162,7 @@ server.exposeClassInstance(new Plant(), 'plant') // reachable over both
190
162
 
191
163
  ### A bus without a broker
192
164
 
193
- Here is that bus, in full. It exposes nothing, so every frame that reaches it is addressed to
194
- somebody else and gets forwarded. Everything else dials *it*, and gets what MQTT would have given
195
- them: presence, addressing by name, and any peer able to call any other.
165
+ Here is that bus, in full. It exposes nothing, so every frame that reaches it is addressed to somebody else and gets forwarded. Everything else dials *it*, and gets what MQTT would have given them: presence, addressing by name, and any peer able to call any other.
196
166
 
197
167
  ```typescript
198
168
  const bus = new RpcServer({ name: 'bus', transports: [{ port: 7843 }] })
@@ -205,14 +175,9 @@ const oven = await cellSrv.proxy<Oven>('oven', 'ovenSrv')
205
175
  await oven.remote!.temperature()
206
176
  ```
207
177
 
208
- Read the last two lines again, because they are the part that surprises people. `cellSrv` is a
209
- *server* — and it is calling out. `RpcServer.proxy()` takes the name of a namespace and the name of
210
- the peer holding it, and returns the same typed object `RpcClient.proxy()` would. The call leaves
211
- over the connection `cellSrv` already opened to the bus, the bus forwards it to `ovenSrv`, and the
212
- answer comes back the same way.
178
+ Read the last two lines again, because they are the part that surprises people. `cellSrv` is a *server* — and it is calling out. `RpcServer.proxy()` takes the name of a namespace and the name of the peer holding it, and returns the same typed object `RpcClient.proxy()` would. The call leaves over the connection `cellSrv` already opened to the bus, the bus forwards it to `ovenSrv`, and the answer comes back the same way.
213
179
 
214
- Nothing here dialled `ovenSrv` directly. It may not even be dialable — it could be a browser tab.
215
- The bus is what they have in common, and that is enough.
180
+ Nothing here dialled `ovenSrv` directly. It may not even be dialable — it could be a browser tab. The bus is what they have in common, and that is enough.
216
181
 
217
182
  ### One peer, several links
218
183
 
@@ -224,8 +189,7 @@ A peer holds one link per transport, and the two kinds are not interchangeable:
224
189
  | `{ connect: url }` | **opens** exactly one, to that url |
225
190
  | `{ brokerurl }` | opens one, to that broker |
226
191
 
227
- A socket cannot both accept and dial, so a Node service that serves browsers *and* joins a bus
228
- genuinely holds two:
192
+ A socket cannot both accept and dial, so a Node service that serves browsers *and* joins a bus genuinely holds two:
229
193
 
230
194
  ```
231
195
  browsers ──▶ :8080 ┐
@@ -233,21 +197,16 @@ browsers ──▶ :8080 ┐
233
197
 
234
198
  ```
235
199
 
236
- But **every link carries traffic both ways**. `proxy()` picks whichever transport reaches the
237
- target, so:
200
+ But **every link carries traffic both ways**. `proxy()` picks whichever transport reaches the target, so:
238
201
 
239
- - calling a browser that dialled in costs no new connection — the frame goes back down the socket
240
- that browser opened;
202
+ - calling a browser that dialled in costs no new connection — the frame goes back down the socket that browser opened;
241
203
  - calling a peer on the bus goes out over the link `nodeSrv` already holds.
242
204
 
243
- Which is why a peer that both serves and calls needs no `RpcClient` at all. Adding one to do the
244
- calling would open a *third* connection and put a second name on the network for what is really one
245
- program — and over MQTT, that means a second broker session too.
205
+ Which is why a peer that both serves and calls needs no `RpcClient` at all. Adding one to do the calling would open a *third* connection and put a second name on the network for what is really one program — and over MQTT, that means a second broker session too.
246
206
 
247
207
  ### Serving over a connection you open
248
208
 
249
- A browser cannot listen, so a page that wants to *host* a service has to dial out. `connect` gives
250
- an `RpcServer` an outbound link, and it serves over it exactly as it would over one it accepted:
209
+ A browser cannot listen, so a page that wants to *host* a service has to dial out. `connect` gives an `RpcServer` an outbound link, and it serves over it exactly as it would over one it accepted:
251
210
 
252
211
  ```typescript
253
212
  const panel = new RpcServer({ name: 'cellPanel', transports: [{ connect: 'https://hub.plant' }] })
@@ -258,53 +217,32 @@ Whatever it connects to relays calls to it — see [Discovery](#discovery).
258
217
 
259
218
  ### Names and targets
260
219
 
261
- Every peer has a `name`. A server's name is how callers address it; a client's name is how the
262
- server routes events back and, when authenticating, who it is.
220
+ Every peer has a `name`. A server's name is how callers address it; a client's name is how the server routes events back and, when authenticating, who it is.
263
221
 
264
- Over a single socket there is one server, so the default target `'*'` finds it and names can be left
265
- alone. Over a broker there are many, so a caller has to say which — either once, with
266
- `defaultTarget`, or per proxy:
222
+ Over a single socket there is one server, so the default target `'*'` finds it and names can be left alone. Over a broker there are many, so a caller has to say which — either once, with `defaultTarget`, or per proxy:
267
223
 
268
224
  ```typescript
269
225
  const plant = await client.proxy<Plant>('plant', 'plantServer')
270
226
  ```
271
227
 
272
- Client names must be unique among the peers sharing a server; the default is a UUID, which is unique
273
- but tells you nothing in a log. Over MQTT a name is also the broker client id, and a broker allows
274
- one connection per id, so two peers sharing a name disconnect each other in a loop.
228
+ Client names must be unique among the peers sharing a server; the default is three words - `brisk-otter-cable` - rather than a UUID, because a name is also a log line, a peer-list entry and an MQTT client id, and a UUID tells you nothing in any of them. Over MQTT a name is also the broker client id, and a broker allows one connection per id, so two peers sharing a name disconnect each other in a loop.
275
229
 
276
230
  ### Discovery
277
231
 
278
- Every peer announces its name when it connects, and is told who else is there. The events are the
279
- same on both transports, so code that watches a network does not care which one it is on:
232
+ Every peer announces its name when it connects, and is told who else is there. The events are the same on both transports, so code that watches a network does not care which one it is on:
280
233
 
281
234
  ```typescript
282
235
  transport.on(TransportEvent.peerOnline, (peer) => console.log(peer, 'is up'))
283
236
  transport.on(TransportEvent.peerGone, (peer) => console.log(peer, 'is gone'))
284
237
  ```
285
238
 
286
- Over MQTT this is retained presence: subscribing to `<prefix>/presence/+` hands over everyone
287
- already online, and a last will covers a peer that dies rather than leaves. Over socket.io the
288
- server keeps the list and sends it to each peer that announces itself.
289
-
290
- **A name is an address, so two peers must not share one.** Both transports report a collision as
291
- `TransportEvent.peerDisplaced`, and warn once. The newcomer takes the address either way: a peer
292
- reconnecting after a blip announces itself while the old connection may still look live, and
293
- refusing it would lock a peer out of its own name. What the event is for is the other case — two
294
- peers genuinely running under one name send each other's replies into the wrong place, which reads
295
- as calls timing out for no reason and is close to undiagnosable if nothing says so.
296
-
297
- Which end finds out differs, because the two protocols enforce it in different places. Over
298
- socket.io the **server** sees a second connection announce a name it already holds. Over MQTT there
299
- is no server in the middle and nothing has to detect anything: the client id is derived from the
300
- peer name, so the broker hands the session over and tells the **displaced peer** why, with reason
301
- code `0x8E` — which needs MQTT 5, since 3.1.1 has no reason codes and the connection simply closes.
302
-
303
- **A server relays for the peers connected to it.** A frame addressed to another peer it can see is
304
- forwarded rather than executed locally, which is what makes a peer that can only dial out reachable
305
- at all. A server holding both a socket.io listener and a broker connection therefore bridges them:
306
- a browser peer discovers a peer that exists only on the broker, and calls it, with the call arriving
307
- under the browser peer's own name rather than the bridge's.
239
+ Over MQTT this is retained presence: subscribing to `<prefix>/presence/+` hands over everyone already online, and a last will covers a peer that dies rather than leaves. Over socket.io the server keeps the list and sends it to each peer that announces itself.
240
+
241
+ **A name is an address, so two peers must not share one.** Both transports report a collision as `TransportEvent.peerDisplaced`, and warn once. The newcomer takes the address either way: a peer reconnecting after a blip announces itself while the old connection may still look live, and refusing it would lock a peer out of its own name. What the event is for is the other case — two peers genuinely running under one name send each other's replies into the wrong place, which reads as calls timing out for no reason and is close to undiagnosable if nothing says so.
242
+
243
+ Which end finds out differs, because the two protocols enforce it in different places. Over socket.io the **server** sees a second connection announce a name it already holds. Over MQTT there is no server in the middle and nothing has to detect anything: the client id is derived from the peer name, so the broker hands the session over and tells the **displaced peer** why, with reason code `0x8E` — which needs MQTT 5, since 3.1.1 has no reason codes and the connection simply closes.
244
+
245
+ **A server relays for the peers connected to it.** A frame addressed to another peer it can see is forwarded rather than executed locally, which is what makes a peer that can only dial out reachable at all. A server holding both a socket.io listener and a broker connection therefore bridges them: a browser peer discovers a peer that exists only on the broker, and calls it, with the call arriving under the browser peer's own name rather than the bridge's.
308
246
 
309
247
  ```typescript
310
248
  const bridge = new RpcServer({
@@ -324,65 +262,40 @@ const hub = new RpcServer({
324
262
  })
325
263
  ```
326
264
 
327
- The rule is asked once per pair of peers, and the answer covers the traffic going back — a call has
328
- a reply and usually events after it, and a rule written about the caller would otherwise strand
329
- them. Without `authenticate`, a relaying server prints a warning the first time it forwards
330
- anything: `source` is a claim until a connection vouches for it, so it passes on whatever it is
331
- told.
265
+ The rule is asked once per pair of peers, and the answer covers the traffic going back — a call has a reply and usually events after it, and a rule written about the caller would otherwise strand them. Without `authenticate`, a relaying server prints a warning the first time it forwards anything: `source` is a claim until a connection vouches for it, so it passes on whatever it is told.
332
266
 
333
267
  ### More than one hop
334
268
 
335
- A peer announces not only its own name but the peers reachable **through** it, so a server that is a
336
- hub for its own peers and a member of a bus makes both sides visible to each other:
269
+ A peer announces not only its own name but the peers reachable **through** it, so a server that is a hub for its own peers and a member of a bus makes both sides visible to each other:
337
270
 
338
271
  ```
339
272
  panel1 ── cellCtl ── bus ── hmi
340
273
  ```
341
274
 
342
- `cellCtl` advertises `panel1` upwards; the bus routes to `panel1` by handing frames to `cellCtl`,
343
- which passes them inwards. Calls, replies and events all traverse it, and `panel1` leaving
344
- propagates the same way. Verified to three hops.
275
+ `cellCtl` advertises `panel1` upwards; the bus routes to `panel1` by handing frames to `cellCtl`, which passes them inwards. Calls, replies and events all traverse it, and `panel1` leaving propagates the same way. Covered by a test at two hops.
345
276
 
346
277
  Two rules keep that from eating itself:
347
278
 
348
- - **Split horizon.** A peer is never advertised back along the link it was learned from, and the
349
- list a server hands a newly connected peer excludes whatever that peer reaches for it. Without
350
- either, two hubs each conclude the other is the way to a peer and it disappears from everyone
351
- further out.
352
- - **A hop limit.** Frames carry a count and are dropped after 8 relays. Split horizon keeps a tree's
353
- tables loop-free, but a mesh that has just lost a link can hold a cycle until the tables settle,
354
- and a frame going round one never stops on its own.
279
+ - **Split horizon.** A peer is never advertised back along the link it was learned from, and the list a server hands a newly connected peer excludes whatever that peer reaches for it. Without either, two hubs each conclude the other is the way to a peer and it disappears from everyone further out.
280
+ - **A hop limit.** Frames carry a count and are dropped after 8 relays. Split horizon keeps a tree's tables loop-free, but a mesh that has just lost a link can hold a cycle until the tables settle, and a frame going round one never stops on its own.
355
281
 
356
- A peer offered by two links keeps the first; the second is remembered, and used if the first goes
357
- away. A peer announcing *itself* always wins over one merely carried.
282
+ A peer offered by two links keeps the first; the second is remembered, and used if the first goes away. A peer announcing *itself* always wins over one merely carried.
358
283
 
359
- **What relaying is not.** It does not make a server a broker in the MQTT sense. There is no
360
- store-and-forward, no queueing for a peer that is not connected, and no fan-out — a frame is passed
361
- to one peer that is there now, or reported as `unroutable`. Discovery is not a routing protocol
362
- either: there are no metrics and no shortest path, only reachability.
284
+ **What relaying is not.** It does not make a server a broker in the MQTT sense. There is no store-and-forward, no queueing for a peer that is not connected, and no fan-out — a frame is passed to one peer that is there now, or refused - answered with a `TransportError` back down the link it arrived on, and reported as `unroutable` here. Refused rather than dropped, because a caller only ever saw a drop as an unexplained timeout. Discovery is not a routing protocol either: there are no metrics and no shortest path, only reachability.
363
285
 
364
286
  ### Ready and close
365
287
 
366
- `await server.ready()` and `await client.ready()` resolve when every transport is connected, and
367
- throw after `readyTimeout` (30 s; `0` waits forever) rather than hanging with no diagnostic. Expose
368
- your instances *before* `ready()`: a resumed MQTT session is handed its queued requests the moment it
369
- connects.
288
+ `await server.ready()` and `await client.ready()` resolve when every transport is connected, and throw after `readyTimeout` (30 s; `0` waits forever) rather than hanging with no diagnostic. Expose your instances *before* `ready()`: a resumed MQTT session is handed its queued requests the moment it connects.
370
289
 
371
- `await client.close()` rejects any in-flight calls at once instead of leaving them to time out, and
372
- forgets the subscriptions it held. `await server.close()` closes every transport, which is what
373
- tells the peers on the other side that it went away.
290
+ `await client.close()` rejects any in-flight calls at once instead of leaving them to time out, and forgets the subscriptions it held. `await server.close()` closes every transport, which is what tells the peers on the other side that it went away.
374
291
 
375
292
  ### Encoding
376
293
 
377
- MsgPack by default, so `Uint8Array` and `Date` cross the wire as themselves rather than as string
378
- encodings of themselves. `useMsgPack: false` selects JSON on both sides — readable in a broker
379
- inspector, at the cost of those two types. Both ends must agree.
294
+ MsgPack by default, so `Uint8Array` and `Date` cross the wire as themselves rather than as string encodings of themselves. `useMsgPack: false` selects JSON on both sides — readable in a broker inspector, at the cost of those two types. Both ends must agree.
380
295
 
381
296
  ## Exposing methods
382
297
 
383
- `exposeClassInstance` walks the prototype chain and publishes every function it finds, so a helper
384
- a class never meant to offer becomes callable by anyone who can reach the transport. Marking the
385
- intended methods turns that into an allow-list.
298
+ `exposeClassInstance` walks the prototype chain and publishes every function it finds, so a helper a class never meant to offer becomes callable by anyone who can reach the transport. Marking the intended methods turns that into an allow-list.
386
299
 
387
300
  ```typescript
388
301
  import { rpc, rpcNamespace } from '@source-repo/rpc'
@@ -397,20 +310,13 @@ class Plant {
397
310
  server.exposeClassInstance(new Plant()) // name taken from @rpcNamespace
398
311
  ```
399
312
 
400
- Standard ECMAScript decorators, so no `experimentalDecorators` is needed. Marks are inherited, so a
401
- subclass keeps its parent's. Without decorators, `exposeMethods(Plant, ['writeSetpoint'])` does the
402
- same and rejects names that are not methods.
313
+ Standard ECMAScript decorators, so no `experimentalDecorators` is needed. Marks are inherited, so a subclass keeps its parent's. Without decorators, `exposeMethods(Plant, ['writeSetpoint'])` does the same and rejects names that are not methods.
403
314
 
404
- A class that marks nothing publishes every method on its prototype chain, which is what makes the
405
- plain style above work. Set `requireExplicitExposure` on `RpcServer` to refuse such a class instead,
406
- which makes the discipline enforceable across a project.
315
+ A class that marks nothing publishes every method on its prototype chain, which is what makes the plain style above work. Set `requireExplicitExposure` on `RpcServer` to refuse such a class instead, which makes the discipline enforceable across a project.
407
316
 
408
- `@rpcNamespace` also tells the extraction CLI which namespace a class belongs to, since the name
409
- would otherwise exist only at the `exposeClassInstance` call site.
317
+ `@rpcNamespace` also tells the extraction CLI which namespace a class belongs to, since the name would otherwise exist only at the `exposeClassInstance` call site.
410
318
 
411
- Both decorators take options: `@rpc({ semantics: 'non-repeatable-command' })` says what calling a
412
- method does to the world, and `@rpcNamespace('cell', { execution: 'serial' })` says whether calls
413
- into the instance may overlap. Both are in [Commands](#commands), which is where they matter.
319
+ Both decorators take options: `@rpc({ semantics: 'non-repeatable-command' })` says what calling a method does to the world, and `@rpcNamespace('cell', { execution: 'serial' })` says whether calls into the instance may overlap. Both are in [Commands](#commands), which is where they matter.
414
320
 
415
321
  ### More than one, and without a class
416
322
 
@@ -425,62 +331,17 @@ const plant = await client.proxy<Plant>('plant')
425
331
  const history = await client.proxy<History>('history')
426
332
  ```
427
333
 
428
- `exposeObject` publishes an object's own function properties rather than a prototype chain, which
429
- suits a handful of functions that never wanted to be a class.
430
-
431
- ## Errors
432
-
433
- A call rejects with an `RpcError` carrying a `code`, the remote `message`, and the remote stack in
434
- `remoteStack` when the peer sent one.
435
-
436
- ```typescript
437
- import { RpcError } from '@source-repo/rpc'
438
-
439
- try {
440
- await calculator.remote!.square(3)
441
- } catch (e) {
442
- if (e instanceof RpcError) console.log(e.code, e.message, e.remoteStack)
443
- }
444
- ```
445
-
446
- | code | meaning |
447
- | --- | --- |
448
- | `Exception` | the exposed method threw |
449
- | `MethodNotFound` | the instance exists but the method is not exposed |
450
- | `ClassNotFound` | nothing is exposed under that name |
451
- | `Timeout` | no response within `callTimeout`, or the server refused to run it that late |
452
- | `TransportError` | the link dropped, or the message could not be encoded or sent |
453
- | `Unauthorized` | the caller is not authenticated and the server requires it |
454
- | `Forbidden` | the caller is authenticated but not permitted this call |
455
- | `InvalidParams` | the arguments do not match the schema for that method |
456
- | `IncompatibleVersion` | the caller's contract cannot be served by this one |
457
- | `UnknownOutcome` | the request was sent and its fate is not known - it may have run |
458
-
459
- **A call that timed out will not run afterwards.** Every request carries the time its caller will
460
- still wait, so a server that reaches the method late answers `Timeout` instead of running it, and an
461
- MQTT broker is given the same deadline as its message expiry rather than a longer one of its own.
462
- Without that, a request queued for a restarting server arrives after the operator has already been
463
- told the call failed and acted on it - which for a read is wasted work and for `start pump` is a
464
- machine moving when nobody expects it to.
465
-
466
- It is a duration on the wire rather than a moment, so no two peers ever have to agree what time it
467
- is - a browser page's clock belongs to whoever is sitting at it. `refuseExpiredCalls` on the server
468
- handler turns the refusal off.
334
+ `exposeObject` publishes an object's own function properties rather than a prototype chain, which suits a handful of functions that never wanted to be a class.
469
335
 
470
336
  ## Commands
471
337
 
472
- Most RPC libraries make it easy to call a function. Rather fewer distinguish *the call failed* from
473
- *I lost the answer to a command that may well have run*, and on a plant that is the distinction that
474
- matters: retrying a read costs a round trip, and retrying a start costs a second start.
338
+ Most RPC libraries make it easy to call a function. Rather fewer distinguish *the call failed* from *I lost the answer to a command that may well have run*, and on a plant that is the distinction that matters: retrying a read costs a round trip, and retrying a start costs a second start.
475
339
 
476
340
  ### The honest statement
477
341
 
478
- > **Delivery and execution are at least once, unless the method is guarded by a durable idempotency
479
- > store.**
342
+ > **Delivery and execution are at least once, unless the method is guarded by a durable idempotency store.**
480
343
 
481
- That is true of every RPC system without such a store; the difference is whether it is written down.
482
- QoS 1 is at-least-once by definition, the in-memory duplicate cache dies with the process that holds
483
- it, and a server can change something physical and then fail before it says so.
344
+ That is true of every RPC system without such a store; the difference is whether it is written down. QoS 1 is at-least-once by definition, the in-memory duplicate cache dies with the process that holds it, and a server can change something physical and then fail before it says so.
484
345
 
485
346
  ### What a method does to the world
486
347
 
@@ -498,10 +359,7 @@ class Pump {
498
359
  | `idempotent-command` | leaves the same state as doing it once | `setSetpoint(1200)`, `close()` |
499
360
  | `non-repeatable-command` | does it again | `dispense()`, `advanceBatch()` |
500
361
 
501
- It is part of the contract, not a comment: `extract` reads it out of the decorator, `describe()`
502
- reports it, and `check` calls it a **breaking change** when a method becomes more dangerous to
503
- repeat than the version a caller was built against. Every type still lines up in that case, which is
504
- exactly why nothing else would catch it.
362
+ It is part of the contract, not a comment: `extract` reads it out of the decorator, `describe()` reports it, and `check` calls it a **breaking change** when a method becomes more dangerous to repeat than the version a caller was built against. Every type still lines up in that case, which is exactly why nothing else would catch it.
505
363
 
506
364
  Undeclared stays undeclared. The library will not guess that a method is safe to repeat.
507
365
 
@@ -517,52 +375,37 @@ try {
517
375
  }
518
376
  ```
519
377
 
520
- `TransportError` now means the request never left - a failed encode, a closed link, a broker that
521
- refused the publish - so the command certainly did not run. `UnknownOutcome` means it did leave and
522
- nothing came back. `Timeout` is the same uncertainty with a more specific cause, and should be read
523
- the same way for a command.
378
+ `TransportError` now means the request never left - a failed encode, a closed link, a broker that refused the publish - so the command certainly did not run. `UnknownOutcome` means it did leave and nothing came back. `Timeout` is the same uncertainty with a more specific cause, and should be read the same way for a command.
524
379
 
525
380
  ### Running a command once
526
381
 
527
- Give a server somewhere durable to record what a non-repeatable command did, and a redelivery after
528
- a crash is answered from the record instead of run again:
382
+ Give a server somewhere durable to record what a non-repeatable command did, and a redelivery after a crash is answered from the record instead of run again:
529
383
 
530
384
  ```typescript
531
385
  const server = new RpcServer({ idempotency: myRedisStore }) // RpcIdempotencyStore
532
386
  ```
533
387
 
534
- The library ships the interface and a `MemoryIdempotencyStore` for tests - deliberately no database,
535
- and the memory one is not an answer to the problem, since it dies exactly when the durable one would
536
- earn its keep.
388
+ The library ships the interface and a `MemoryIdempotencyStore` for tests - deliberately no database, and the memory one is not an answer to the problem, since it dies exactly when the durable one would earn its keep.
537
389
 
538
- The store is consulted only for `non-repeatable-command` methods, so reads pay nothing. What it
539
- records is keyed by the **request id**, which makes a redelivered packet the same command. An
540
- operator pressing the button again is a *different* request, and only the caller knows the two are
541
- one intent:
390
+ The store is consulted only for `non-repeatable-command` methods, so reads pay nothing. What it records is keyed by the **request id**, which makes a redelivered packet the same command. An operator pressing the button again is a *different* request, and only the caller knows the two are one intent:
542
391
 
543
392
  ```typescript
544
393
  await pump.remote!.$with({ idempotencyKey: workOrder }).dispense()
545
394
  ```
546
395
 
547
- `$with` returns another proxy for the same instance, so the key never leaks into calls that did not
548
- ask for it. The outcome is recorded **before** the answer is sent - the other order leaves a window
549
- where the caller has the result and the store does not.
396
+ `$with` returns another proxy for the same instance, so the key never leaks into calls that did not ask for it. The outcome is recorded **before** the answer is sent - the other order leaves a window where the caller has the result and the store does not.
550
397
 
551
- A store that cannot be reached refuses the command with `UnknownOutcome` rather than running it.
552
- Failing open would turn an unreachable guard into exactly the double execution it was installed to
553
- prevent.
398
+ A store that cannot be reached refuses the command with `UnknownOutcome` rather than running it. Failing open would turn an unreachable guard into exactly the double execution it was installed to prevent.
554
399
 
555
400
  ### Calls that overlap
556
401
 
557
- Calls run side by side, which is right for stateless services and unrelated devices, and wrong for
558
- one long-lived object holding mutable state - where
402
+ Calls run side by side, which is right for stateless services and unrelated devices, and wrong for one long-lived object holding mutable state - where
559
403
 
560
404
  ```
561
405
  setMode('manual'); start(); setSetpoint(80)
562
406
  ```
563
407
 
564
- from one caller can interleave with `stop(); setMode('automatic')` from another and leave a machine
565
- in a combination neither asked for.
408
+ from one caller can interleave with `stop(); setMode('automatic')` from another and leave a machine in a combination neither asked for.
566
409
 
567
410
  ```typescript
568
411
  @rpcNamespace('cell', { execution: 'serial' }) // one call at a time
@@ -573,29 +416,60 @@ server.exposeClassInstance(fleet, 'fleet', { // one call a
573
416
  })
574
417
  ```
575
418
 
576
- A key function is how a server fronting many devices keeps each device's commands in order without
577
- serialising itself behind the slowest of them.
419
+ A key function is how a server fronting many devices keeps each device's commands in order without serialising itself behind the slowest of them.
578
420
 
579
- **`parallel` stays the default**, despite `serial` being the safer-sounding choice. A serial
580
- instance that calls back into itself over RPC deadlocks - the second call queues behind the first,
581
- which is waiting for it - so serialising by default would break re-entrant designs that work today,
582
- silently and only under load. It is one line to opt in, and the class that needs it knows.
421
+ **`parallel` stays the default**, despite `serial` being the safer-sounding choice. A serial instance that calls back into itself over RPC deadlocks - the second call queues behind the first, which is waiting for it - so serialising by default would break re-entrant designs that work today, silently and only under load. It is one line to opt in, and the class that needs it knows.
583
422
 
584
- The queue is also where the deadline is read: a command that waited behind others until its caller
585
- gave up is refused rather than run late.
423
+ The queue is also where the deadline is read: a command that waited behind others until its caller gave up is refused rather than run late.
586
424
 
587
425
  ### Not built
588
426
 
589
- - **Cancellation.** A deadline bounds a call, but nothing tells a running method to stop. Doing it
590
- properly needs a cancel frame *and* handler cooperation, and a library cannot supply the second.
591
- - **`online-only` delivery.** Now that the broker's expiry is the caller's own timeout, a request
592
- for an absent peer already dies when the caller stops waiting; failing immediately instead would
593
- save the wait and little else.
594
- - **A per-call invocation context.** Handlers are plain methods, and threading a context through
595
- every signature costs more than it returns. The store gets the full invocation; a method that
596
- needs its own idempotency has the arguments it was called with.
597
- - **Global admission limits.** Concurrency caps and message-size bounds are about availability
598
- rather than correctness, and belong with a production profile.
427
+ - **Cancellation.** A deadline bounds a call, but nothing tells a running method to stop. Doing it properly needs a cancel frame *and* handler cooperation, and a library cannot supply the second.
428
+ - **`online-only` delivery.** Now that the broker's expiry is the caller's own timeout, a request for an absent peer already dies when the caller stops waiting; failing immediately instead would save the wait and little else.
429
+ - **A per-call invocation context.** Handlers are plain methods, and threading a context through every signature costs more than it returns. The store gets the full invocation; a method that needs its own idempotency has the arguments it was called with.
430
+ - **Global admission limits.** Concurrency caps and message-size bounds are about availability rather than correctness, and belong with a production profile.
431
+
432
+ ## Errors
433
+
434
+ A call rejects with an `RpcError` carrying a `code`, the remote `message`, and the remote stack in `remoteStack` when the peer sent one.
435
+
436
+ ```typescript
437
+ import { RpcError } from '@source-repo/rpc'
438
+
439
+ try {
440
+ await calculator.remote!.square(3)
441
+ } catch (e) {
442
+ if (e instanceof RpcError) console.log(e.code, e.message, e.remoteStack)
443
+ }
444
+ ```
445
+
446
+ | code | meaning |
447
+ | --- | --- |
448
+ | `Exception` | the exposed method threw |
449
+ | `MethodNotFound` | the instance exists but the method is not exposed |
450
+ | `ClassNotFound` | nothing is exposed under that name |
451
+ | `Timeout` | no response within `callTimeout`, or the server refused to run it that late |
452
+ | `TransportError` | the link dropped, or the message could not be encoded or sent |
453
+ | `Unauthorized` | the caller is not authenticated and the server requires it |
454
+ | `Forbidden` | the caller is authenticated but not permitted this call |
455
+ | `InvalidParams` | the arguments do not match the schema for that method |
456
+ | `IncompatibleVersion` | the caller's contract cannot be served by this one |
457
+ | `UnknownOutcome` | the request was sent and its fate is not known - it may have run |
458
+
459
+ **A method can choose its own code.** Throwing an error whose `code` is one of the nine above — `Unauthorized`, `Forbidden`, `InvalidParams`, `IncompatibleVersion`, `ClassNotFound`, `MethodNotFound`, `TransportError`, `Timeout`, `UnknownOutcome` — sends that code rather than `Exception`:
460
+
461
+ ```typescript
462
+ @rpc async writeSetpoint(value: number) {
463
+ if (!this.permitted) throw Object.assign(new Error('this cell is in local mode'), { code: 'Forbidden' })
464
+ if (await this.downstream.timedOut()) throw Object.assign(new Error('the drive did not answer'), { code: 'UnknownOutcome' })
465
+ }
466
+ ```
467
+
468
+ The list is an allow-list, so an ordinary Node error carrying `code: 'ENOENT'` stays `Exception` rather than becoming a protocol code that means something else. `UnknownOutcome` is the one worth reaching for: it is the honest reply from a gateway whose own downstream call was lost, where inventing a result and reporting a plain exception would both claim more than is known.
469
+
470
+ **A call that timed out will not run afterwards.** Every request carries the time its caller will still wait, so a server that reaches the method late answers `Timeout` instead of running it, and an MQTT broker is given the same deadline as its message expiry rather than a longer one of its own. Without that, a request queued for a restarting server arrives after the operator has already been told the call failed and acted on it - which for a read is wasted work and for `start pump` is a machine moving when nobody expects it to.
471
+
472
+ It is a duration on the wire rather than a moment, so no two peers ever have to agree what time it is - a browser page's clock belongs to whoever is sitting at it. `refuseExpiredCalls` on the server handler turns the refusal off.
599
473
 
600
474
  ## Events and reconnection
601
475
 
@@ -607,8 +481,7 @@ await plant.remote!.on('alarm', (message: string) => console.log(message))
607
481
  await plant.remote!.off('alarm', handler) // same handler reference
608
482
  ```
609
483
 
610
- `RpcClient` is itself an `EventEmitter` reporting the state of its link, so an application can show
611
- it rather than infer it from failed calls:
484
+ `RpcClient` is itself an `EventEmitter` reporting the state of its link, so an application can show it rather than infer it from failed calls:
612
485
 
613
486
  ```typescript
614
487
  import { TransportEvent } from '@source-repo/rpc'
@@ -621,22 +494,15 @@ client.on(TransportEvent.connected, ({ restoredSubscriptions }) =>
621
494
  Reconnection is handled for you:
622
495
 
623
496
  - The underlying transport reconnects on its own (socket.io and mqtt.js both do).
624
- - On every reconnect the client replays its event subscriptions. This restores server-side state if
625
- the server restarted, and re-identifies the client so pushed events reach it again.
626
- - Replaying is idempotent: the server will not stack a second listener for a subscription it already
627
- holds.
497
+ - On every reconnect the client replays its event subscriptions. This restores server-side state if the server restarted, and re-identifies the client so pushed events reach it again.
498
+ - Replaying is idempotent: the server will not stack a second listener for a subscription it already holds.
628
499
  - When a client's connection drops, the server releases the event subscriptions it held for it.
629
- - An event is delivered only to subscriptions taken out on the peer and namespace it came from.
630
- Watching `alarm` on two instances, or on two peers over one MQTT transport, keeps them apart.
631
- - `off()` is not subject to `authorize`: a subscription is keyed by the peer that made it, so a peer
632
- can only drop its own, and refusing to let someone stop receiving events would be strange.
500
+ - An event is delivered only to subscriptions taken out on the peer and namespace it came from. Watching `alarm` on two instances, or on two peers over one MQTT transport, keeps them apart.
501
+ - `off()` is not subject to `authorize`: a subscription is keyed by the peer that made it, so a peer can only drop its own, and refusing to let someone stop receiving events would be strange.
633
502
 
634
503
  ## Checking arguments
635
504
 
636
- Types are a compile-time promise between a client and a server that share a class. Nothing about
637
- MQTT or a browser page guarantees the caller is one of those — a Python historian or a Node-RED flow
638
- calling in over MQTT 5 shares none of your types — so a schema lets the server check what it was
639
- actually sent.
505
+ Types are a compile-time promise between a client and a server that share a class. Nothing about MQTT or a browser page guarantees the caller is one of those — a Python historian or a Node-RED flow calling in over MQTT 5 shares none of your types — so a schema lets the server check what it was actually sent.
640
506
 
641
507
  ```typescript
642
508
  const schema: RpcSchema = {
@@ -654,25 +520,13 @@ const schema: RpcSchema = {
654
520
  const server = new RpcServer({ transports: [{ brokerurl }], schema })
655
521
  ```
656
522
 
657
- A call that does not match is refused with `InvalidParams` before it reaches the method, and the
658
- message names the offending position: `argument 0: expected number, got string (this server serves
659
- plant@3)`.
523
+ A call that does not match is refused with `InvalidParams` before it reaches the method, and the message names the offending position: `argument 0: expected number, got string (this server serves plant@3)`.
660
524
 
661
- `source-rpc extract` writes this file from your source rather than you writing it by hand. Note what it
662
- can and cannot see: `value: number` becomes `{ kind: 'number' }`, because a range like `0..2000` is
663
- a runtime invariant that TypeScript does not carry. Extraction gives you shape checking — types,
664
- arity, whether an argument is required. Bounds have to be added to the schema or expressed in the
665
- type.
525
+ `source-rpc extract` writes this file from your source rather than you writing it by hand. Note what it can and cannot see: `value: number` becomes `{ kind: 'number' }`, because a range like `0..2000` is a runtime invariant that TypeScript does not carry. Extraction gives you shape checking — types, arity, whether an argument is required. Bounds have to be added to the schema or expressed in the type.
666
526
 
667
- The type language is small on purpose. It describes what MsgPack actually carries, so `bytes`
668
- (`Uint8Array`) and `date` are values rather than string encodings, and it is checkable without
669
- pulling a validation engine into a package that ships to browsers and embedded targets. `ref` names
670
- a shared or recursive type; nesting beyond 32 levels is refused rather than exhausting the stack.
527
+ The type language is small on purpose. It describes what MsgPack actually carries, so `bytes` (`Uint8Array`) and `date` are values rather than string encodings, and it is checkable without pulling a validation engine into a package that ships to browsers and embedded targets. `ref` names a shared or recursive type; nesting beyond 32 levels is refused rather than exhausting the stack.
671
528
 
672
- `object` describes a known shape and `record` an open one — `{ [tag: string]: Reading }`, which is
673
- how plant data usually arrives. A record checks every value against one type and leaves the keys
674
- open, or constrains them with `keyPattern`; `maxEntries` bounds it the way `maxItems` bounds an
675
- array, since a dictionary is the other shape a caller can grow without limit.
529
+ `object` describes a known shape and `record` an open one — `{ [tag: string]: Reading }`, which is how plant data usually arrives. A record checks every value against one type and leaves the keys open, or constrains them with `keyPattern`; `maxEntries` bounds it the way `maxItems` bounds an array, since a dictionary is the other shape a caller can grow without limit.
676
530
 
677
531
  ```typescript
678
532
  readings: { params: [], returns: { kind: 'record', values: { kind: 'ref', name: 'Reading' } } }
@@ -685,9 +539,7 @@ readings: { params: [], returns: { kind: 'record', values: { kind: 'ref', name:
685
539
  | `validation: 'off'` | disable checking without removing the schema |
686
540
  | `validateResults` | check what handlers return too; off by default, since it is a self-check |
687
541
 
688
- Set `validate: false` on a namespace to skip a hot path where the cost is not worth paying.
689
- Validating `writeSetpoint(number)` is not the same proposition as validating a ten-thousand element
690
- telemetry array on every publish.
542
+ Set `validate: false` on a namespace to skip a hot path where the cost is not worth paying. Validating `writeSetpoint(number)` is not the same proposition as validating a ten-thousand element telemetry array on every publish.
691
543
 
692
544
  ### Serving older callers
693
545
 
@@ -697,32 +549,20 @@ Give a client the contract it was built against and it declares the version on e
697
549
  const client = new RpcClient(url, { schema: contractTheClientWasBuiltAgainst })
698
550
  ```
699
551
 
700
- The server keeps earlier versions of a namespace under `history`, and compares the caller's contract
701
- with the one it now serves. It is a structural comparison, not an equality check, so a caller whose
702
- contract still holds keeps working and only a genuine incompatibility is refused — with
703
- `IncompatibleVersion` and the reason:
552
+ The server keeps earlier versions of a namespace under `history`, and compares the caller's contract with the one it now serves. It is a structural comparison, not an equality check, so a caller whose contract still holds keeps working and only a genuine incompatibility is refused — with `IncompatibleVersion` and the reason:
704
553
 
705
554
  ```
706
555
  plant@1 is not compatible with plant@2: writeSetpoint argument 0 narrowed, so a value the
707
556
  caller may send is no longer accepted
708
557
  ```
709
558
 
710
- The rule is ordinary function subtyping. **Parameters are contravariant**: the current contract has
711
- to accept everything the old one allowed, so widening a parameter is safe and narrowing it is not.
712
- **Returns are covariant**: everything the current contract can return has to fit what the old caller
713
- expects, so narrowing a return is safe and widening it is not. Adding an optional field or an
714
- optional argument is safe; adding a required one is not. Events run the other way, since the server
715
- emits and the caller receives.
559
+ The rule is ordinary function subtyping. **Parameters are contravariant**: the current contract has to accept everything the old one allowed, so widening a parameter is safe and narrowing it is not. **Returns are covariant**: everything the current contract can return has to fit what the old caller expects, so narrowing a return is safe and widening it is not. Adding an optional field or an optional argument is safe; adding a required one is not. Events run the other way, since the server emits and the caller receives.
716
560
 
717
- The comparison happens once per peer and version, not per call, and is conservative: where it cannot
718
- prove compatibility it reports incompatibility, since a false "safe" is the expensive direction.
561
+ The comparison happens once per peer and version, not per call, and is conservative: where it cannot prove compatibility it reports incompatibility, since a false "safe" is the expensive direction.
719
562
 
720
- A caller that declares nothing is simply not version-checked — only its arguments are. A caller
721
- declaring a version the server has no history for is allowed by default, since truncating history is
722
- a legitimate operational choice; `unknownVersion: 'reject'` refuses it instead.
563
+ A caller that declares nothing is simply not version-checked — only its arguments are. A caller declaring a version the server has no history for is allowed by default, since truncating history is a legitimate operational choice; `unknownVersion: 'reject'` refuses it instead.
723
564
 
724
- `source-rpc check` runs the same comparison at build time, so a change that would refuse a deployed peer
725
- fails the build instead of surfacing when that peer next calls.
565
+ `source-rpc check` runs the same comparison at build time, so a change that would refuse a deployed peer fails the build instead of surfacing when that peer next calls.
726
566
 
727
567
  ## Describing a server
728
568
 
@@ -734,36 +574,21 @@ const server = new RpcServer({ transports: [{ brokerurl }], exposeIntrospection:
734
574
  const described = await (await client.proxy<Introspection>('msgrpc')).remote!.describe()
735
575
  ```
736
576
 
737
- It reports each namespace with its class, its contract version, whether the instance was created at
738
- runtime, its methods with types when a schema describes them, and its events with how many peers are
739
- currently subscribed. `source-rpc console` renders this in a browser.
577
+ It reports each namespace with its class, its contract version, whether the instance was created at runtime, its methods with types when a schema describes them, and its events with how many peers are currently subscribed. `source-rpc console` renders this in a browser.
740
578
 
741
- `describe` describes itself: the `source-rpc` namespace comes with its own contract, so a peer reading a
742
- server sees the type it will get back, and `validation: 'required'` does not refuse the one call
743
- made to find out what is there. Its named types are prefixed — `msgrpc.ServerDescription` — because
744
- the schema has one type map shared by every namespace, and a plant defining its own `TypeNode`
745
- should not find `describe()` described against it. A schema that already defines `source-rpc` is left
746
- untouched.
579
+ `describe` describes itself: the `msgrpc` namespace comes with its own contract, so a peer reading a server sees the type it will get back, and `validation: 'required'` does not refuse the one call made to find out what is there. Its named types are prefixed — `msgrpc.ServerDescription` — because the schema has one type map shared by every namespace, and a plant defining its own `TypeNode` should not find `describe()` described against it. A schema that already defines `msgrpc` is left untouched.
747
580
 
748
- **Off by default, and subject to `authorize` like any other call.** Listing every class, method and
749
- live instance is reconnaissance, and instance names on a plant network tend to encode plant
750
- structure.
581
+ **Off by default, and subject to `authorize` like any other call.** Listing every class, method and live instance is reconnaissance, and instance names on a plant network tend to encode plant structure.
751
582
 
752
- This is msgrpc's own shape rather than a borrowed one. OpenAPI is HTTP-shaped and cannot describe a
753
- server pushing events; AsyncAPI models everything as a channel, which fights an RPC surface. Either
754
- would mean describing this system in someone else's concepts.
583
+ This is msgrpc's own shape rather than a borrowed one. OpenAPI is HTTP-shaped and cannot describe a server pushing events; AsyncAPI models everything as a channel, which fights an RPC surface. Either would mean describing this system in someone else's concepts.
755
584
 
756
585
  ## Authentication and authorization
757
586
 
758
- Both are off by default, so an unconfigured server accepts any peer and allows any exposed call. The
759
- management surface is *not* off by default in the same sense — it is simply never published unless
760
- asked for. See below.
587
+ Both are off by default, so an unconfigured server accepts any peer and allows any exposed call. The management surface is *not* off by default in the same sense — it is simply never published unless asked for. See below.
761
588
 
762
589
  ### Authenticating peers
763
590
 
764
- `authenticate` receives whatever the client sent as `credentials` and returns an identity to accept
765
- the peer, or `undefined` to reject it. Rejected peers never reach the RPC layer — the check runs as
766
- socket.io middleware, before the connection is established at all.
591
+ `authenticate` receives whatever the client sent as `credentials` and returns an identity to accept the peer, or `undefined` to reject it. Rejected peers never reach the RPC layer — the check runs as socket.io middleware, before the connection is established at all.
767
592
 
768
593
  ```typescript
769
594
  const server = new RpcServer({
@@ -780,20 +605,13 @@ const client = new RpcClient('http://localhost:7843', {
780
605
  })
781
606
  ```
782
607
 
783
- **`RpcClientOptions.name` must match `RpcIdentity.name`.** The `source` field of a message is written
784
- by the sender, so it is a claim, not evidence. An authenticating transport pins each connection to
785
- the name it authenticated as and drops frames claiming any other source. Without that, an
786
- authenticated peer could address its calls as another peer and inherit its rights.
608
+ **`RpcClientOptions.name` must match `RpcIdentity.name`.** The `source` field of a message is written by the sender, so it is a claim, not evidence. An authenticating transport pins each connection to the name it authenticated as and drops frames claiming any other source. Without that, an authenticated peer could address its calls as another peer and inherit its rights.
787
609
 
788
- It pins the peer *registry* to the same rule. A frame's source is normally learned as it is parsed,
789
- which is how discovery works over MQTT — the broker is the authority there and there is no
790
- connection to check. Where there is one, a name is registered only once the connection has been
791
- checked, so a rejected frame cannot leave a peer that does not exist in the routing table.
610
+ It pins the peer *registry* to the same rule. A frame's source is normally learned as it is parsed, which is how discovery works over MQTT — the broker is the authority there and there is no connection to check. Where there is one, a name is registered only once the connection has been checked, so a rejected frame cannot leave a peer that does not exist in the routing table.
792
611
 
793
612
  ### Tokens, without writing the authenticator
794
613
 
795
- `createTokenAuthenticator` is the common case packaged: a map from bearer token to the peer it
796
- admits.
614
+ `createTokenAuthenticator` is the common case packaged: a map from bearer token to the peer it admits.
797
615
 
798
616
  ```typescript
799
617
  import { createTokenAuthenticator, defaultWebSocketPort, RpcServer } from '@source-repo/rpc'
@@ -807,20 +625,13 @@ const server = new RpcServer({
807
625
  })
808
626
  ```
809
627
 
810
- **One token per peer, not one token for the bus.** A token that maps to a name is evidence of who is
811
- calling, and the rule above then does the rest: a holder that connects under any other name gets a
812
- socket and nothing else — its announcement is refused, so it is never listed, and its frames are
813
- dropped. A single token shared by everyone proves only that the caller is inside the fence, and any
814
- holder could then claim to be the peer whose commands matter. There is deliberately no
815
- single-secret form.
628
+ **One token per peer, not one token for the bus.** A token that maps to a name is evidence of who is calling, and the rule above then does the rest: a holder that connects under any other name gets a socket and nothing else — its announcement is refused, so it is never listed, and its frames are dropped. A single token shared by everyone proves only that the caller is inside the fence, and any holder could then claim to be the peer whose commands matter. There is deliberately no single-secret form.
816
629
 
817
- Blank tokens, grants with no name and an empty map all throw rather than construct, because each one
818
- would quietly admit more than it looks like it does.
630
+ Blank tokens, grants with no name and an empty map all throw rather than construct, because each one would quietly admit more than it looks like it does.
819
631
 
820
632
  ### Authorizing calls
821
633
 
822
- `authorize` runs for every call and every event subscription. Return false to reject with a
823
- `Forbidden` error.
634
+ `authorize` runs for every call and every event subscription. Return false to reject with a `Forbidden` error.
824
635
 
825
636
  ```typescript
826
637
  const server = new RpcServer({
@@ -834,33 +645,25 @@ const server = new RpcServer({
834
645
  })
835
646
  ```
836
647
 
837
- An authorizer that throws denies the call. Failing open would turn a bug in the authorizer into an
838
- access-control bypass.
648
+ An authorizer that throws denies the call. Failing open would turn a bug in the authorizer into an access-control bypass.
839
649
 
840
- `requireAuthenticatedPeers` defaults to true when `authenticate` is set, rejecting calls from peers
841
- no transport can vouch for with an `Unauthorized` error.
650
+ `requireAuthenticatedPeers` defaults to true when `authenticate` is set, rejecting calls from peers no transport can vouch for with an `Unauthorized` error.
842
651
 
843
652
  ### The management surface
844
653
 
845
- `manageRpc` is **not exposed by default**. Enabling it publishes exactly one method,
846
- `createRpcInstance`, which constructs an instance of a class already passed to `exposeClass()`:
654
+ `manageRpc` is **not exposed by default**. Enabling it publishes exactly one method, `createRpcInstance`, which constructs an instance of a class already passed to `exposeClass()`:
847
655
 
848
656
  ```typescript
849
657
  const server = new RpcServer({ transports: [{ port: 7843 }], exposeManagement: true })
850
658
  ```
851
659
 
852
- It is still subject to `authorize`, so you can restrict who may create instances. The `expose*`
853
- methods are never remotely reachable.
660
+ It is still subject to `authorize`, so you can restrict who may create instances. The `expose*` methods are never remotely reachable.
854
661
 
855
- > Versions before 2.0.0 published all of `ManageRpc` under `manageRpc` with no authentication, so
856
- > any peer that could reach the transport could construct any `exposeClass`'d class with chosen
857
- > arguments, or overwrite an exposed name and deny service to every other client. If you are
858
- > upgrading, treat both as having been reachable.
662
+ > Versions before 2.0.0 published all of `ManageRpc` under `manageRpc` with no authentication, so any peer that could reach the transport could construct any `exposeClass`'d class with chosen arguments, or overwrite an exposed name and deny service to every other client. If you are upgrading, treat both as having been reachable.
859
663
 
860
664
  ## MQTT
861
665
 
862
- Servers and clients addressed by name over a broker, which is how a network of them is usually put
863
- together. The different servers are addressed by their `name`.
666
+ Servers and clients addressed by name over a broker, which is how a network of them is usually put together. The different servers are addressed by their `name`.
864
667
 
865
668
  ```typescript
866
669
  const server = new RpcServer({
@@ -875,9 +678,7 @@ const client = new RpcClient('mqtt://broker:1883', {
875
678
  })
876
679
  ```
877
680
 
878
- **Both ends must agree on the prefix.** An `mqtt://` url gives the client the default `msgrpc/v2`,
879
- so a server that sets its own is unreachable from a client that does not — and it fails as a call
880
- timeout, which says nothing about why. There is no client option for it, so build the transport:
681
+ **Both ends must agree on the prefix.** An `mqtt://` url gives the client the default `msgrpc/v2`, so a server that sets its own is unreachable from a client that does not — and it fails as a call timeout, which says nothing about why. There is no client option for it, so build the transport:
881
682
 
882
683
  ```typescript
883
684
  import { MqttTransport } from '@source-repo/rpc'
@@ -886,14 +687,11 @@ const transport = new MqttTransport('hmi-1', 'mqtt://broker:1883', { prefix: 'si
886
687
  const client = new RpcClient(undefined, { name: 'hmi-1', transport, defaultTarget: 'plantServer' })
887
688
  ```
888
689
 
889
- The same applies to every `MqttTransportOptions` field: the url form takes the defaults, and
890
- anything else means constructing the transport yourself.
690
+ The same applies to every `MqttTransportOptions` field: the url form takes the defaults, and anything else means constructing the transport yourself.
891
691
 
892
692
  ### Topics
893
693
 
894
- MQTT 5 is the default. Reply address, correlation and method travel as packet properties, so a peer
895
- with no msgrpc code can take part and standard tooling can read the traffic.
896
- [`docs/mqtt5-frame-spec.md`](https://github.com/source-repo/rpc/blob/main/docs/mqtt5-frame-spec.md) describes the layout in full.
694
+ MQTT 5 is the default. Reply address, correlation and method travel as packet properties, so a peer with no msgrpc code can take part and standard tooling can read the traffic. [`docs/mqtt5-frame-spec.md`](https://github.com/source-repo/rpc/blob/main/docs/mqtt5-frame-spec.md) describes the layout in full.
897
695
 
898
696
  ```
899
697
  <prefix>/req/<peer> calls and subscribe requests default prefix msgrpc/v2
@@ -902,35 +700,19 @@ with no msgrpc code can take part and standard tooling can read the traffic.
902
700
  <prefix>/presence/<peer> retained: "online", or "offline" via the last will
903
701
  ```
904
702
 
905
- Set `protocol: 4` for a broker that does not speak MQTT 5. That uses the older `$`-delimited header
906
- on `<prefix>/rpc/<peer>` under a default prefix of `msgrpc/v1`, so the two layouts never share a
907
- topic and can run side by side during a migration.
703
+ Set `protocol: 4` for a broker that does not speak MQTT 5. That uses the older `$`-delimited header on `<prefix>/rpc/<peer>` under a default prefix of `msgrpc/v1`, so the two layouts never share a topic and can run side by side during a migration.
908
704
 
909
- **Peer names are validated.** A name is one topic level, so `#`, `+`, `/`, spaces, an empty name and
910
- names over 128 characters are rejected when the transport is constructed. Without this a peer named
911
- `#` subscribed to `<prefix>/#` and received every other peer's requests and replies.
705
+ **Peer names are validated.** A name is one topic level, so `#`, `+`, `/`, spaces, an empty name and names over 128 characters are rejected when the transport is constructed. Without this a peer named `#` subscribed to `<prefix>/#` and received every other peer's requests and replies.
912
706
 
913
707
  ### Delivery and sessions
914
708
 
915
- **QoS 1 by default.** QoS 0 drops messages silently whenever the broker or link hiccups, which
916
- surfaces as an unexplained call timeout. At-least-once permits duplicate delivery, so the server
917
- suppresses repeats by request id and answers them from a cache rather than running the method again
918
- — a redelivered `writeSetpoint` must not execute twice. Publishes are awaited, so a failure to reach
919
- the broker rejects the call instead of vanishing.
920
-
921
- **Presence replaces the connection MQTT does not have.** Each peer registers a retained last will, so
922
- when it disappears the broker publishes `offline` on its behalf and servers release the event
923
- subscriptions they held for it. Without this those subscriptions leaked forever, because an MQTT
924
- server never sees a disconnect. A peer that closes gracefully announces `offline` and then clears its
925
- retained value, so it leaves nothing behind on the broker.
926
-
927
- **Sessions.** Servers connect with a stable client id and a persistent session, so requests published
928
- while they restart are queued rather than lost. Under MQTT 5 the session is bounded by
929
- `sessionExpirySeconds`; under 3.1.1, which has no expiry, clients use a clean session instead.
930
-
931
- > **Expose before awaiting `ready()`.** A resumed session is handed its queued requests the moment it
932
- > connects. Anything exposed after `await server.ready()` is registered too late for them, and those
933
- > callers get `ClassNotFound`. Construct, expose, then await:
709
+ **QoS 1 by default.** QoS 0 drops messages silently whenever the broker or link hiccups, which surfaces as an unexplained call timeout. At-least-once permits duplicate delivery, so the server suppresses repeats by request id and answers them from a cache rather than running the method again — a redelivered `writeSetpoint` must not execute twice. Publishes are awaited, so a failure to reach the broker rejects the call instead of vanishing.
710
+
711
+ **Presence replaces the connection MQTT does not have.** Each peer registers a retained last will, so when it disappears the broker publishes `offline` on its behalf and servers release the event subscriptions they held for it. Without this those subscriptions leaked forever, because an MQTT server never sees a disconnect. A peer that closes gracefully announces `offline` and then clears its retained value, so it leaves nothing behind on the broker.
712
+
713
+ **Sessions.** Servers connect with a stable client id and a persistent session, so requests published while they restart are queued rather than lost. Under MQTT 5 the session is bounded by `sessionExpirySeconds`; under 3.1.1, which has no expiry, clients use a clean session instead.
714
+
715
+ > **Expose before awaiting `ready()`.** A resumed session is handed its queued requests the moment it connects. Anything exposed after `await server.ready()` is registered too late for them, and those callers get `ClassNotFound`. Construct, expose, then await:
934
716
  >
935
717
  > ```typescript
936
718
  > const server = new RpcServer({ transports: [{ brokerurl }] })
@@ -938,41 +720,27 @@ while they restart are queued rather than lost. Under MQTT 5 the session is boun
938
720
  > await server.ready()
939
721
  > ```
940
722
 
941
- **Peer names must be unique.** A peer's MQTT client id derives from its name, and a broker allows one
942
- connection per client id, so a second peer using the same name disconnects the first.
723
+ **Peer names must be unique.** A peer's MQTT client id derives from its name, and a broker allows one connection per client id, so a second peer using the same name disconnects the first.
943
724
 
944
725
  ### Replicas
945
726
 
946
- Set `sharedGroup` and several processes can serve one peer name, with the broker distributing
947
- requests among them. Each replica needs its own `replicaId`, because of the client id rule above.
948
- Only the request channel is shared — a reply has to reach the requester waiting for it. Replicas keep
949
- no session, since a dead replica's share of the queue would never be drained, and they observe
950
- presence without announcing it: one replica's will would otherwise declare the whole group offline.
951
- Event subscriptions still bind to the replica that handled them, so events do not fan out across a
952
- group.
727
+ Set `sharedGroup` and several processes can serve one peer name, with the broker distributing requests among them. Each replica needs its own `replicaId`, because of the client id rule above. Only the request channel is shared — a reply has to reach the requester waiting for it. Replicas keep no session, since a dead replica's share of the queue would never be drained, and they observe presence without announcing it: one replica's will would otherwise declare the whole group offline. Event subscriptions still bind to the replica that handled them, so events do not fan out across a group.
953
728
 
954
729
  ### What the broker still has to do
955
730
 
956
- MQTT has no server-side handshake, so `authenticate` does not apply to MQTT transports and `identity`
957
- is undefined for MQTT callers unless frames are signed. Trust otherwise comes from the broker:
731
+ MQTT has no server-side handshake, so `authenticate` does not apply to MQTT transports and `identity` is undefined for MQTT callers unless frames are signed. Trust otherwise comes from the broker:
958
732
 
959
733
  - Set broker credentials or TLS client certificates through the transport's `mqtt` options.
960
734
  - Restrict which peer may publish or subscribe to which topic with broker ACLs.
961
735
  - `authorize` still runs, but its `source` is only as trustworthy as those ACLs make it.
962
736
 
963
- A server mixing an authenticating socket.io transport with an MQTT transport will reject its MQTT
964
- peers, because `requireAuthenticatedPeers` turns on with `authenticate`. Set it `false` explicitly to
965
- allow both and rely on the broker for the MQTT half.
737
+ A server mixing an authenticating socket.io transport with an MQTT transport will reject its MQTT peers, because `requireAuthenticatedPeers` turns on with `authenticate`. Set it `false` explicitly to allow both and rely on the broker for the MQTT half.
966
738
 
967
- **msgrpc cannot hide MQTT traffic.** Peer names can no longer widen their own subscription, but
968
- anyone holding broker credentials can subscribe to `<prefix>/#` and read everything. Only broker ACLs
969
- prevent that. Signing, below, makes a message's origin checkable — it does not make it private.
739
+ **msgrpc cannot hide MQTT traffic.** Peer names can no longer widen their own subscription, but anyone holding broker credentials can subscribe to `<prefix>/#` and read everything. Only broker ACLs prevent that. Signing, below, makes a message's origin checkable — it does not make it private.
970
740
 
971
741
  ### Signing frames
972
742
 
973
- Without a connection to authenticate, `source` is a claim. Signing each frame makes it checkable, so
974
- a broker operator — or any peer whose ACLs let it publish to another peer's topic — cannot forge a
975
- message from someone else.
743
+ Without a connection to authenticate, `source` is a claim. Signing each frame makes it checkable, so a broker operator — or any peer whose ACLs let it publish to another peer's topic — cannot forge a message from someone else.
976
744
 
977
745
  ```typescript
978
746
  import { createHmacSigner, createHmacVerifier } from '@source-repo/rpc'
@@ -997,35 +765,19 @@ const client = new RpcClient('mqtt://broker:1883', {
997
765
  })
998
766
  ```
999
767
 
1000
- Once `verify` is set, a frame must be signed, fresh, unreplayed and signed by the key on file for the
1001
- name it claims — otherwise it is dropped before reaching the RPC layer and a `rejected` event carries
1002
- the reason. A verified peer becomes an `RpcIdentity`, so `requireAuthenticatedPeers` and `authorize`
1003
- work over MQTT exactly as they do over WebSocket.
768
+ Once `verify` is set, a frame must be signed, fresh, unreplayed and signed by the key on file for the name it claims — otherwise it is dropped before reaching the RPC layer and a `rejected` event carries the reason. A verified peer becomes an `RpcIdentity`, so `requireAuthenticatedPeers` and `authorize` work over MQTT exactly as they do over WebSocket.
1004
769
 
1005
- The signature covers everything that decides what a frame means and where it goes, encoded as a JSON
1006
- array of those fields followed by the payload bytes. The array fixes field order and escapes values,
1007
- so no combination of names can be made to look like a different frame. Under MQTT 5 that includes the
1008
- destination topic, since the topic is what carries the addressing; the exact field list is in the
1009
- frame spec.
770
+ The signature covers everything that decides what a frame means and where it goes, encoded as a JSON array of those fields followed by the payload bytes. The array fixes field order and escapes values, so no combination of names can be made to look like a different frame. Under MQTT 5 that includes the destination topic, since the topic is what carries the addressing; the exact field list is in the frame spec.
1010
771
 
1011
- **Replay protection.** A signature does not stop a captured frame being sent again, which for RPC
1012
- means replaying a command. Each frame carries a nonce, and a receiver rejects frames outside
1013
- `maxClockSkew` (default 60 s) or whose nonce it has already seen. Peers therefore need clocks within
1014
- that window of each other; the window also bounds how many nonces must be remembered.
772
+ **Replay protection.** A signature does not stop a captured frame being sent again, which for RPC means replaying a command. Each frame carries a nonce, and a receiver rejects frames outside `maxClockSkew` (default 60 s) or whose nonce it has already seen. Peers therefore need clocks within that window of each other; the window also bounds how many nonces must be remembered.
1015
773
 
1016
- **HMAC is symmetric.** Whoever can verify a peer's messages can also forge them, so an HMAC secret
1017
- must only be shared with parties allowed to act as that peer. Where a compromised server must not be
1018
- able to impersonate its peers, use `createEd25519Signer` / `createEd25519Verifier`, which take
1019
- WebCrypto keys directly and leave only public keys on the verifying side.
774
+ **HMAC is symmetric.** Whoever can verify a peer's messages can also forge them, so an HMAC secret must only be shared with parties allowed to act as that peer. Where a compromised server must not be able to impersonate its peers, use `createEd25519Signer` / `createEd25519Verifier`, which take WebCrypto keys directly and leave only public keys on the verifying side.
1020
775
 
1021
- Both are built on WebCrypto, so the same signer works in Node and in the browser. To use an HSM or
1022
- another algorithm, supply your own `MessageSigner` / `MessageVerifier` — the built-ins are only
1023
- conveniences.
776
+ Both are built on WebCrypto, so the same signer works in Node and in the browser. To use an HSM or another algorithm, supply your own `MessageSigner` / `MessageVerifier` — the built-ins are only conveniences.
1024
777
 
1025
778
  ## Options
1026
779
 
1027
- The types are the reference; these are the ones worth explaining. Anything not listed defaults to
1028
- off or absent.
780
+ The types are the reference; these are the ones worth explaining. Anything not listed defaults to off or absent.
1029
781
 
1030
782
  ### RpcServerOptions
1031
783
 
@@ -1048,21 +800,15 @@ off or absent.
1048
800
  | `relay` | `true` | forward frames addressed to another connected peer; `false`, or a predicate per connection |
1049
801
  | `callTimeout` | `10000` | for this server's own outgoing calls, via `proxy()` |
1050
802
 
1051
- A transport entry is `{ port, tls?, path? }` for a socket.io server, `{ server, path? }` to attach
1052
- to an existing `http.Server`, `{ connect, path?, credentials? }` to serve over a connection this
1053
- server opens, `{ brokerurl, ...MqttTransportOptions }` for MQTT, or a `Transport` instance you built
1054
- yourself. A `connect` entry also takes `ca` — the authority to trust when the hub serves TLS — and
1055
- `allowInsecureTls` for a development hub whose certificate nobody signed.
803
+ A transport entry is `{ port, tls?, path? }` for a socket.io server, `{ server, path? }` to attach to an existing `http.Server`, `{ connect, path?, credentials? }` to serve over a connection this server opens, `{ brokerurl, ...MqttTransportOptions }` for MQTT, or a `Transport` instance you built yourself. A `connect` entry also takes `ca` — the authority to trust when the hub serves TLS — and `allowInsecureTls` for a development hub whose certificate nobody signed.
1056
804
 
1057
- `tls` takes the certificate and key that `https.createServer` takes, and its presence is what makes
1058
- the server HTTPS - there is no useful HTTPS server without key material, which is why there is no
1059
- boolean for it.
805
+ `tls` takes the certificate and key that `https.createServer` takes, and its presence is what makes the server HTTPS - there is no useful HTTPS server without key material, which is why there is no boolean for it.
1060
806
 
1061
807
  ### RpcClientOptions
1062
808
 
1063
809
  | option | default | meaning |
1064
810
  | --- | --- | --- |
1065
- | `name` | a UUID | how this client identifies itself; must be unique among peers sharing a server |
811
+ | `name` | three readable words | how this client identifies itself; must be unique among peers sharing a server |
1066
812
  | `transport` | built from the url | supply one to take full control of the link |
1067
813
  | `defaultTarget` | `'*'` | which peer `proxy()` addresses when not told otherwise |
1068
814
  | `callTimeout` | `10000` | before rejecting with `Timeout` |
@@ -1089,6 +835,7 @@ boolean for it.
1089
835
  | `allowResponseTopic` | under the prefix | decides whether a request may have its reply published where it asks |
1090
836
  | `allowInsecureTls` | `false` | accept any certificate from an `mqtts`/`wss` broker; unsafe by design |
1091
837
  | `channels` | all three | which of `req`/`rsp`/`evt` to subscribe to |
838
+ | `tap` | `false` | watch everything under the prefix and report it as `relayed` without acting on it; answers no calls and checks no signatures |
1092
839
  | `sharedGroup` / `replicaId` | — | see [Replicas](#replicas) |
1093
840
  | `sign` / `verify` | — | see [Signing frames](#signing-frames) |
1094
841
  | `maxClockSkew` / `maxTrackedNonces` | `60000` / `5000` | replay window and how much of it to remember |
@@ -1096,51 +843,32 @@ boolean for it.
1096
843
 
1097
844
  ## Browser use
1098
845
 
1099
- The `browser` export condition resolves to a build whose static dependencies are `socket.io-client`,
1100
- `@msgpack/msgpack`, `uint8array-extras`, `uuid` and `events`. The MQTT client is **not** among them:
1101
- `RpcClient` imports it on demand, so a bundle only carries it if an `mqtt://` url is actually used,
1102
- in which case bundlers place it in a separate chunk.
846
+ The `browser` export condition resolves to a build whose static dependencies are `socket.io-client`, `@msgpack/msgpack`, `uint8array-extras`, `uuid` and `events`. The MQTT client is **not** among them: `RpcClient` imports it on demand, so a bundle only carries it if an `mqtt://` url is actually used, in which case bundlers place it in a separate chunk.
1103
847
 
1104
- `events` is a real dependency rather than a `node:` builtin so bundlers can substitute the browser
1105
- shim. Signing uses WebCrypto, which browsers expose only in a secure context (https, or localhost).
848
+ `events` is a real dependency rather than a `node:` builtin so bundlers can substitute the browser shim. Signing uses WebCrypto, which browsers expose only in a secure context (https, or localhost).
1106
849
 
1107
- A page can host an `RpcServer` as well as call one: `transports: [{ connect: url }]` serves over the
1108
- connection it opens, and the hub relays calls to it. See
1109
- [Serving over a connection you open](#serving-over-a-connection-you-open).
850
+ A page can host an `RpcServer` as well as call one: `transports: [{ connect: url }]` serves over the connection it opens, and the hub relays calls to it. See [Serving over a connection you open](#serving-over-a-connection-you-open).
1110
851
 
1111
- **`RpcServer` means a different class here**, and deliberately. In Node it is `NodeRpcServer`, which
1112
- adds `{ port }`, `{ server }` and `{ brokerurl }`; in a browser it is the portable base, which has
1113
- none of them — a page cannot open a listening socket or speak MQTT. So the same source file is
1114
- portable as long as it sticks to what a browser can do, and `{ port: 8080 }` in browser code is a
1115
- compile error rather than a class that throws when constructed:
852
+ **`RpcServer` means a different class here**, and deliberately. In Node it is `NodeRpcServer`, which adds `{ port }`, `{ server }` and `{ brokerurl }`; in a browser it is the portable base, which has none of them — a page cannot open a listening socket or speak MQTT. So the same source file is portable as long as it sticks to what a browser can do, and `{ port: 8080 }` in browser code is a compile error rather than a class that throws when constructed:
1116
853
 
1117
854
  ```
1118
855
  Object literal may only specify known properties, and 'port' does not exist in
1119
856
  type 'Transport | ConnectServerOptions'
1120
857
  ```
1121
858
 
1122
- It also means nothing a browser resolves imports socket.io's server or the MQTT client, so neither
1123
- reaches the bundle — no aliases and no bundler configuration. `NodeRpcServer` is exported under that
1124
- name too, for code that would rather say where it runs.
859
+ It also means nothing a browser resolves imports socket.io's server or the MQTT client, so neither reaches the bundle — no aliases and no bundler configuration. `NodeRpcServer` is exported under that name too, for code that would rather say where it runs.
1125
860
 
1126
861
  ## Peer routing
1127
862
 
1128
- Each `RpcServer` and `RpcClient` owns a `PeerRegistry`, shared by its own modules and nothing wider:
1129
- transports record which peer a message arrived from, and the switch reads it back to send the reply
1130
- out of the same transport. Entries are dropped when a peer disconnects, and the registry is bounded,
1131
- since the keys arrive from remote peers.
863
+ Each `RpcServer` and `RpcClient` owns a `PeerRegistry`, shared by its own modules and nothing wider: transports record which peer a message arrived from, and the switch reads it back to send the reply out of the same transport. Entries are dropped when a peer disconnects, and the registry is bounded, since the keys arrive from remote peers.
1132
864
 
1133
- Peer names must be unique within one graph. Across separate `RpcServer` instances they do not
1134
- interfere.
865
+ Peer names must be unique within one graph. Across separate `RpcServer` instances they do not interfere.
1135
866
 
1136
867
  ## Low level: modules
1137
868
 
1138
- `RpcServer` and `RpcClient` are assembled from smaller pieces, and the same pieces are available for
1139
- building something else.
869
+ `RpcServer` and `RpcClient` are assembled from smaller pieces, and the same pieces are available for building something else.
1140
870
 
1141
- A module receives, processes and sends messages. *Sending* here means from one module to the next
1142
- within a process, not over a network, and a *message* is any JavaScript value. Modules are connected
1143
- with `pipe`:
871
+ A module receives, processes and sends messages. *Sending* here means from one module to the next within a process, not over a network, and a *message* is any JavaScript value. Modules are connected with `pipe`:
1144
872
 
1145
873
  ```typescript
1146
874
  const first = new MyModule()
@@ -1154,23 +882,16 @@ const third = new MyModule([first])
1154
882
  first.pipe((message) => console.log('first wanted to send:', message))
1155
883
  ```
1156
884
 
1157
- To write one, extend `GenericModule` and call `this.send(message)`. Its `receive` may be async, and a
1158
- rejection propagates back through the pipe to the original sender, where it can be caught either at
1159
- the `send` call or with a `TryCatch` module:
885
+ To write one, extend `GenericModule` and call `this.send(message)`. Its `receive` may be async, and a rejection propagates back through the pipe to the original sender, where it can be caught either at the `send` call or with a `TryCatch` module:
1160
886
 
1161
887
  ```typescript
1162
888
  const tryCatch = new TryCatch([source])
1163
889
  tryCatch.on('caught', (message, error) => console.log('caught', error))
1164
890
  ```
1165
891
 
1166
- Included utilities: **Converter** (map each message through a function), **Filter** (pass those a
1167
- predicate accepts), **Switch** (route to a named target; an unresolvable target is dropped) and
1168
- **TryCatch**.
892
+ Included utilities: **Converter** (map each message through a function), **Filter** (pass those a predicate accepts), **Switch** (route to a named target; an unresolvable target is dropped) and **TryCatch**.
1169
893
 
1170
- Transports own their wire format. A transport receives and emits `Message` objects and encodes them
1171
- itself with a `FrameCodec`, which is what lets MQTT 5 carry the method and correlation as packet
1172
- properties rather than burying them in an opaque payload. Wiring RPC by hand is therefore just the
1173
- handler and a transport:
894
+ Transports own their wire format. A transport receives and emits `Message` objects and encodes them itself with a `FrameCodec`, which is what lets MQTT 5 carry the method and correlation as packet properties rather than burying them in an opaque payload. Wiring RPC by hand is therefore just the handler and a transport:
1174
895
 
1175
896
  ```typescript
1176
897
  import { RpcServerHandler, SocketIoServerTransport } from '@source-repo/rpc'
@@ -1182,8 +903,7 @@ handler.pipe(transport) // handler -> tra
1182
903
  handler.manageRpc.exposeObject({ hello: () => 'world' }, 'greeter')
1183
904
  ```
1184
905
 
1185
- Before 2.0.0 a `Converter` sat on each side of the handler to encode and decode. Those converters
1186
- are still exported, but they are no longer part of the RPC chain.
906
+ Before 2.0.0 a `Converter` sat on each side of the handler to encode and decode. Those converters are still exported, but they are no longer part of the RPC chain.
1187
907
 
1188
908
  ## Development
1189
909
 
@@ -1204,5 +924,4 @@ docker compose -f docker-compose/docker-compose.yml up -d
1204
924
 
1205
925
  Point them at a different broker with `MSGRPC_TEST_BROKER=mqtt://host:1883`.
1206
926
 
1207
- [`examples/`](https://github.com/source-repo/rpc/tree/main/packages/rpc/examples) is a small plant service showing the 2.0 idioms: `@rpcNamespace` and `@rpc`,
1208
- an extracted contract, and a server that validates against it and exposes introspection.
927
+ [`examples/`](https://github.com/source-repo/rpc/tree/main/packages/rpc/examples) is a small plant service showing the 2.0 idioms: `@rpcNamespace` and `@rpc`, an extracted contract, and a server that validates against it and exposes introspection.