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