@source-repo/rpc 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1208 -0
  3. package/dist/Logging/ILogger.d.ts +7 -0
  4. package/dist/Logging/ILogger.d.ts.map +1 -0
  5. package/dist/Logging/ILogger.js +2 -0
  6. package/dist/Logging/ILogger.js.map +1 -0
  7. package/dist/NodeRpcServer.d.ts +55 -0
  8. package/dist/NodeRpcServer.d.ts.map +1 -0
  9. package/dist/NodeRpcServer.js +46 -0
  10. package/dist/NodeRpcServer.js.map +1 -0
  11. package/dist/RPC/Auth.d.ts +98 -0
  12. package/dist/RPC/Auth.d.ts.map +1 -0
  13. package/dist/RPC/Auth.js +14 -0
  14. package/dist/RPC/Auth.js.map +1 -0
  15. package/dist/RPC/Codec.d.ts +24 -0
  16. package/dist/RPC/Codec.d.ts.map +1 -0
  17. package/dist/RPC/Codec.js +21 -0
  18. package/dist/RPC/Codec.js.map +1 -0
  19. package/dist/RPC/Compatibility.d.ts +39 -0
  20. package/dist/RPC/Compatibility.d.ts.map +1 -0
  21. package/dist/RPC/Compatibility.js +195 -0
  22. package/dist/RPC/Compatibility.js.map +1 -0
  23. package/dist/RPC/Core.d.ts +188 -0
  24. package/dist/RPC/Core.d.ts.map +1 -0
  25. package/dist/RPC/Core.js +354 -0
  26. package/dist/RPC/Core.js.map +1 -0
  27. package/dist/RPC/Expose.d.ts +90 -0
  28. package/dist/RPC/Expose.d.ts.map +1 -0
  29. package/dist/RPC/Expose.js +110 -0
  30. package/dist/RPC/Expose.js.map +1 -0
  31. package/dist/RPC/Idempotency.d.ts +90 -0
  32. package/dist/RPC/Idempotency.d.ts.map +1 -0
  33. package/dist/RPC/Idempotency.js +46 -0
  34. package/dist/RPC/Idempotency.js.map +1 -0
  35. package/dist/RPC/Introspection.d.ts +92 -0
  36. package/dist/RPC/Introspection.d.ts.map +1 -0
  37. package/dist/RPC/Introspection.js +188 -0
  38. package/dist/RPC/Introspection.js.map +1 -0
  39. package/dist/RPC/Introspection.types.json +1093 -0
  40. package/dist/RPC/Messages.d.ts +108 -0
  41. package/dist/RPC/Messages.d.ts.map +1 -0
  42. package/dist/RPC/Messages.js +30 -0
  43. package/dist/RPC/Messages.js.map +1 -0
  44. package/dist/RPC/Rpc.d.ts +51 -0
  45. package/dist/RPC/Rpc.d.ts.map +1 -0
  46. package/dist/RPC/Rpc.js +54 -0
  47. package/dist/RPC/Rpc.js.map +1 -0
  48. package/dist/RPC/RpcClientHandler.d.ts +129 -0
  49. package/dist/RPC/RpcClientHandler.d.ts.map +1 -0
  50. package/dist/RPC/RpcClientHandler.js +267 -0
  51. package/dist/RPC/RpcClientHandler.js.map +1 -0
  52. package/dist/RPC/RpcServerHandler.d.ts +226 -0
  53. package/dist/RPC/RpcServerHandler.d.ts.map +1 -0
  54. package/dist/RPC/RpcServerHandler.js +676 -0
  55. package/dist/RPC/RpcServerHandler.js.map +1 -0
  56. package/dist/RPC/Schema.d.ts +150 -0
  57. package/dist/RPC/Schema.d.ts.map +1 -0
  58. package/dist/RPC/Schema.js +198 -0
  59. package/dist/RPC/Schema.js.map +1 -0
  60. package/dist/RPC/Signing.d.ts +118 -0
  61. package/dist/RPC/Signing.d.ts.map +1 -0
  62. package/dist/RPC/Signing.js +171 -0
  63. package/dist/RPC/Signing.js.map +1 -0
  64. package/dist/RPC/Tokens.d.ts +49 -0
  65. package/dist/RPC/Tokens.d.ts.map +1 -0
  66. package/dist/RPC/Tokens.js +45 -0
  67. package/dist/RPC/Tokens.js.map +1 -0
  68. package/dist/RpcClient.d.ts +91 -0
  69. package/dist/RpcClient.d.ts.map +1 -0
  70. package/dist/RpcClient.js +145 -0
  71. package/dist/RpcClient.js.map +1 -0
  72. package/dist/RpcServer.d.ts +191 -0
  73. package/dist/RpcServer.d.ts.map +1 -0
  74. package/dist/RpcServer.js +293 -0
  75. package/dist/RpcServer.js.map +1 -0
  76. package/dist/Transports/Mqtt5Frame.d.ts +107 -0
  77. package/dist/Transports/Mqtt5Frame.d.ts.map +1 -0
  78. package/dist/Transports/Mqtt5Frame.js +162 -0
  79. package/dist/Transports/Mqtt5Frame.js.map +1 -0
  80. package/dist/Transports/MqttTransport.d.ts +279 -0
  81. package/dist/Transports/MqttTransport.d.ts.map +1 -0
  82. package/dist/Transports/MqttTransport.js +843 -0
  83. package/dist/Transports/MqttTransport.js.map +1 -0
  84. package/dist/Transports/Presence.d.ts +68 -0
  85. package/dist/Transports/Presence.d.ts.map +1 -0
  86. package/dist/Transports/Presence.js +21 -0
  87. package/dist/Transports/Presence.js.map +1 -0
  88. package/dist/Transports/SocketIoClientTransport.d.ts +89 -0
  89. package/dist/Transports/SocketIoClientTransport.d.ts.map +1 -0
  90. package/dist/Transports/SocketIoClientTransport.js +280 -0
  91. package/dist/Transports/SocketIoClientTransport.js.map +1 -0
  92. package/dist/Transports/SocketIoServerTransport.d.ts +135 -0
  93. package/dist/Transports/SocketIoServerTransport.d.ts.map +1 -0
  94. package/dist/Transports/SocketIoServerTransport.js +505 -0
  95. package/dist/Transports/SocketIoServerTransport.js.map +1 -0
  96. package/dist/Utilities/Converters.d.ts +22 -0
  97. package/dist/Utilities/Converters.d.ts.map +1 -0
  98. package/dist/Utilities/Converters.js +58 -0
  99. package/dist/Utilities/Converters.js.map +1 -0
  100. package/dist/Utilities/Filter.d.ts +7 -0
  101. package/dist/Utilities/Filter.d.ts.map +1 -0
  102. package/dist/Utilities/Filter.js +14 -0
  103. package/dist/Utilities/Filter.js.map +1 -0
  104. package/dist/Utilities/ReadableName.d.ts +38 -0
  105. package/dist/Utilities/ReadableName.d.ts.map +1 -0
  106. package/dist/Utilities/ReadableName.js +58 -0
  107. package/dist/Utilities/ReadableName.js.map +1 -0
  108. package/dist/Utilities/Switch.d.ts +21 -0
  109. package/dist/Utilities/Switch.d.ts.map +1 -0
  110. package/dist/Utilities/Switch.js +70 -0
  111. package/dist/Utilities/Switch.js.map +1 -0
  112. package/dist/Utilities/TryCatch.d.ts +11 -0
  113. package/dist/Utilities/TryCatch.d.ts.map +1 -0
  114. package/dist/Utilities/TryCatch.js +13 -0
  115. package/dist/Utilities/TryCatch.js.map +1 -0
  116. package/dist/index-web.d.ts +25 -0
  117. package/dist/index-web.d.ts.map +1 -0
  118. package/dist/index-web.js +28 -0
  119. package/dist/index-web.js.map +1 -0
  120. package/dist/index.d.ts +28 -0
  121. package/dist/index.d.ts.map +1 -0
  122. package/dist/index.js +30 -0
  123. package/dist/index.js.map +1 -0
  124. package/package.json +77 -0
@@ -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