@source-repo/rpc 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1208 -0
  3. package/dist/Logging/ILogger.d.ts +7 -0
  4. package/dist/Logging/ILogger.d.ts.map +1 -0
  5. package/dist/Logging/ILogger.js +2 -0
  6. package/dist/Logging/ILogger.js.map +1 -0
  7. package/dist/NodeRpcServer.d.ts +55 -0
  8. package/dist/NodeRpcServer.d.ts.map +1 -0
  9. package/dist/NodeRpcServer.js +46 -0
  10. package/dist/NodeRpcServer.js.map +1 -0
  11. package/dist/RPC/Auth.d.ts +98 -0
  12. package/dist/RPC/Auth.d.ts.map +1 -0
  13. package/dist/RPC/Auth.js +14 -0
  14. package/dist/RPC/Auth.js.map +1 -0
  15. package/dist/RPC/Codec.d.ts +24 -0
  16. package/dist/RPC/Codec.d.ts.map +1 -0
  17. package/dist/RPC/Codec.js +21 -0
  18. package/dist/RPC/Codec.js.map +1 -0
  19. package/dist/RPC/Compatibility.d.ts +39 -0
  20. package/dist/RPC/Compatibility.d.ts.map +1 -0
  21. package/dist/RPC/Compatibility.js +195 -0
  22. package/dist/RPC/Compatibility.js.map +1 -0
  23. package/dist/RPC/Core.d.ts +188 -0
  24. package/dist/RPC/Core.d.ts.map +1 -0
  25. package/dist/RPC/Core.js +354 -0
  26. package/dist/RPC/Core.js.map +1 -0
  27. package/dist/RPC/Expose.d.ts +90 -0
  28. package/dist/RPC/Expose.d.ts.map +1 -0
  29. package/dist/RPC/Expose.js +110 -0
  30. package/dist/RPC/Expose.js.map +1 -0
  31. package/dist/RPC/Idempotency.d.ts +90 -0
  32. package/dist/RPC/Idempotency.d.ts.map +1 -0
  33. package/dist/RPC/Idempotency.js +46 -0
  34. package/dist/RPC/Idempotency.js.map +1 -0
  35. package/dist/RPC/Introspection.d.ts +92 -0
  36. package/dist/RPC/Introspection.d.ts.map +1 -0
  37. package/dist/RPC/Introspection.js +188 -0
  38. package/dist/RPC/Introspection.js.map +1 -0
  39. package/dist/RPC/Introspection.types.json +1093 -0
  40. package/dist/RPC/Messages.d.ts +108 -0
  41. package/dist/RPC/Messages.d.ts.map +1 -0
  42. package/dist/RPC/Messages.js +30 -0
  43. package/dist/RPC/Messages.js.map +1 -0
  44. package/dist/RPC/Rpc.d.ts +51 -0
  45. package/dist/RPC/Rpc.d.ts.map +1 -0
  46. package/dist/RPC/Rpc.js +54 -0
  47. package/dist/RPC/Rpc.js.map +1 -0
  48. package/dist/RPC/RpcClientHandler.d.ts +129 -0
  49. package/dist/RPC/RpcClientHandler.d.ts.map +1 -0
  50. package/dist/RPC/RpcClientHandler.js +267 -0
  51. package/dist/RPC/RpcClientHandler.js.map +1 -0
  52. package/dist/RPC/RpcServerHandler.d.ts +226 -0
  53. package/dist/RPC/RpcServerHandler.d.ts.map +1 -0
  54. package/dist/RPC/RpcServerHandler.js +676 -0
  55. package/dist/RPC/RpcServerHandler.js.map +1 -0
  56. package/dist/RPC/Schema.d.ts +150 -0
  57. package/dist/RPC/Schema.d.ts.map +1 -0
  58. package/dist/RPC/Schema.js +198 -0
  59. package/dist/RPC/Schema.js.map +1 -0
  60. package/dist/RPC/Signing.d.ts +118 -0
  61. package/dist/RPC/Signing.d.ts.map +1 -0
  62. package/dist/RPC/Signing.js +171 -0
  63. package/dist/RPC/Signing.js.map +1 -0
  64. package/dist/RPC/Tokens.d.ts +49 -0
  65. package/dist/RPC/Tokens.d.ts.map +1 -0
  66. package/dist/RPC/Tokens.js +45 -0
  67. package/dist/RPC/Tokens.js.map +1 -0
  68. package/dist/RpcClient.d.ts +91 -0
  69. package/dist/RpcClient.d.ts.map +1 -0
  70. package/dist/RpcClient.js +145 -0
  71. package/dist/RpcClient.js.map +1 -0
  72. package/dist/RpcServer.d.ts +191 -0
  73. package/dist/RpcServer.d.ts.map +1 -0
  74. package/dist/RpcServer.js +293 -0
  75. package/dist/RpcServer.js.map +1 -0
  76. package/dist/Transports/Mqtt5Frame.d.ts +107 -0
  77. package/dist/Transports/Mqtt5Frame.d.ts.map +1 -0
  78. package/dist/Transports/Mqtt5Frame.js +162 -0
  79. package/dist/Transports/Mqtt5Frame.js.map +1 -0
  80. package/dist/Transports/MqttTransport.d.ts +279 -0
  81. package/dist/Transports/MqttTransport.d.ts.map +1 -0
  82. package/dist/Transports/MqttTransport.js +843 -0
  83. package/dist/Transports/MqttTransport.js.map +1 -0
  84. package/dist/Transports/Presence.d.ts +68 -0
  85. package/dist/Transports/Presence.d.ts.map +1 -0
  86. package/dist/Transports/Presence.js +21 -0
  87. package/dist/Transports/Presence.js.map +1 -0
  88. package/dist/Transports/SocketIoClientTransport.d.ts +89 -0
  89. package/dist/Transports/SocketIoClientTransport.d.ts.map +1 -0
  90. package/dist/Transports/SocketIoClientTransport.js +280 -0
  91. package/dist/Transports/SocketIoClientTransport.js.map +1 -0
  92. package/dist/Transports/SocketIoServerTransport.d.ts +135 -0
  93. package/dist/Transports/SocketIoServerTransport.d.ts.map +1 -0
  94. package/dist/Transports/SocketIoServerTransport.js +505 -0
  95. package/dist/Transports/SocketIoServerTransport.js.map +1 -0
  96. package/dist/Utilities/Converters.d.ts +22 -0
  97. package/dist/Utilities/Converters.d.ts.map +1 -0
  98. package/dist/Utilities/Converters.js +58 -0
  99. package/dist/Utilities/Converters.js.map +1 -0
  100. package/dist/Utilities/Filter.d.ts +7 -0
  101. package/dist/Utilities/Filter.d.ts.map +1 -0
  102. package/dist/Utilities/Filter.js +14 -0
  103. package/dist/Utilities/Filter.js.map +1 -0
  104. package/dist/Utilities/ReadableName.d.ts +38 -0
  105. package/dist/Utilities/ReadableName.d.ts.map +1 -0
  106. package/dist/Utilities/ReadableName.js +58 -0
  107. package/dist/Utilities/ReadableName.js.map +1 -0
  108. package/dist/Utilities/Switch.d.ts +21 -0
  109. package/dist/Utilities/Switch.d.ts.map +1 -0
  110. package/dist/Utilities/Switch.js +70 -0
  111. package/dist/Utilities/Switch.js.map +1 -0
  112. package/dist/Utilities/TryCatch.d.ts +11 -0
  113. package/dist/Utilities/TryCatch.d.ts.map +1 -0
  114. package/dist/Utilities/TryCatch.js +13 -0
  115. package/dist/Utilities/TryCatch.js.map +1 -0
  116. package/dist/index-web.d.ts +25 -0
  117. package/dist/index-web.d.ts.map +1 -0
  118. package/dist/index-web.js +28 -0
  119. package/dist/index-web.js.map +1 -0
  120. package/dist/index.d.ts +28 -0
  121. package/dist/index.d.ts.map +1 -0
  122. package/dist/index.js +30 -0
  123. package/dist/index.js.map +1 -0
  124. package/package.json +77 -0
