@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.
- package/LICENSE +21 -0
- package/README.md +1208 -0
- package/dist/Logging/ILogger.d.ts +7 -0
- package/dist/Logging/ILogger.d.ts.map +1 -0
- package/dist/Logging/ILogger.js +2 -0
- package/dist/Logging/ILogger.js.map +1 -0
- package/dist/NodeRpcServer.d.ts +55 -0
- package/dist/NodeRpcServer.d.ts.map +1 -0
- package/dist/NodeRpcServer.js +46 -0
- package/dist/NodeRpcServer.js.map +1 -0
- package/dist/RPC/Auth.d.ts +98 -0
- package/dist/RPC/Auth.d.ts.map +1 -0
- package/dist/RPC/Auth.js +14 -0
- package/dist/RPC/Auth.js.map +1 -0
- package/dist/RPC/Codec.d.ts +24 -0
- package/dist/RPC/Codec.d.ts.map +1 -0
- package/dist/RPC/Codec.js +21 -0
- package/dist/RPC/Codec.js.map +1 -0
- package/dist/RPC/Compatibility.d.ts +39 -0
- package/dist/RPC/Compatibility.d.ts.map +1 -0
- package/dist/RPC/Compatibility.js +195 -0
- package/dist/RPC/Compatibility.js.map +1 -0
- package/dist/RPC/Core.d.ts +188 -0
- package/dist/RPC/Core.d.ts.map +1 -0
- package/dist/RPC/Core.js +354 -0
- package/dist/RPC/Core.js.map +1 -0
- package/dist/RPC/Expose.d.ts +90 -0
- package/dist/RPC/Expose.d.ts.map +1 -0
- package/dist/RPC/Expose.js +110 -0
- package/dist/RPC/Expose.js.map +1 -0
- package/dist/RPC/Idempotency.d.ts +90 -0
- package/dist/RPC/Idempotency.d.ts.map +1 -0
- package/dist/RPC/Idempotency.js +46 -0
- package/dist/RPC/Idempotency.js.map +1 -0
- package/dist/RPC/Introspection.d.ts +92 -0
- package/dist/RPC/Introspection.d.ts.map +1 -0
- package/dist/RPC/Introspection.js +188 -0
- package/dist/RPC/Introspection.js.map +1 -0
- package/dist/RPC/Introspection.types.json +1093 -0
- package/dist/RPC/Messages.d.ts +108 -0
- package/dist/RPC/Messages.d.ts.map +1 -0
- package/dist/RPC/Messages.js +30 -0
- package/dist/RPC/Messages.js.map +1 -0
- package/dist/RPC/Rpc.d.ts +51 -0
- package/dist/RPC/Rpc.d.ts.map +1 -0
- package/dist/RPC/Rpc.js +54 -0
- package/dist/RPC/Rpc.js.map +1 -0
- package/dist/RPC/RpcClientHandler.d.ts +129 -0
- package/dist/RPC/RpcClientHandler.d.ts.map +1 -0
- package/dist/RPC/RpcClientHandler.js +267 -0
- package/dist/RPC/RpcClientHandler.js.map +1 -0
- package/dist/RPC/RpcServerHandler.d.ts +226 -0
- package/dist/RPC/RpcServerHandler.d.ts.map +1 -0
- package/dist/RPC/RpcServerHandler.js +676 -0
- package/dist/RPC/RpcServerHandler.js.map +1 -0
- package/dist/RPC/Schema.d.ts +150 -0
- package/dist/RPC/Schema.d.ts.map +1 -0
- package/dist/RPC/Schema.js +198 -0
- package/dist/RPC/Schema.js.map +1 -0
- package/dist/RPC/Signing.d.ts +118 -0
- package/dist/RPC/Signing.d.ts.map +1 -0
- package/dist/RPC/Signing.js +171 -0
- package/dist/RPC/Signing.js.map +1 -0
- package/dist/RPC/Tokens.d.ts +49 -0
- package/dist/RPC/Tokens.d.ts.map +1 -0
- package/dist/RPC/Tokens.js +45 -0
- package/dist/RPC/Tokens.js.map +1 -0
- package/dist/RpcClient.d.ts +91 -0
- package/dist/RpcClient.d.ts.map +1 -0
- package/dist/RpcClient.js +145 -0
- package/dist/RpcClient.js.map +1 -0
- package/dist/RpcServer.d.ts +191 -0
- package/dist/RpcServer.d.ts.map +1 -0
- package/dist/RpcServer.js +293 -0
- package/dist/RpcServer.js.map +1 -0
- package/dist/Transports/Mqtt5Frame.d.ts +107 -0
- package/dist/Transports/Mqtt5Frame.d.ts.map +1 -0
- package/dist/Transports/Mqtt5Frame.js +162 -0
- package/dist/Transports/Mqtt5Frame.js.map +1 -0
- package/dist/Transports/MqttTransport.d.ts +279 -0
- package/dist/Transports/MqttTransport.d.ts.map +1 -0
- package/dist/Transports/MqttTransport.js +843 -0
- package/dist/Transports/MqttTransport.js.map +1 -0
- package/dist/Transports/Presence.d.ts +68 -0
- package/dist/Transports/Presence.d.ts.map +1 -0
- package/dist/Transports/Presence.js +21 -0
- package/dist/Transports/Presence.js.map +1 -0
- package/dist/Transports/SocketIoClientTransport.d.ts +89 -0
- package/dist/Transports/SocketIoClientTransport.d.ts.map +1 -0
- package/dist/Transports/SocketIoClientTransport.js +280 -0
- package/dist/Transports/SocketIoClientTransport.js.map +1 -0
- package/dist/Transports/SocketIoServerTransport.d.ts +135 -0
- package/dist/Transports/SocketIoServerTransport.d.ts.map +1 -0
- package/dist/Transports/SocketIoServerTransport.js +505 -0
- package/dist/Transports/SocketIoServerTransport.js.map +1 -0
- package/dist/Utilities/Converters.d.ts +22 -0
- package/dist/Utilities/Converters.d.ts.map +1 -0
- package/dist/Utilities/Converters.js +58 -0
- package/dist/Utilities/Converters.js.map +1 -0
- package/dist/Utilities/Filter.d.ts +7 -0
- package/dist/Utilities/Filter.d.ts.map +1 -0
- package/dist/Utilities/Filter.js +14 -0
- package/dist/Utilities/Filter.js.map +1 -0
- package/dist/Utilities/ReadableName.d.ts +38 -0
- package/dist/Utilities/ReadableName.d.ts.map +1 -0
- package/dist/Utilities/ReadableName.js +58 -0
- package/dist/Utilities/ReadableName.js.map +1 -0
- package/dist/Utilities/Switch.d.ts +21 -0
- package/dist/Utilities/Switch.d.ts.map +1 -0
- package/dist/Utilities/Switch.js +70 -0
- package/dist/Utilities/Switch.js.map +1 -0
- package/dist/Utilities/TryCatch.d.ts +11 -0
- package/dist/Utilities/TryCatch.d.ts.map +1 -0
- package/dist/Utilities/TryCatch.js +13 -0
- package/dist/Utilities/TryCatch.js.map +1 -0
- package/dist/index-web.d.ts +25 -0
- package/dist/index-web.d.ts.map +1 -0
- package/dist/index-web.js +28 -0
- package/dist/index-web.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- 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.
|