@source-repo/rpc 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1208 -0
- package/dist/Logging/ILogger.d.ts +7 -0
- package/dist/Logging/ILogger.d.ts.map +1 -0
- package/dist/Logging/ILogger.js +2 -0
- package/dist/Logging/ILogger.js.map +1 -0
- package/dist/NodeRpcServer.d.ts +55 -0
- package/dist/NodeRpcServer.d.ts.map +1 -0
- package/dist/NodeRpcServer.js +46 -0
- package/dist/NodeRpcServer.js.map +1 -0
- package/dist/RPC/Auth.d.ts +98 -0
- package/dist/RPC/Auth.d.ts.map +1 -0
- package/dist/RPC/Auth.js +14 -0
- package/dist/RPC/Auth.js.map +1 -0
- package/dist/RPC/Codec.d.ts +24 -0
- package/dist/RPC/Codec.d.ts.map +1 -0
- package/dist/RPC/Codec.js +21 -0
- package/dist/RPC/Codec.js.map +1 -0
- package/dist/RPC/Compatibility.d.ts +39 -0
- package/dist/RPC/Compatibility.d.ts.map +1 -0
- package/dist/RPC/Compatibility.js +195 -0
- package/dist/RPC/Compatibility.js.map +1 -0
- package/dist/RPC/Core.d.ts +188 -0
- package/dist/RPC/Core.d.ts.map +1 -0
- package/dist/RPC/Core.js +354 -0
- package/dist/RPC/Core.js.map +1 -0
- package/dist/RPC/Expose.d.ts +90 -0
- package/dist/RPC/Expose.d.ts.map +1 -0
- package/dist/RPC/Expose.js +110 -0
- package/dist/RPC/Expose.js.map +1 -0
- package/dist/RPC/Idempotency.d.ts +90 -0
- package/dist/RPC/Idempotency.d.ts.map +1 -0
- package/dist/RPC/Idempotency.js +46 -0
- package/dist/RPC/Idempotency.js.map +1 -0
- package/dist/RPC/Introspection.d.ts +92 -0
- package/dist/RPC/Introspection.d.ts.map +1 -0
- package/dist/RPC/Introspection.js +188 -0
- package/dist/RPC/Introspection.js.map +1 -0
- package/dist/RPC/Introspection.types.json +1093 -0
- package/dist/RPC/Messages.d.ts +108 -0
- package/dist/RPC/Messages.d.ts.map +1 -0
- package/dist/RPC/Messages.js +30 -0
- package/dist/RPC/Messages.js.map +1 -0
- package/dist/RPC/Rpc.d.ts +51 -0
- package/dist/RPC/Rpc.d.ts.map +1 -0
- package/dist/RPC/Rpc.js +54 -0
- package/dist/RPC/Rpc.js.map +1 -0
- package/dist/RPC/RpcClientHandler.d.ts +129 -0
- package/dist/RPC/RpcClientHandler.d.ts.map +1 -0
- package/dist/RPC/RpcClientHandler.js +267 -0
- package/dist/RPC/RpcClientHandler.js.map +1 -0
- package/dist/RPC/RpcServerHandler.d.ts +226 -0
- package/dist/RPC/RpcServerHandler.d.ts.map +1 -0
- package/dist/RPC/RpcServerHandler.js +676 -0
- package/dist/RPC/RpcServerHandler.js.map +1 -0
- package/dist/RPC/Schema.d.ts +150 -0
- package/dist/RPC/Schema.d.ts.map +1 -0
- package/dist/RPC/Schema.js +198 -0
- package/dist/RPC/Schema.js.map +1 -0
- package/dist/RPC/Signing.d.ts +118 -0
- package/dist/RPC/Signing.d.ts.map +1 -0
- package/dist/RPC/Signing.js +171 -0
- package/dist/RPC/Signing.js.map +1 -0
- package/dist/RPC/Tokens.d.ts +49 -0
- package/dist/RPC/Tokens.d.ts.map +1 -0
- package/dist/RPC/Tokens.js +45 -0
- package/dist/RPC/Tokens.js.map +1 -0
- package/dist/RpcClient.d.ts +91 -0
- package/dist/RpcClient.d.ts.map +1 -0
- package/dist/RpcClient.js +145 -0
- package/dist/RpcClient.js.map +1 -0
- package/dist/RpcServer.d.ts +191 -0
- package/dist/RpcServer.d.ts.map +1 -0
- package/dist/RpcServer.js +293 -0
- package/dist/RpcServer.js.map +1 -0
- package/dist/Transports/Mqtt5Frame.d.ts +107 -0
- package/dist/Transports/Mqtt5Frame.d.ts.map +1 -0
- package/dist/Transports/Mqtt5Frame.js +162 -0
- package/dist/Transports/Mqtt5Frame.js.map +1 -0
- package/dist/Transports/MqttTransport.d.ts +279 -0
- package/dist/Transports/MqttTransport.d.ts.map +1 -0
- package/dist/Transports/MqttTransport.js +843 -0
- package/dist/Transports/MqttTransport.js.map +1 -0
- package/dist/Transports/Presence.d.ts +68 -0
- package/dist/Transports/Presence.d.ts.map +1 -0
- package/dist/Transports/Presence.js +21 -0
- package/dist/Transports/Presence.js.map +1 -0
- package/dist/Transports/SocketIoClientTransport.d.ts +89 -0
- package/dist/Transports/SocketIoClientTransport.d.ts.map +1 -0
- package/dist/Transports/SocketIoClientTransport.js +280 -0
- package/dist/Transports/SocketIoClientTransport.js.map +1 -0
- package/dist/Transports/SocketIoServerTransport.d.ts +135 -0
- package/dist/Transports/SocketIoServerTransport.d.ts.map +1 -0
- package/dist/Transports/SocketIoServerTransport.js +505 -0
- package/dist/Transports/SocketIoServerTransport.js.map +1 -0
- package/dist/Utilities/Converters.d.ts +22 -0
- package/dist/Utilities/Converters.d.ts.map +1 -0
- package/dist/Utilities/Converters.js +58 -0
- package/dist/Utilities/Converters.js.map +1 -0
- package/dist/Utilities/Filter.d.ts +7 -0
- package/dist/Utilities/Filter.d.ts.map +1 -0
- package/dist/Utilities/Filter.js +14 -0
- package/dist/Utilities/Filter.js.map +1 -0
- package/dist/Utilities/ReadableName.d.ts +38 -0
- package/dist/Utilities/ReadableName.d.ts.map +1 -0
- package/dist/Utilities/ReadableName.js +58 -0
- package/dist/Utilities/ReadableName.js.map +1 -0
- package/dist/Utilities/Switch.d.ts +21 -0
- package/dist/Utilities/Switch.d.ts.map +1 -0
- package/dist/Utilities/Switch.js +70 -0
- package/dist/Utilities/Switch.js.map +1 -0
- package/dist/Utilities/TryCatch.d.ts +11 -0
- package/dist/Utilities/TryCatch.d.ts.map +1 -0
- package/dist/Utilities/TryCatch.js +13 -0
- package/dist/Utilities/TryCatch.js.map +1 -0
- package/dist/index-web.d.ts +25 -0
- package/dist/index-web.d.ts.map +1 -0
- package/dist/index-web.js +28 -0
- package/dist/index-web.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { MessageModule, MessageType } from './Core.js';
|
|
2
|
+
import { RpcMessageType, toRemoteError } from './Messages.js';
|
|
3
|
+
export * from './Messages.js';
|
|
4
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
+
import { isFailedOutcome } from './Idempotency.js';
|
|
6
|
+
import EventEmitter from 'events';
|
|
7
|
+
import { declaredNamespace, declaredSemantics, markedMethods } from './Expose.js';
|
|
8
|
+
import { validateParams, validateValue } from './Schema.js';
|
|
9
|
+
import { describeProblems, namespaceProblems } from './Compatibility.js';
|
|
10
|
+
export class EventProxy {
|
|
11
|
+
rpcServer;
|
|
12
|
+
instance;
|
|
13
|
+
event;
|
|
14
|
+
target;
|
|
15
|
+
instanceName;
|
|
16
|
+
/**
|
|
17
|
+
* Held so the listener can be removed again. Binding at registration time produced a fresh
|
|
18
|
+
* function that removeListener() could never match.
|
|
19
|
+
*/
|
|
20
|
+
listener = (...args) => {
|
|
21
|
+
// Caught here, because an emit is not a call: nothing above this is awaiting the delivery,
|
|
22
|
+
// so a transport that cannot publish - a broker that has gone away, most obviously - would
|
|
23
|
+
// otherwise reject a promise nobody holds, and Node ends the process on one of those.
|
|
24
|
+
void this.rpcServer.sendEvent(this.target, this.event, args, this.instanceName).catch((e) => this.rpcServer.emit('deliveryError', { target: this.target, event: this.event, path: this.instanceName, error: e }));
|
|
25
|
+
};
|
|
26
|
+
constructor(rpcServer, instance, event, target, instanceName) {
|
|
27
|
+
this.rpcServer = rpcServer;
|
|
28
|
+
this.instance = instance;
|
|
29
|
+
this.event = event;
|
|
30
|
+
this.target = target;
|
|
31
|
+
this.instanceName = instanceName;
|
|
32
|
+
}
|
|
33
|
+
attach() {
|
|
34
|
+
this.instance.on(this.event, this.listener);
|
|
35
|
+
}
|
|
36
|
+
detach() {
|
|
37
|
+
this.instance.removeListener(this.event, this.listener);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Identifies one subscription. Previously a Map keyed by a freshly allocated object literal, so
|
|
42
|
+
* lookups compared by reference and never matched - every repeated on() stacked another listener.
|
|
43
|
+
*/
|
|
44
|
+
const eventProxyKey = (instanceName, event, source) => `${instanceName}\u0000${event}\u0000${source}`;
|
|
45
|
+
const isRpcCallInstanceMethodPayload = (payload) => {
|
|
46
|
+
return payload.type === RpcMessageType.CallInstanceMethod;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The codes a method may answer with by throwing an error that carries one.
|
|
50
|
+
*
|
|
51
|
+
* Everything a method threw used to come back as `Exception`, so a service that wanted to say
|
|
52
|
+
* "you may not do that" could say it only in the message - and a caller reading `code` to decide
|
|
53
|
+
* whether to retry, re-authenticate or give up learned nothing. Restricted to the codes the
|
|
54
|
+
* protocol already defines, so an error carrying an unrelated `code` - a Node `ENOENT`, say - is
|
|
55
|
+
* still reported as the exception it is.
|
|
56
|
+
*/
|
|
57
|
+
const CHOSEN_CODES = new Set([
|
|
58
|
+
'Unauthorized',
|
|
59
|
+
'Forbidden',
|
|
60
|
+
'InvalidParams',
|
|
61
|
+
'IncompatibleVersion',
|
|
62
|
+
'ClassNotFound',
|
|
63
|
+
'MethodNotFound',
|
|
64
|
+
'TransportError',
|
|
65
|
+
'Timeout',
|
|
66
|
+
// A method that talked to something else and did not learn the answer may say so. It is the
|
|
67
|
+
// honest reply from a gateway whose own downstream call timed out, and inventing a result or
|
|
68
|
+
// reporting a plain exception would both claim more than is known.
|
|
69
|
+
'UnknownOutcome'
|
|
70
|
+
]);
|
|
71
|
+
const chosenCode = (e) => {
|
|
72
|
+
const code = e?.code;
|
|
73
|
+
return typeof code === 'string' && CHOSEN_CODES.has(code) ? code : 'Exception';
|
|
74
|
+
};
|
|
75
|
+
export class RpcServerHandler extends MessageModule {
|
|
76
|
+
manageRpc = new ManageRpc();
|
|
77
|
+
eventProxies = new Map();
|
|
78
|
+
/** Decides whether a call may proceed. When unset, every call that resolves is allowed. */
|
|
79
|
+
authorize;
|
|
80
|
+
/** Supplied by RpcServer; asks the transports which identity a peer name is bound to. */
|
|
81
|
+
resolveIdentity;
|
|
82
|
+
/** Reject calls from peers no transport can vouch for. */
|
|
83
|
+
requireIdentity = false;
|
|
84
|
+
/**
|
|
85
|
+
* Suppress duplicate requests. MQTT at QoS 1 is at-least-once, so the same request can be
|
|
86
|
+
* delivered twice; without this a retransmission would run the method a second time, which is
|
|
87
|
+
* not something a caller can undo. A duplicate is answered from the cache instead.
|
|
88
|
+
*/
|
|
89
|
+
deduplicateRequests = true;
|
|
90
|
+
/** How many recent request ids to remember. Oldest are evicted first. */
|
|
91
|
+
maxTrackedRequests = 1000;
|
|
92
|
+
/**
|
|
93
|
+
* Refuse a call whose caller has already stopped waiting for it, instead of running it late.
|
|
94
|
+
*
|
|
95
|
+
* A request carries the milliseconds its caller intended to wait, and that budget is counted
|
|
96
|
+
* from the moment this process received the frame. A read arriving late only wastes work; a
|
|
97
|
+
* command does not - the operator saw a timeout, did something else about it, and then the
|
|
98
|
+
* original 'start pump' runs anyway. On by default, because that outcome is worse than the
|
|
99
|
+
* failed call it replaces.
|
|
100
|
+
*/
|
|
101
|
+
refuseExpiredCalls = true;
|
|
102
|
+
/**
|
|
103
|
+
* Where to record what a non-repeatable command did, so a redelivery after a crash is answered
|
|
104
|
+
* rather than executed a second time. Unset means execution is at least once - see
|
|
105
|
+
* RPC/Idempotency.ts, which spells out exactly what that leaves open.
|
|
106
|
+
*/
|
|
107
|
+
idempotency;
|
|
108
|
+
/** Describes what exposed methods accept. Absent means nothing is checked. */
|
|
109
|
+
schema;
|
|
110
|
+
/**
|
|
111
|
+
* 'described' checks calls into namespaces the schema covers and lets the rest through, so a
|
|
112
|
+
* partial schema is useful. 'required' refuses anything the schema does not describe.
|
|
113
|
+
*/
|
|
114
|
+
validation = 'described';
|
|
115
|
+
/** Check what handlers return as well as what callers send. Off by default: it is a self-check. */
|
|
116
|
+
validateResults = false;
|
|
117
|
+
/**
|
|
118
|
+
* What to do with a caller declaring a contract version this schema has no history for.
|
|
119
|
+
* 'allow' by default, because truncating history is a legitimate operational choice and should
|
|
120
|
+
* not strand peers.
|
|
121
|
+
*/
|
|
122
|
+
unknownVersion = 'allow';
|
|
123
|
+
/** Verdicts by namespace and declared version, so the comparison runs once per peer, not per call. */
|
|
124
|
+
compatibility = new Map();
|
|
125
|
+
recentResponses = new Map();
|
|
126
|
+
inFlightRequests = new Set();
|
|
127
|
+
constructor(name, sources) {
|
|
128
|
+
super(name, sources);
|
|
129
|
+
this.manageRpc.logger?.log('Information', 'RpcServerHandler {name} starting', { name });
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Gate a call before it reaches an exposed method. Returns an error code to reject with, or
|
|
133
|
+
* undefined to allow. Subscriptions go through here too: without that, anyone could attach to
|
|
134
|
+
* an instance's events and receive everything it emits.
|
|
135
|
+
*/
|
|
136
|
+
async checkAccess(payload, source, subscription) {
|
|
137
|
+
const identity = this.resolveIdentity?.(source);
|
|
138
|
+
if (this.requireIdentity && !identity)
|
|
139
|
+
return 'Unauthorized';
|
|
140
|
+
if (!this.authorize)
|
|
141
|
+
return undefined;
|
|
142
|
+
const context = {
|
|
143
|
+
identity,
|
|
144
|
+
source,
|
|
145
|
+
instanceName: payload.path,
|
|
146
|
+
method: payload.method,
|
|
147
|
+
params: payload.params,
|
|
148
|
+
subscription
|
|
149
|
+
};
|
|
150
|
+
try {
|
|
151
|
+
return (await this.authorize(context)) ? undefined : 'Forbidden';
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// An authorizer that throws denies. Failing open here would turn a bug in the
|
|
155
|
+
// authorizer into an access-control bypass.
|
|
156
|
+
return 'Forbidden';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Returns a reason to refuse the call, or undefined. The namespace's contract version rides
|
|
161
|
+
* along in the message, because a caller built against an older contract otherwise looks
|
|
162
|
+
* indistinguishable from one sending plain rubbish.
|
|
163
|
+
*/
|
|
164
|
+
checkParams(payload) {
|
|
165
|
+
if (!this.schema || this.validation === 'off')
|
|
166
|
+
return undefined;
|
|
167
|
+
const namespace = this.schema.namespaces[payload.path];
|
|
168
|
+
if (!namespace)
|
|
169
|
+
return this.validation === 'required' ? `${payload.path} is not described by the schema` : undefined;
|
|
170
|
+
if (namespace.validate === false)
|
|
171
|
+
return undefined;
|
|
172
|
+
const method = namespace.methods[payload.method];
|
|
173
|
+
if (!method)
|
|
174
|
+
return this.validation === 'required' ? `${payload.path}.${payload.method} is not described by the schema` : undefined;
|
|
175
|
+
const failure = validateParams(payload.params, method, this.schema.types);
|
|
176
|
+
if (!failure)
|
|
177
|
+
return undefined;
|
|
178
|
+
return namespace.version ? `${failure} (this server serves ${payload.path}@${namespace.version})` : failure;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Compares the contract a caller declares with the one served here. Only a genuine structural
|
|
182
|
+
* incompatibility is refused: a caller whose contract still holds keeps working, which is why
|
|
183
|
+
* this compares schemas rather than gating on a version or a hash being equal.
|
|
184
|
+
*/
|
|
185
|
+
checkVersion(payload) {
|
|
186
|
+
if (!this.schema || this.validation === 'off' || !payload.version)
|
|
187
|
+
return undefined;
|
|
188
|
+
const namespace = this.schema.namespaces[payload.path];
|
|
189
|
+
if (!namespace || payload.version === namespace.version)
|
|
190
|
+
return undefined;
|
|
191
|
+
const key = `${payload.path}@${payload.version}`;
|
|
192
|
+
if (this.compatibility.has(key))
|
|
193
|
+
return this.compatibility.get(key);
|
|
194
|
+
const previous = namespace.history?.[payload.version];
|
|
195
|
+
let verdict;
|
|
196
|
+
if (!previous) {
|
|
197
|
+
verdict =
|
|
198
|
+
this.unknownVersion === 'reject'
|
|
199
|
+
? `${payload.path}@${payload.version} is unknown to this server, which serves ${payload.path}@${namespace.version ?? 'an unversioned contract'}`
|
|
200
|
+
: undefined;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
verdict = describeProblems(payload.path, payload.version, namespace.version, namespaceProblems(previous, namespace, this.schema.types));
|
|
204
|
+
}
|
|
205
|
+
this.compatibility.set(key, verdict);
|
|
206
|
+
return verdict;
|
|
207
|
+
}
|
|
208
|
+
async receive(message, source, target) {
|
|
209
|
+
this.manageRpc.logger?.log('Debug', 'RpcServerHandler {name} received message type {type} from {source} to {target}: {message}', {
|
|
210
|
+
name: this.name,
|
|
211
|
+
type: message.type,
|
|
212
|
+
source,
|
|
213
|
+
target,
|
|
214
|
+
message: JSON.stringify(message)
|
|
215
|
+
});
|
|
216
|
+
// Deliberately not awaited: awaiting would serialise every request arriving on one
|
|
217
|
+
// connection behind the slowest handler. The catch is what stops an unawaited promise
|
|
218
|
+
// becoming an unhandled rejection - receivePayload answers the caller itself, so reaching
|
|
219
|
+
// here at all means even that failed.
|
|
220
|
+
if (message.payload)
|
|
221
|
+
void this.receivePayload(message.payload, source, target).catch((e) => this.emit('handlerError', { source, target, error: e }));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Send a response and remember it against its request id, so a redelivered request can be
|
|
225
|
+
* answered without running the method again.
|
|
226
|
+
*/
|
|
227
|
+
respond(requestId, source, response, messageType) {
|
|
228
|
+
if (this.deduplicateRequests) {
|
|
229
|
+
this.inFlightRequests.delete(requestId);
|
|
230
|
+
this.recentResponses.set(requestId, response);
|
|
231
|
+
while (this.recentResponses.size > this.maxTrackedRequests) {
|
|
232
|
+
const oldest = this.recentResponses.keys().next();
|
|
233
|
+
if (oldest.done)
|
|
234
|
+
break;
|
|
235
|
+
this.recentResponses.delete(oldest.value);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return this.sendPayload(response, messageType, this.name, source);
|
|
239
|
+
}
|
|
240
|
+
async receivePayload(payload, source, target) {
|
|
241
|
+
try {
|
|
242
|
+
await this.dispatch(payload, source, target);
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
// Anything that escaped dispatch - a transport that could not publish the reply, a
|
|
246
|
+
// schema check that threw, a bug here - used to be an unhandled rejection, and the
|
|
247
|
+
// caller learned nothing until its own timeout expired. Tell it what happened instead.
|
|
248
|
+
await this.reportDispatchFailure(payload, source, e);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Last resort when dispatch itself failed. Answers the caller if the request can still be
|
|
253
|
+
* identified, and never throws: the thing that failed may well be the transport this reply
|
|
254
|
+
* would go out on, and a failure to report a failure has nowhere left to go.
|
|
255
|
+
*/
|
|
256
|
+
async reportDispatchFailure(payload, source, error) {
|
|
257
|
+
this.emit('handlerError', { source, payload, error });
|
|
258
|
+
const id = payload.id;
|
|
259
|
+
if (!isRpcCallInstanceMethodPayload(payload) || !id)
|
|
260
|
+
return;
|
|
261
|
+
if (this.deduplicateRequests)
|
|
262
|
+
this.inFlightRequests.delete(id);
|
|
263
|
+
try {
|
|
264
|
+
await this.sendPayload({ type: RpcMessageType.error, id, code: 'Exception', error: toRemoteError(error) }, MessageType.ErrorMessage, this.name, source);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// The link is gone; the caller's timeout is all that is left.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
271
|
+
async dispatch(payload, source, target) {
|
|
272
|
+
// Stamped here, on arrival, and by this process's own clock. Comparing the caller's clock
|
|
273
|
+
// with this one would need the two to agree, which over MQTT they broadly do and from a
|
|
274
|
+
// browser they do not.
|
|
275
|
+
const arrived = Date.now();
|
|
276
|
+
if (isRpcCallInstanceMethodPayload(payload)) {
|
|
277
|
+
if (this.deduplicateRequests) {
|
|
278
|
+
const cached = this.recentResponses.get(payload.id);
|
|
279
|
+
if (cached) {
|
|
280
|
+
const messageType = cached.code ? MessageType.ErrorMessage : MessageType.ResponseMessage;
|
|
281
|
+
await this.sendPayload(cached, messageType, this.name, source);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Still running: the original call will answer, so drop the redelivery.
|
|
285
|
+
if (this.inFlightRequests.has(payload.id))
|
|
286
|
+
return;
|
|
287
|
+
this.inFlightRequests.add(payload.id);
|
|
288
|
+
}
|
|
289
|
+
// Deliberately not getNameSpaceMethodMap(): that creates a map on miss, which would let
|
|
290
|
+
// unknown remote paths grow exposedNameSpaceMethodMaps without bound.
|
|
291
|
+
const map = this.manageRpc.findNameSpaceMethodMap(payload.path);
|
|
292
|
+
const handler = map?.get(payload.method);
|
|
293
|
+
if (!handler) {
|
|
294
|
+
const instanceName = payload.path;
|
|
295
|
+
const event = payload.params[0];
|
|
296
|
+
const inst = this.manageRpc.exposedNameSpaceInstances[instanceName];
|
|
297
|
+
if (payload.method === 'on' && inst instanceof EventEmitter) {
|
|
298
|
+
const denied = await this.checkAccess(payload, source, true);
|
|
299
|
+
if (denied) {
|
|
300
|
+
await this.sendError(payload.id, source, denied, `not permitted to subscribe to ${payload.path}.${event}`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Idempotent: a client replaying its subscriptions after a reconnect must not
|
|
304
|
+
// stack a second listener for a subscription the server already holds.
|
|
305
|
+
const eventKey = eventProxyKey(instanceName, event, source);
|
|
306
|
+
let eventProxy = this.eventProxies.get(eventKey);
|
|
307
|
+
let result = 'ok - already exists';
|
|
308
|
+
if (!eventProxy) {
|
|
309
|
+
eventProxy = new EventProxy(this, inst, event, source, instanceName);
|
|
310
|
+
this.eventProxies.set(eventKey, eventProxy);
|
|
311
|
+
eventProxy.attach();
|
|
312
|
+
result = 'ok';
|
|
313
|
+
}
|
|
314
|
+
await this.respond(payload.id, source, { type: RpcMessageType.success, result, id: payload.id }, MessageType.ResponseMessage);
|
|
315
|
+
}
|
|
316
|
+
else if ((payload.method === 'off' || payload.method === 'removeListener') && inst instanceof EventEmitter) {
|
|
317
|
+
// Deliberately not authorized. The key includes the caller, so a peer can only
|
|
318
|
+
// drop its own subscription, and refusing to let someone stop receiving events
|
|
319
|
+
// would be a strange thing to enforce.
|
|
320
|
+
const eventKey = eventProxyKey(instanceName, event, source);
|
|
321
|
+
const eventProxy = this.eventProxies.get(eventKey);
|
|
322
|
+
if (eventProxy) {
|
|
323
|
+
eventProxy.detach();
|
|
324
|
+
this.eventProxies.delete(eventKey);
|
|
325
|
+
}
|
|
326
|
+
await this.respond(payload.id, source, { type: RpcMessageType.success, result: eventProxy ? 'ok' : 'ok - was not subscribed', id: payload.id }, MessageType.ResponseMessage);
|
|
327
|
+
}
|
|
328
|
+
else
|
|
329
|
+
await this.sendError(payload.id, source, map ? 'MethodNotFound' : 'ClassNotFound', `${payload.path}.${payload.method} is not exposed`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const denied = await this.checkAccess(payload, source, false);
|
|
333
|
+
if (denied) {
|
|
334
|
+
await this.sendError(payload.id, source, denied, `not permitted to call ${payload.path}.${payload.method}`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// After authorization: no point spending the check on a caller already being refused.
|
|
338
|
+
const incompatible = this.checkVersion(payload);
|
|
339
|
+
if (incompatible) {
|
|
340
|
+
await this.sendError(payload.id, source, 'IncompatibleVersion', incompatible);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const invalid = this.checkParams(payload);
|
|
344
|
+
if (invalid) {
|
|
345
|
+
await this.sendError(payload.id, source, 'InvalidParams', invalid);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// The queue, when this instance asked for one, wraps everything from here on: the
|
|
349
|
+
// deadline has to be read after waiting in it, since that wait is exactly what it
|
|
350
|
+
// exists to catch, and a duplicate has to be recognised before a sibling call can start
|
|
351
|
+
// running the same command alongside it.
|
|
352
|
+
const key = this.executionKey(payload, source);
|
|
353
|
+
if (key === undefined)
|
|
354
|
+
await this.invoke(payload, source, arrived, handler);
|
|
355
|
+
else
|
|
356
|
+
await this.serialise(key, () => this.invoke(payload, source, arrived, handler));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Run one call: check the budget, claim the command, invoke it, answer.
|
|
361
|
+
*
|
|
362
|
+
* Split out of dispatch because it is what a serial instance runs one at a time, and because
|
|
363
|
+
* the order of these four is the whole of the delivery semantics.
|
|
364
|
+
*/
|
|
365
|
+
async invoke(payload, source, arrived, handler) {
|
|
366
|
+
const spent = this.expiredBy(payload, arrived);
|
|
367
|
+
if (spent !== undefined) {
|
|
368
|
+
await this.sendError(payload.id, source, 'Timeout', `${payload.path}.${payload.method} was not run: its caller stopped waiting ${spent} ms ago`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const invocation = this.invocationFor(payload, source);
|
|
372
|
+
let claimed = false;
|
|
373
|
+
if (invocation) {
|
|
374
|
+
let claim;
|
|
375
|
+
try {
|
|
376
|
+
claim = await this.idempotency.begin(invocation);
|
|
377
|
+
}
|
|
378
|
+
catch (e) {
|
|
379
|
+
// Refused rather than run. A store that cannot be reached is the one condition under
|
|
380
|
+
// which running would risk the double execution it was installed to prevent, and an
|
|
381
|
+
// operator would rather see a command refused than a pump started twice.
|
|
382
|
+
await this.sendError(payload.id, source, 'UnknownOutcome', `${payload.path}.${payload.method} was not run: its idempotency store could not be reached (${e instanceof Error ? e.message : String(e)})`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Somebody else holds this command. They will answer the caller, and two answers to one
|
|
386
|
+
// request would be worse than waiting for theirs.
|
|
387
|
+
if (claim === 'in-progress') {
|
|
388
|
+
this.inFlightRequests.delete(payload.id);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (claim !== 'acquired') {
|
|
392
|
+
// It already ran. This is what it answered, sent again without running anything.
|
|
393
|
+
const stored = isFailedOutcome(claim)
|
|
394
|
+
? { type: RpcMessageType.error, id: payload.id, code: claim.code, error: claim.error }
|
|
395
|
+
: { type: RpcMessageType.success, id: payload.id, result: claim.result };
|
|
396
|
+
await this.respond(payload.id, source, stored, stored.type === RpcMessageType.error ? MessageType.ErrorMessage : MessageType.ResponseMessage);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
claimed = true;
|
|
400
|
+
}
|
|
401
|
+
const params = [...payload.params];
|
|
402
|
+
let result;
|
|
403
|
+
try {
|
|
404
|
+
result = await handler(...params);
|
|
405
|
+
const badResult = this.checkResult(payload, result);
|
|
406
|
+
if (badResult) {
|
|
407
|
+
// Recorded as the failure it is: the method ran, so a redelivery must not run it
|
|
408
|
+
// again just because this server disliked what it returned.
|
|
409
|
+
await this.settle(invocation, claimed, { code: 'InvalidParams', error: { name: 'RpcError', message: badResult } });
|
|
410
|
+
await this.sendError(payload.id, source, 'InvalidParams', badResult);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
await this.settle(invocation, claimed, { result });
|
|
414
|
+
await this.respond(payload.id, source, { type: RpcMessageType.success, id: payload.id, result }, MessageType.ResponseMessage);
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
const code = chosenCode(e);
|
|
418
|
+
await this.settle(invocation, claimed, { code, error: toRemoteError(e) });
|
|
419
|
+
await this.respond(payload.id, source, { type: RpcMessageType.error, id: payload.id, code, error: toRemoteError(e) }, MessageType.ErrorMessage);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Write down what the command did, before its answer is sent.
|
|
424
|
+
*
|
|
425
|
+
* This order is the one that matters. Recording after answering would leave a window in which
|
|
426
|
+
* the caller has the result and the store does not, so a redelivery arriving in that window
|
|
427
|
+
* runs the command again - which is the whole failure this is here to prevent. A store that
|
|
428
|
+
* cannot record it is reported and the answer still goes out: the command has already run, and
|
|
429
|
+
* withholding the result would not un-run it.
|
|
430
|
+
*/
|
|
431
|
+
async settle(invocation, claimed, outcome) {
|
|
432
|
+
if (!invocation || !claimed)
|
|
433
|
+
return;
|
|
434
|
+
try {
|
|
435
|
+
await this.idempotency.complete(invocation, outcome);
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
this.emit('idempotencyError', { invocation, outcome, error: e });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/** The command a call is an attempt at, or undefined when nothing here needs to know. */
|
|
442
|
+
invocationFor(payload, source) {
|
|
443
|
+
// Only where a repeat would cost something. A store round trip on every read would be paid
|
|
444
|
+
// by the calls that least need it.
|
|
445
|
+
if (!this.idempotency || this.semanticsOf(payload) !== 'non-repeatable-command')
|
|
446
|
+
return undefined;
|
|
447
|
+
return {
|
|
448
|
+
idempotencyKey: payload.idempotencyKey ?? payload.id,
|
|
449
|
+
requestId: payload.id,
|
|
450
|
+
scope: `${payload.path}.${payload.method}`,
|
|
451
|
+
source,
|
|
452
|
+
...(payload.ttl !== undefined ? { ttl: payload.ttl } : {})
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* What a method says it does to the world: the running class first, then the schema.
|
|
457
|
+
*
|
|
458
|
+
* The class wins because it is what will actually run. A schema is a description, and a server
|
|
459
|
+
* whose description has drifted from its code should act on the code.
|
|
460
|
+
*/
|
|
461
|
+
semanticsOf(payload) {
|
|
462
|
+
return this.manageRpc.exposedSemantics[payload.path]?.get(payload.method) ?? this.schema?.namespaces[payload.path]?.methods[payload.method]?.semantics;
|
|
463
|
+
}
|
|
464
|
+
/** The queue a call belongs in, or undefined when this instance lets calls overlap. */
|
|
465
|
+
executionKey(payload, source) {
|
|
466
|
+
const execution = this.manageRpc.exposedExecution[payload.path];
|
|
467
|
+
if (!execution || execution === 'parallel')
|
|
468
|
+
return undefined;
|
|
469
|
+
if (execution === 'serial')
|
|
470
|
+
return payload.path;
|
|
471
|
+
const context = {
|
|
472
|
+
identity: this.resolveIdentity?.(source),
|
|
473
|
+
source,
|
|
474
|
+
instanceName: payload.path,
|
|
475
|
+
method: payload.method,
|
|
476
|
+
params: payload.params,
|
|
477
|
+
subscription: false
|
|
478
|
+
};
|
|
479
|
+
try {
|
|
480
|
+
return `${payload.path}\u0000${execution(context)}`;
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// A key function that throws serialises the whole instance rather than letting the call
|
|
484
|
+
// run unordered. The safe reading of "I cannot tell you which queue" is the strictest one.
|
|
485
|
+
return payload.path;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/** One promise chain per key. Entries go away once nothing is waiting, so idle costs nothing. */
|
|
489
|
+
executionQueues = new Map();
|
|
490
|
+
async serialise(key, run) {
|
|
491
|
+
const ahead = this.executionQueues.get(key) ?? Promise.resolve();
|
|
492
|
+
let release;
|
|
493
|
+
const mine = new Promise((resolve) => (release = resolve));
|
|
494
|
+
// The tail is what the next arrival waits behind. It only ever resolves - release() is in a
|
|
495
|
+
// finally - so a method that throws cannot leave the queue permanently rejected.
|
|
496
|
+
const tail = ahead.then(() => mine);
|
|
497
|
+
this.executionQueues.set(key, tail);
|
|
498
|
+
await ahead;
|
|
499
|
+
try {
|
|
500
|
+
return await run();
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
release();
|
|
504
|
+
if (this.executionQueues.get(key) === tail)
|
|
505
|
+
this.executionQueues.delete(key);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* How long ago the caller's budget ran out, or undefined while it still has one.
|
|
510
|
+
*
|
|
511
|
+
* The budget starts when the frame arrived here, so it covers the wait in front of this method
|
|
512
|
+
* and nothing before it. What came before is the transport's to account for: MQTT 5 hands the
|
|
513
|
+
* broker the same budget as a message expiry, so a request that sat in a queue arrives already
|
|
514
|
+
* shortened, or does not arrive at all.
|
|
515
|
+
*/
|
|
516
|
+
expiredBy(payload, arrived) {
|
|
517
|
+
if (!this.refuseExpiredCalls || payload.ttl === undefined)
|
|
518
|
+
return undefined;
|
|
519
|
+
if (!Number.isFinite(payload.ttl) || payload.ttl < 0)
|
|
520
|
+
return undefined;
|
|
521
|
+
const overdue = Date.now() - (arrived + payload.ttl);
|
|
522
|
+
return overdue > 0 ? overdue : undefined;
|
|
523
|
+
}
|
|
524
|
+
/** Catches this server returning something its own contract does not allow. */
|
|
525
|
+
checkResult(payload, result) {
|
|
526
|
+
if (!this.validateResults || !this.schema)
|
|
527
|
+
return undefined;
|
|
528
|
+
const returns = this.schema.namespaces[payload.path]?.methods[payload.method]?.returns;
|
|
529
|
+
if (!returns)
|
|
530
|
+
return undefined;
|
|
531
|
+
const failure = validateValue(result, returns, this.schema.types, 'result');
|
|
532
|
+
return failure ? `${payload.path}.${payload.method} returned a value its own schema forbids: ${failure}` : undefined;
|
|
533
|
+
}
|
|
534
|
+
sendError(id, target, code, message) {
|
|
535
|
+
return this.respond(id, target, { type: RpcMessageType.error, id, code, error: { name: 'RpcError', message } }, MessageType.ErrorMessage);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Release every event subscription held for a peer that has gone away. Without this the
|
|
539
|
+
* exposed instance keeps the listener forever and each emit produces an undeliverable frame.
|
|
540
|
+
*/
|
|
541
|
+
removePeer(source) {
|
|
542
|
+
for (const [key, eventProxy] of this.eventProxies) {
|
|
543
|
+
if (eventProxy.target !== source)
|
|
544
|
+
continue;
|
|
545
|
+
eventProxy.detach();
|
|
546
|
+
this.eventProxies.delete(key);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async sendEvent(target, event, params, path) {
|
|
550
|
+
return await this.sendPayload({ type: RpcMessageType.event, event, params, path }, MessageType.EventMessage, this.name, target);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
554
|
+
class DummyLogger {
|
|
555
|
+
log(level, messageTemplate, properties) {
|
|
556
|
+
console.log(`LEVEL ${level.toString()}, messageTemplate: ${messageTemplate}, properties: "${properties ? JSON.stringify(properties) : '<empty>'}"`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
export class ManageRpc {
|
|
560
|
+
logger;
|
|
561
|
+
exposedNameSpaceMethodMaps = {};
|
|
562
|
+
exposedNameSpaceInstances = {};
|
|
563
|
+
exposedClasses = {};
|
|
564
|
+
createdInstances = new Map();
|
|
565
|
+
/** What each exposed namespace's methods declare about repeating them. */
|
|
566
|
+
exposedSemantics = {};
|
|
567
|
+
/** How each exposed namespace lets its calls overlap. Absent is `parallel`. */
|
|
568
|
+
exposedExecution = {};
|
|
569
|
+
constructor(logger) {
|
|
570
|
+
this.logger = logger;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Make the management surface callable remotely. Off unless RpcServer is constructed with
|
|
574
|
+
* exposeManagement, because it is the most dangerous thing this library can offer.
|
|
575
|
+
*
|
|
576
|
+
* Only createRpcInstance is published, and only for classes already passed to exposeClass().
|
|
577
|
+
* The previous behaviour was exposeClassInstance(this, 'manageRpc'), which published every
|
|
578
|
+
* method on this class - including exposeClassInstance and exposeObject themselves, letting
|
|
579
|
+
* any peer that could reach the transport publish arbitrary objects and instantiate arbitrary
|
|
580
|
+
* exposed classes. The logger was published the same way, so anyone could write log entries.
|
|
581
|
+
*/
|
|
582
|
+
exposeManagement() {
|
|
583
|
+
const map = this.getNameSpaceMethodMap('manageRpc');
|
|
584
|
+
map.set('createRpcInstance', (...args) => this.createRpcInstance(args[0], args[1], ...args.slice(2)));
|
|
585
|
+
this.exposedNameSpaceInstances['manageRpc'] = this;
|
|
586
|
+
}
|
|
587
|
+
getNameSpaceMethodMap(name) {
|
|
588
|
+
let result = this.exposedNameSpaceMethodMaps[name];
|
|
589
|
+
if (!result) {
|
|
590
|
+
result = new Map();
|
|
591
|
+
this.exposedNameSpaceMethodMaps[name] = result;
|
|
592
|
+
}
|
|
593
|
+
return result;
|
|
594
|
+
}
|
|
595
|
+
/** Look up a namespace without creating one. Use this on paths that came off the wire. */
|
|
596
|
+
findNameSpaceMethodMap(name) {
|
|
597
|
+
return this.exposedNameSpaceMethodMaps[name];
|
|
598
|
+
}
|
|
599
|
+
/** Set by RpcServer: refuse to expose a class that marks nothing. */
|
|
600
|
+
requireExplicitExposure = false;
|
|
601
|
+
exposeClassInstance(instance, name, options) {
|
|
602
|
+
const declared = declaredNamespace(instance);
|
|
603
|
+
const namespace = name ?? declared?.name;
|
|
604
|
+
if (!namespace)
|
|
605
|
+
throw new Error(`exposeClassInstance: ${instance.constructor.name} declares no @rpcNamespace, so a name is required`);
|
|
606
|
+
// A bare number is the old third argument, which was prototypeSteps and nothing else.
|
|
607
|
+
const settings = typeof options === 'number' ? { prototypeSteps: options } : (options ?? {});
|
|
608
|
+
let prototypeSteps = settings.prototypeSteps;
|
|
609
|
+
// Marked methods are an allow-list. Without marks every function on the prototype chain is
|
|
610
|
+
// published, including helpers the class never meant to offer.
|
|
611
|
+
const allowed = markedMethods(instance);
|
|
612
|
+
if (!allowed && this.requireExplicitExposure)
|
|
613
|
+
throw new Error(`exposeClassInstance: ${instance.constructor.name} marks no @rpc methods and requireExplicitExposure is on`);
|
|
614
|
+
this.exposedNameSpaceInstances[namespace] = instance;
|
|
615
|
+
// The call site wins over the class, since it is the one that knows how this particular
|
|
616
|
+
// instance is being used - the same class may front one device here and a pool there.
|
|
617
|
+
const execution = settings.execution ?? declared?.execution;
|
|
618
|
+
if (execution)
|
|
619
|
+
this.exposedExecution[namespace] = execution;
|
|
620
|
+
const semantics = declaredSemantics(instance);
|
|
621
|
+
if (semantics.size)
|
|
622
|
+
this.exposedSemantics[namespace] = semantics;
|
|
623
|
+
// Iterate upwards to find all the methods within the prototype chain.
|
|
624
|
+
let props = Object.getOwnPropertyNames(instance.constructor.prototype);
|
|
625
|
+
let parent = Object.getPrototypeOf(instance.constructor.prototype);
|
|
626
|
+
while (parent && parent.constructor.name !== 'Object' && parent.constructor.name !== 'EventEmitter') {
|
|
627
|
+
const parentProps = Object.getOwnPropertyNames(parent);
|
|
628
|
+
props = props.concat(parentProps);
|
|
629
|
+
parent = Object.getPrototypeOf(parent);
|
|
630
|
+
if (prototypeSteps && prototypeSteps-- === 0)
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
// All methods was found.
|
|
634
|
+
const map = this.getNameSpaceMethodMap(namespace);
|
|
635
|
+
for (const f of props) {
|
|
636
|
+
if (f === 'constructor' || typeof instance[f] !== 'function')
|
|
637
|
+
continue;
|
|
638
|
+
if (allowed && !allowed.has(f))
|
|
639
|
+
continue;
|
|
640
|
+
map.set(f, instance[f].bind(instance));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
exposeClass(constructor, aliasName) {
|
|
644
|
+
let name = constructor.name;
|
|
645
|
+
if (aliasName)
|
|
646
|
+
name = aliasName;
|
|
647
|
+
this.exposedClasses[name] = constructor;
|
|
648
|
+
}
|
|
649
|
+
exposeObject(obj, name) {
|
|
650
|
+
this.exposedNameSpaceInstances[name] = obj;
|
|
651
|
+
const props = Object.getOwnPropertyNames(obj);
|
|
652
|
+
for (const f of props) {
|
|
653
|
+
if (f !== 'constructor' && typeof obj[f] === 'function') {
|
|
654
|
+
const map = this.getNameSpaceMethodMap(name);
|
|
655
|
+
map.set(f, obj[f]);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
expose(methodName, method) {
|
|
660
|
+
const map = this.getNameSpaceMethodMap(methodName);
|
|
661
|
+
map.set(methodName, method);
|
|
662
|
+
}
|
|
663
|
+
async createRpcInstance(className, instanceName, ...args) {
|
|
664
|
+
let result = '';
|
|
665
|
+
const con = this.exposedClasses[className];
|
|
666
|
+
if (con) {
|
|
667
|
+
const id = instanceName ? instanceName : uuidv4();
|
|
668
|
+
const instance = new con(...args);
|
|
669
|
+
this.createdInstances.set(id, instance);
|
|
670
|
+
this.exposeClassInstance(instance, id);
|
|
671
|
+
result = id;
|
|
672
|
+
}
|
|
673
|
+
return result;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
//# sourceMappingURL=RpcServerHandler.js.map
|