package/README.md ADDED
@@ -0,0 +1,1208 @@
1
+ ```
2
+ ███████╗ ██████╗ ██╗ ██╗██████╗ ██████╗███████╗
3
+ ██╔════╝██╔═══██╗██║ ██║██╔══██╗██╔════╝██╔════╝
4
+ ███████╗██║ ██║██║ ██║██████╔╝██║ █████╗
5
+ ╚════██║██║ ██║██║ ██║██╔══██╗██║ ██╔══╝
6
+ ███████║╚██████╔╝╚██████╔╝██║ ██║╚██████╗███████╗
7
+ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝
8
+ ██████╗ ██████╗ ██████╗
9
+ ██╔══██╗██╔══██╗██╔════╝
10
+ ██████╔╝██████╔╝██║
11
+ ██╔══██╗██╔═══╝ ██║
12
+ ██║ ██║██║ ╚██████╗
13
+ ╚═╝ ╚═╝╚═╝ ╚═════╝
14
+ ```
15
+
16
+ # @source-repo/rpc
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.
22
+
23
+ ```
24
+ npm install @source-repo/rpc
25
+ ```
26
+
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.
30
+
31
+ Upgrading from 1.x? [`CHANGELOG.md`](https://github.com/source-repo/rpc/blob/main/CHANGELOG.md) lists what breaks.
32
+
33
+ ### What is in it
34
+
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.
38
+ - **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.
49
+ - **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.
52
+
53
+ ## Quick start
54
+
55
+ The class both sides share. Nothing here is msgrpc-specific — no decorators, no base class:
56
+
57
+ ```typescript
58
+ // calculator.ts
59
+ export class Calculator {
60
+ private memory = 0
61
+
62
+ async square(n: number) {
63
+ return n * n
64
+ }
65
+
66
+ async add(n: number) {
67
+ this.memory += n
68
+ return this.memory
69
+ }
70
+ }
71
+ ```
72
+
73
+ The server. With no transports configured it listens on WebSocket (socket.io) port 7843:
74
+
75
+ ```typescript
76
+ import { RpcServer } from '@source-repo/rpc'
77
+ import { Calculator } from './calculator.js'
78
+
79
+ const server = new RpcServer()
80
+ server.exposeClassInstance(new Calculator(), 'calculator')
81
+ await server.ready()
82
+ ```
83
+
84
+ A client, in another Node process, a browser page, or the same process for testing:
85
+
86
+ ```typescript
87
+ import { RpcClient } from '@source-repo/rpc'
88
+ import type { Calculator } from './calculator.js' // the type only - see below
89
+
90
+ const client = new RpcClient('http://localhost:7843')
91
+ await client.ready()
92
+
93
+ const calculator = await client.proxy<Calculator>('calculator')
94
+ console.log(await calculator.remote!.square(3)) // 9
95
+ console.log(await calculator.remote!.add(10)) // 10
96
+ console.log(await calculator.remote!.add(5)) // 15 - one instance, holding its own state
97
+
98
+ await client.close()
99
+ ```
100
+
101
+ Three things worth noticing:
102
+
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).)
109
+
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>()`.
115
+
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 `!`.
119
+
120
+ ## Connecting
121
+
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.
124
+
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.
127
+
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:
131
+
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.
136
+ - **A peer that only relays is a bus.** Nothing else is needed to build one.
137
+
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.
141
+
142
+ The server's `transports` say where it listens; the client's url says where to reach it.
143
+
144
+ | server | client |
145
+ | --- | --- |
146
+ | `new RpcServer()` | `new RpcClient()` — socket.io on port 7843, the default on both sides |
147
+ | `transports: [{ port: 8080 }]` (also `tls`, `path`) | `new RpcClient('http://host:8080')` |
148
+ | `transports: [{ server: httpServer }]` | `new RpcClient(origin)` — share an `http.Server` you already have, so the page and its RPC arrive on one port |
149
+ | `transports: [{ brokerurl: 'mqtt://broker:1883' }]` | `new RpcClient('mqtt://broker:1883', { defaultTarget: 'plantServer' })` |
150
+
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.
155
+
156
+ | | | |
157
+ | --- | --- | --- |
158
+ | `1883` | | MQTT |
159
+ | `8083` | | MQTT over WebSocket |
160
+ | `7843` | `rpc` | Source RPC — an `RpcServer`, or `source-rpc broker` |
161
+ | `7844` | `console` | Source RPC console, or anything else of yours serving a page |
162
+ | `8843` | `rpc-tls` | the same, with a certificate |
163
+ | `8844` | `console-tls` | |
164
+
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.
170
+
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.
174
+
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.
179
+
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:
182
+
183
+ ```typescript
184
+ const server = new RpcServer({
185
+ name: 'plantServer',
186
+ transports: [{ port: 8080 }, { brokerurl: 'mqtt://broker:1883' }]
187
+ })
188
+ server.exposeClassInstance(new Plant(), 'plant') // reachable over both
189
+ ```
190
+
191
+ ### A bus without a broker
192
+
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.
196
+
197
+ ```typescript
198
+ const bus = new RpcServer({ name: 'bus', transports: [{ port: 7843 }] })
199
+
200
+ const cellSrv = new RpcServer({ name: 'cellSrv', transports: [{ connect: 'http://bus:7843' }] })
201
+ cellSrv.exposeClassInstance(new Cell(), 'cell')
202
+
203
+ // The same object calls back out, over the same connection and under the same name.
204
+ const oven = await cellSrv.proxy<Oven>('oven', 'ovenSrv')
205
+ await oven.remote!.temperature()
206
+ ```
207
+
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.
213
+
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.
216
+
217
+ ### One peer, several links
218
+
219
+ A peer holds one link per transport, and the two kinds are not interchangeable:
220
+
221
+ | transport | connections |
222
+ | --- | --- |
223
+ | `{ port }` / `{ server }` | **accepts** many — every peer that dials in |
224
+ | `{ connect: url }` | **opens** exactly one, to that url |
225
+ | `{ brokerurl }` | opens one, to that broker |
226
+
227
+ A socket cannot both accept and dial, so a Node service that serves browsers *and* joins a bus
228
+ genuinely holds two:
229
+
230
+ ```
231
+ browsers ──▶ :8080 ┐
232
+ ├─ nodeSrv ──▶ ws://bus:9000
233
+
234
+ ```
235
+
236
+ But **every link carries traffic both ways**. `proxy()` picks whichever transport reaches the
237
+ target, so:
238
+
239
+ - calling a browser that dialled in costs no new connection — the frame goes back down the socket
240
+ that browser opened;
241
+ - calling a peer on the bus goes out over the link `nodeSrv` already holds.
242
+
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.
246
+
247
+ ### Serving over a connection you open
248
+
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:
251
+
252
+ ```typescript
253
+ const panel = new RpcServer({ name: 'cellPanel', transports: [{ connect: 'https://hub.plant' }] })
254
+ panel.exposeClassInstance(new Cell(), 'cell') // now callable, from a browser tab
255
+ ```
256
+
257
+ Whatever it connects to relays calls to it — see [Discovery](#discovery).
258
+
259
+ ### Names and targets
260
+
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.
263
+
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:
267
+
268
+ ```typescript
269
+ const plant = await client.proxy<Plant>('plant', 'plantServer')
270
+ ```
271
+
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.
275
+
276
+ ### Discovery
277
+
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:
280
+
281
+ ```typescript
282
+ transport.on(TransportEvent.peerOnline, (peer) => console.log(peer, 'is up'))
283
+ transport.on(TransportEvent.peerGone, (peer) => console.log(peer, 'is gone'))
284
+ ```
285
+
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.
308
+
309
+ ```typescript
310
+ const bridge = new RpcServer({
311
+ name: 'bridge',
312
+ transports: [{ port: 8080 }, new MqttTransport('bridge', 'mqtt://broker:1883')]
313
+ })
314
+ ```
315
+
316
+ Relaying is on by default. `relay: false` forwards nothing, and a predicate decides per connection:
317
+
318
+ ```typescript
319
+ const hub = new RpcServer({
320
+ name: 'hub',
321
+ transports: [{ port: 8080 }],
322
+ authenticate,
323
+ relay: ({ identity, target }) => identity?.roles?.includes('engineer') || target === 'readOnlyGateway'
324
+ })
325
+ ```
326
+
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.
332
+
333
+ ### More than one hop
334
+
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:
337
+
338
+ ```
339
+ panel1 ── cellCtl ── bus ── hmi
340
+ ```
341
+
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.
345
+
346
+ Two rules keep that from eating itself:
347
+
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.
355
+
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.
358
+
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.
363
+
364
+ ### Ready and close
365
+
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.
370
+
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.
374
+
375
+ ### Encoding
376
+
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.
380
+
381
+ ## Exposing methods
382
+
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.
386
+
387
+ ```typescript
388
+ import { rpc, rpcNamespace } from '@source-repo/rpc'
389
+
390
+ @rpcNamespace('plant', { version: '2' })
391
+ class Plant {
392
+ @rpc async writeSetpoint(value: number) { ... }
393
+ @rpc async readSetpoint() { ... }
394
+ async wipeConfiguration() { ... } // unmarked, so unreachable
395
+ }
396
+
397
+ server.exposeClassInstance(new Plant()) // name taken from @rpcNamespace
398
+ ```
399
+
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.
403
+
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.
407
+
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.
410
+
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.
414
+
415
+ ### More than one, and without a class
416
+
417
+ A server exposes as many namespaces as you like, and a client takes a proxy per namespace:
418
+
419
+ ```typescript
420
+ server.exposeClassInstance(new Plant(), 'plant')
421
+ server.exposeClassInstance(new History(), 'history')
422
+ server.exposeObject({ ping: () => 'pong' }, 'health') // a plain object's own functions
423
+
424
+ const plant = await client.proxy<Plant>('plant')
425
+ const history = await client.proxy<History>('history')
426
+ ```
427
+
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.
469
+
470
+ ## Commands
471
+
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.
475
+
476
+ ### The honest statement
477
+
478
+ > **Delivery and execution are at least once, unless the method is guarded by a durable idempotency
479
+ > store.**
480
+
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.
484
+
485
+ ### What a method does to the world
486
+
487
+ ```typescript
488
+ class Pump {
489
+ @rpc({ semantics: 'query' }) async pressure() { … }
490
+ @rpc({ semantics: 'idempotent-command' }) async setSetpoint(bar: number) { … }
491
+ @rpc({ semantics: 'non-repeatable-command' }) async dispense() { … }
492
+ }
493
+ ```
494
+
495
+ | semantics | repeating it | example |
496
+ | --- | --- | --- |
497
+ | `query` | costs a round trip | a reading, a status, a list |
498
+ | `idempotent-command` | leaves the same state as doing it once | `setSetpoint(1200)`, `close()` |
499
+ | `non-repeatable-command` | does it again | `dispense()`, `advanceBatch()` |
500
+
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.
505
+
506
+ Undeclared stays undeclared. The library will not guess that a method is safe to repeat.
507
+
508
+ ### Not knowing
509
+
510
+ ```typescript
511
+ try {
512
+ await pump.remote!.dispense()
513
+ } catch (e) {
514
+ if (e instanceof RpcError && e.code === 'UnknownOutcome') {
515
+ // The request went out. It may have run. Go and look before sending it again.
516
+ }
517
+ }
518
+ ```
519
+
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.
524
+
525
+ ### Running a command once
526
+
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:
529
+
530
+ ```typescript
531
+ const server = new RpcServer({ idempotency: myRedisStore }) // RpcIdempotencyStore
532
+ ```
533
+
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.
537
+
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:
542
+
543
+ ```typescript
544
+ await pump.remote!.$with({ idempotencyKey: workOrder }).dispense()
545
+ ```
546
+
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.
550
+
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.
554
+
555
+ ### Calls that overlap
556
+
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
559
+
560
+ ```
561
+ setMode('manual'); start(); setSetpoint(80)
562
+ ```
563
+
564
+ from one caller can interleave with `stop(); setMode('automatic')` from another and leave a machine
565
+ in a combination neither asked for.
566
+
567
+ ```typescript
568
+ @rpcNamespace('cell', { execution: 'serial' }) // one call at a time
569
+ class Cell { … }
570
+
571
+ server.exposeClassInstance(fleet, 'fleet', { // one call at a time per device
572
+ execution: (call) => String(call.params[0])
573
+ })
574
+ ```
575
+
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.
578
+
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.
583
+
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.
586
+
587
+ ### Not built
588
+
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.
599
+
600
+ ## Events and reconnection
601
+
602
+ An exposed instance that extends `EventEmitter` can push to subscribers:
603
+
604
+ ```typescript
605
+ const plant = await client.proxy<Plant>('plant')
606
+ await plant.remote!.on('alarm', (message: string) => console.log(message))
607
+ await plant.remote!.off('alarm', handler) // same handler reference
608
+ ```
609
+
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:
612
+
613
+ ```typescript
614
+ import { TransportEvent } from '@source-repo/rpc'
615
+
616
+ client.on(TransportEvent.disconnected, (reason) => console.log('link lost:', reason))
617
+ client.on(TransportEvent.connected, ({ restoredSubscriptions }) =>
618
+ console.log('link back, subscriptions restored:', restoredSubscriptions))
619
+ ```
620
+
621
+ Reconnection is handled for you:
622
+
623
+ - 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.
628
+ - 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.
633
+
634
+ ## Checking arguments
635
+
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.
640
+
641
+ ```typescript
642
+ const schema: RpcSchema = {
643
+ schema: 1,
644
+ namespaces: {
645
+ plant: {
646
+ version: '3',
647
+ methods: {
648
+ writeSetpoint: { params: [{ kind: 'number', min: 0, max: 2000 }], returns: { kind: 'number' } }
649
+ }
650
+ }
651
+ }
652
+ }
653
+
654
+ const server = new RpcServer({ transports: [{ brokerurl }], schema })
655
+ ```
656
+
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)`.
660
+
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.
666
+
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.
671
+
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.
676
+
677
+ ```typescript
678
+ readings: { params: [], returns: { kind: 'record', values: { kind: 'ref', name: 'Reading' } } }
679
+ ```
680
+
681
+ | option | effect |
682
+ | --- | --- |
683
+ | `validation: 'described'` | check the namespaces the schema covers, let the rest through (default when a schema is given) |
684
+ | `validation: 'required'` | refuse anything the schema does not describe |
685
+ | `validation: 'off'` | disable checking without removing the schema |
686
+ | `validateResults` | check what handlers return too; off by default, since it is a self-check |
687
+
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.
691
+
692
+ ### Serving older callers
693
+
694
+ Give a client the contract it was built against and it declares the version on every call:
695
+
696
+ ```typescript
697
+ const client = new RpcClient(url, { schema: contractTheClientWasBuiltAgainst })
698
+ ```
699
+
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:
704
+
705
+ ```
706
+ plant@1 is not compatible with plant@2: writeSetpoint argument 0 narrowed, so a value the
707
+ caller may send is no longer accepted
708
+ ```
709
+
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.
716
+
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.
719
+
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.
723
+
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.
726
+
727
+ ## Describing a server
728
+
729
+ A server can report what it exposes, so a peer or a person can find out without reading the source.
730
+
731
+ ```typescript
732
+ const server = new RpcServer({ transports: [{ brokerurl }], exposeIntrospection: true })
733
+
734
+ const described = await (await client.proxy<Introspection>('msgrpc')).remote!.describe()
735
+ ```
736
+
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.
740
+
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.
747
+
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.
751
+
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.
755
+
756
+ ## Authentication and authorization
757
+
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.
761
+
762
+ ### Authenticating peers
763
+
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.
767
+
768
+ ```typescript
769
+ const server = new RpcServer({
770
+ transports: [{ port: 7843 }],
771
+ authenticate: async (credentials) => {
772
+ const user = await lookUpToken((credentials as { token?: string }).token)
773
+ return user && { name: user.id, roles: user.roles }
774
+ }
775
+ })
776
+
777
+ const client = new RpcClient('http://localhost:7843', {
778
+ name: 'operator-17', // must equal the identity's name, see below
779
+ credentials: { token: 'a-token' }
780
+ })
781
+ ```
782
+
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.
787
+
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.
792
+
793
+ ### Tokens, without writing the authenticator
794
+
795
+ `createTokenAuthenticator` is the common case packaged: a map from bearer token to the peer it
796
+ admits.
797
+
798
+ ```typescript
799
+ import { createTokenAuthenticator, defaultWebSocketPort, RpcServer } from '@source-repo/rpc'
800
+
801
+ const server = new RpcServer({
802
+ transports: [{ port: defaultWebSocketPort }],
803
+ authenticate: createTokenAuthenticator({
804
+ [process.env.PLANT_TOKEN!]: 'plantServer',
805
+ [process.env.HMI_TOKEN!]: { name: 'hmi', roles: ['operator'] }
806
+ })
807
+ })
808
+ ```
809
+
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.
816
+
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.
819
+
820
+ ### Authorizing calls
821
+
822
+ `authorize` runs for every call and every event subscription. Return false to reject with a
823
+ `Forbidden` error.
824
+
825
+ ```typescript
826
+ const server = new RpcServer({
827
+ transports: [{ port: 7843 }],
828
+ authenticate,
829
+ authorize: ({ identity, instanceName, method, subscription }) => {
830
+ if (subscription) return identity?.roles?.includes('observer') ?? false
831
+ if (instanceName === 'plant' && method.startsWith('write')) return identity?.roles?.includes('engineer') ?? false
832
+ return true
833
+ }
834
+ })
835
+ ```
836
+
837
+ An authorizer that throws denies the call. Failing open would turn a bug in the authorizer into an
838
+ access-control bypass.
839
+
840
+ `requireAuthenticatedPeers` defaults to true when `authenticate` is set, rejecting calls from peers
841
+ no transport can vouch for with an `Unauthorized` error.
842
+
843
+ ### The management surface
844
+
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()`:
847
+
848
+ ```typescript
849
+ const server = new RpcServer({ transports: [{ port: 7843 }], exposeManagement: true })
850
+ ```
851
+
852
+ It is still subject to `authorize`, so you can restrict who may create instances. The `expose*`
853
+ methods are never remotely reachable.
854
+
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.
859
+
860
+ ## MQTT
861
+
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`.
864
+
865
+ ```typescript
866
+ const server = new RpcServer({
867
+ name: 'plantServer',
868
+ transports: [{ brokerurl: 'mqtt://broker:1883', mqtt: { username: 'plant', password: '...' } }]
869
+ })
870
+
871
+ const client = new RpcClient('mqtt://broker:1883', {
872
+ name: 'hmi-1',
873
+ defaultTarget: 'plantServer',
874
+ credentials: { username: 'hmi', password: '...' } // MQTT credentials go to the broker
875
+ })
876
+ ```
877
+
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:
881
+
882
+ ```typescript
883
+ import { MqttTransport } from '@source-repo/rpc'
884
+
885
+ const transport = new MqttTransport('hmi-1', 'mqtt://broker:1883', { prefix: 'site-4' })
886
+ const client = new RpcClient(undefined, { name: 'hmi-1', transport, defaultTarget: 'plantServer' })
887
+ ```
888
+
889
+ The same applies to every `MqttTransportOptions` field: the url form takes the defaults, and
890
+ anything else means constructing the transport yourself.
891
+
892
+ ### Topics
893
+
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.
897
+
898
+ ```
899
+ <prefix>/req/<peer> calls and subscribe requests default prefix msgrpc/v2
900
+ <prefix>/rsp/<peer> results and errors
901
+ <prefix>/evt/<peer> events pushed to a subscriber
902
+ <prefix>/presence/<peer> retained: "online", or "offline" via the last will
903
+ ```
904
+
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.
908
+
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.
912
+
913
+ ### Delivery and sessions
914
+
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:
934
+ >
935
+ > ```typescript
936
+ > const server = new RpcServer({ transports: [{ brokerurl }] })
937
+ > server.exposeClassInstance(new Plant(), 'plant') // before, not after
938
+ > await server.ready()
939
+ > ```
940
+
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.
943
+
944
+ ### Replicas
945
+
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.
953
+
954
+ ### What the broker still has to do
955
+
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:
958
+
959
+ - Set broker credentials or TLS client certificates through the transport's `mqtt` options.
960
+ - Restrict which peer may publish or subscribe to which topic with broker ACLs.
961
+ - `authorize` still runs, but its `source` is only as trustworthy as those ACLs make it.
962
+
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.
966
+
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.
970
+
971
+ ### Signing frames
972
+
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.
976
+
977
+ ```typescript
978
+ import { createHmacSigner, createHmacVerifier } from '@source-repo/rpc'
979
+
980
+ const secrets: Record<string, string> = { 'hmi-1': '...', plantServer: '...' }
981
+ const verify = createHmacVerifier(
982
+ (peer) => secrets[peer],
983
+ (peer) => ({ name: peer, roles: rolesFor(peer) }) // optional: attach roles to the identity
984
+ )
985
+
986
+ const server = new RpcServer({
987
+ name: 'plantServer',
988
+ transports: [{ brokerurl: 'mqtt://broker:1883', sign: createHmacSigner(secrets.plantServer), verify }],
989
+ requireAuthenticatedPeers: true,
990
+ authorize: ({ identity, method }) => (method.startsWith('write') ? !!identity?.roles?.includes('engineer') : true)
991
+ })
992
+
993
+ const client = new RpcClient('mqtt://broker:1883', {
994
+ name: 'hmi-1',
995
+ defaultTarget: 'plantServer',
996
+ sign: createHmacSigner(secrets['hmi-1'])
997
+ })
998
+ ```
999
+
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.
1004
+
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.
1010
+
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.
1015
+
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.
1020
+
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.
1024
+
1025
+ ## Options
1026
+
1027
+ The types are the reference; these are the ones worth explaining. Anything not listed defaults to
1028
+ off or absent.
1029
+
1030
+ ### RpcServerOptions
1031
+
1032
+ | option | default | meaning |
1033
+ | --- | --- | --- |
1034
+ | `name` | `'*'` | how this server is addressed |
1035
+ | `transports` | one socket.io server on port 7843 | see below |
1036
+ | `useMsgPack` | `true` | `false` selects JSON, which cannot carry `Uint8Array` or `Date` |
1037
+ | `readyTimeout` | `30000` | how long `ready()` waits before throwing; `0` waits forever |
1038
+ | `authenticate` / `authorize` | — | see [Authentication and authorization](#authentication-and-authorization) |
1039
+ | `requireAuthenticatedPeers` | on when `authenticate` is set | refuse peers no transport can vouch for |
1040
+ | `schema` | — | describes what methods accept, so arguments can be checked |
1041
+ | `validation` | `'described'` when a schema is given | `'required'` refuses anything undescribed; `'off'` disables |
1042
+ | `validateResults` | `false` | check what handlers return too |
1043
+ | `unknownVersion` | `'allow'` | `'reject'` refuses a caller whose version has no stored history |
1044
+ | `idempotency` | — | where to record what a non-repeatable command did, so a redelivery is answered rather than run again |
1045
+ | `requireExplicitExposure` | `false` | refuse a class that marks no `@rpc` methods |
1046
+ | `exposeManagement` | `false` | publish `manageRpc.createRpcInstance` |
1047
+ | `exposeIntrospection` | `false` | publish `msgrpc.describe()` |
1048
+ | `relay` | `true` | forward frames addressed to another connected peer; `false`, or a predicate per connection |
1049
+ | `callTimeout` | `10000` | for this server's own outgoing calls, via `proxy()` |
1050
+
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.
1056
+
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.
1060
+
1061
+ ### RpcClientOptions
1062
+
1063
+ | option | default | meaning |
1064
+ | --- | --- | --- |
1065
+ | `name` | a UUID | how this client identifies itself; must be unique among peers sharing a server |
1066
+ | `transport` | built from the url | supply one to take full control of the link |
1067
+ | `defaultTarget` | `'*'` | which peer `proxy()` addresses when not told otherwise |
1068
+ | `callTimeout` | `10000` | before rejecting with `Timeout` |
1069
+ | `readyTimeout` | `30000` | before `ready()` throws; `0` waits forever |
1070
+ | `failCallsOnDisconnect` | `true` | reject in-flight calls at once rather than waiting out each timeout |
1071
+ | `credentials` | — | socket.io handshake `auth`, or MQTT broker connect options |
1072
+ | `sign` | — | sign outgoing frames; only meaningful for MQTT |
1073
+ | `schema` | — | declares the contract version this client was built against |
1074
+ | `ca` | — | trust this certificate authority as well as the system ones; verification stays **on** |
1075
+ | `allowInsecureTls` | `false` | accept any certificate on an `https`/`wss`/`mqtts` link; unsafe by design, and it says so |
1076
+
1077
+ ### MqttTransportOptions
1078
+
1079
+ | option | default | meaning |
1080
+ | --- | --- | --- |
1081
+ | `protocol` | `5` | `4` for a broker that does not speak MQTT 5 |
1082
+ | `prefix` | `msgrpc/v2` (`msgrpc/v1` at protocol 4) | topic namespace |
1083
+ | `topic` | the transport's name | peer name to subscribe as |
1084
+ | `qos` | `1` | `0` drops messages silently |
1085
+ | `presence` | `true` | retained last will, which is how peers learn of departures |
1086
+ | `persistentSession` | `false`, `true` for `RpcServer`'s own | queue messages while disconnected |
1087
+ | `sessionExpirySeconds` | `3600` persistent, `60` otherwise, `0` for a replica | bounds that queueing |
1088
+ | `requestExpirySeconds` | `30` | how long the broker holds a request that states no deadline; one from an RPC client carries its caller's, and the expiry follows that |
1089
+ | `allowResponseTopic` | under the prefix | decides whether a request may have its reply published where it asks |
1090
+ | `allowInsecureTls` | `false` | accept any certificate from an `mqtts`/`wss` broker; unsafe by design |
1091
+ | `channels` | all three | which of `req`/`rsp`/`evt` to subscribe to |
1092
+ | `sharedGroup` / `replicaId` | — | see [Replicas](#replicas) |
1093
+ | `sign` / `verify` | — | see [Signing frames](#signing-frames) |
1094
+ | `maxClockSkew` / `maxTrackedNonces` | `60000` / `5000` | replay window and how much of it to remember |
1095
+ | `mqtt` | — | passed to mqtt.js: credentials, TLS, keepalive, clientId |
1096
+
1097
+ ## Browser use
1098
+
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.
1103
+
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).
1106
+
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).
1110
+
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:
1116
+
1117
+ ```
1118
+ Object literal may only specify known properties, and 'port' does not exist in
1119
+ type 'Transport | ConnectServerOptions'
1120
+ ```
1121
+
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.
1125
+
1126
+ ## Peer routing
1127
+
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.
1132
+
1133
+ Peer names must be unique within one graph. Across separate `RpcServer` instances they do not
1134
+ interfere.
1135
+
1136
+ ## Low level: modules
1137
+
1138
+ `RpcServer` and `RpcClient` are assembled from smaller pieces, and the same pieces are available for
1139
+ building something else.
1140
+
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`:
1144
+
1145
+ ```typescript
1146
+ const first = new MyModule()
1147
+ const second = new MyModule()
1148
+ first.pipe(second)
1149
+
1150
+ // The same thing, shorter:
1151
+ const third = new MyModule([first])
1152
+
1153
+ // A module can also pipe into a plain function.
1154
+ first.pipe((message) => console.log('first wanted to send:', message))
1155
+ ```
1156
+
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:
1160
+
1161
+ ```typescript
1162
+ const tryCatch = new TryCatch([source])
1163
+ tryCatch.on('caught', (message, error) => console.log('caught', error))
1164
+ ```
1165
+
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**.
1169
+
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:
1174
+
1175
+ ```typescript
1176
+ import { RpcServerHandler, SocketIoServerTransport } from '@source-repo/rpc'
1177
+
1178
+ const transport = new SocketIoServerTransport('server', undefined, 7843)
1179
+ const handler = new RpcServerHandler('server', [transport]) // transport -> handler
1180
+ handler.pipe(transport) // handler -> transport
1181
+
1182
+ handler.manageRpc.exposeObject({ hello: () => 'world' }, 'greeter')
1183
+ ```
1184
+
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.
1187
+
1188
+ ## Development
1189
+
1190
+ ```
1191
+ npm install
1192
+ npm run build # tsc -> dist/
1193
+ npm test # cleans, builds, then runs ava
1194
+ npm run lint
1195
+ npm run typecheck # src and examples, no emit
1196
+ npm run build:examples # examples/ -> dist-examples/
1197
+ ```
1198
+
1199
+ The MQTT tests need a broker on `localhost:1883` and skip themselves when none is reachable:
1200
+
1201
+ ```
1202
+ docker compose -f docker-compose/docker-compose.yml up -d
1203
+ ```
1204
+
1205
+ Point them at a different broker with `MSGRPC_TEST_BROKER=mqtt://host:1883`.
1206
+
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.