Skip to content

Commit 2c649a7

Browse files
committed
add callTimeout, rejectAll, bindings, cbIdGenerator, taggedPackets & onTrim
1 parent 56b61f2 commit 2c649a7

3 files changed

Lines changed: 141 additions & 11 deletions

File tree

index.js

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,22 +67,40 @@
6767
* If false, remote errors will be replaced with a generic message.
6868
* @param {boolean} [options.injectToThis=true] - If true, 'this' inside a function will be replaced with the iorpc object.
6969
* Useful for context-aware APIs.
70+
* @param {boolean} [options.ignoreCallbackUnavailable=false] - If true, missing-callback errors are ignored.
71+
* @param {number} [options.callTimeout=0] - Milliseconds before a pending remote call is rejected with
72+
* an Error whose `code === 'IORPC_TIMEOUT'`. `0` disables timers (default).
73+
* @param {function} [options.cbIdGenerator] - Custom callback-id generator `() => number|string`.
74+
* Defaults to a random integer. Collision check against the local map remains.
75+
* @param {boolean} [options.taggedPackets=false] - If true, packets carry an explicit `kind: 'call'|'cb'` and routing
76+
* uses it instead of the isNaN heuristic. Both sides must match. Default keeps
77+
* the wire-format identical to 9.2.2.
78+
* @param {function} [options.onTrim] - Called as `(removedIds: Array)` whenever clbsTrim evicts entries.
7079
*
71-
* @returns {{ remoteApi: object, routeInput: function, pending: function }} - Returns an object with:
80+
* @returns {{ remoteApi: object, routeInput: function, pending: function, rejectAll: function, bindings: function }}
7281
* - `remoteApi`: proxy API to call remote functions,
7382
* - `routeInput`: function to pass incoming messages from the other side,
74-
* - `pending`: returns the size of the waiting list to check if it is overflowing.
83+
* - `pending`: returns the size of the waiting list to check if it is overflowing,
84+
* - `rejectAll(err?)`: rejects all pending response promises and clears bindings (use on transport close),
85+
* - `bindings()`: returns `{ calls, callbacks }` diagnostic breakdown of `pending`.
7586
*/
7687
const createIorpc = (sendFn, localApi = {}, {
7788
maxPendingResponses = 10000,
7889
allowNestedFunctions = false,
7990
exposeErrors = true,
8091
injectToThis = true,
81-
ignoreCallbackUnavailable = false
92+
ignoreCallbackUnavailable = false,
93+
callTimeout = 0,
94+
cbIdGenerator = null,
95+
taggedPackets = false,
96+
onTrim = null
8297
}) => {
8398
const clbs = {};
8499
let trimWarningFired = false;
85100
let noWait = false;
101+
// taggedPackets: set right before invoking a remote callback wrapper, so the
102+
// outgoing packet is tagged kind:'cb'. Reset after each remoteApi access.
103+
let cbInvoke = false;
86104
let pending = 0;
87105
const clbsTrim = () => {
88106
if (!trimWarningFired) {
@@ -91,14 +109,20 @@
91109
}
92110
const clbsArr = Object.values(clbs);
93111
clbsArr.sort((a, b) => a.lastAck - b.lastAck);
94-
clbsArr.slice(0, pending - maxPendingResponses).map(v => v.cbId).forEach(cbId => {
112+
const removed = clbsArr.slice(0, pending - maxPendingResponses).map(v => v.cbId);
113+
removed.forEach(cbId => {
95114
pending--;
96115
delete clbs[cbId];
97116
});
117+
if (onTrim && removed.length) {
118+
try {
119+
onTrim(removed);
120+
} catch (e) {/* user callback must not break trimming */}
121+
}
98122
};
99123
const genCbid = () => {
100124
while (true) {
101-
const cbId = Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
125+
const cbId = cbIdGenerator ? cbIdGenerator() : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
102126
if (cbId in clbs) continue;
103127
return cbId;
104128
}
@@ -113,13 +137,23 @@
113137
noWait = true;
114138
return remoteApi;
115139
} else {
140+
// Capture and reset the callback-invoke flag at access time: the cb
141+
// wrapper sets it immediately before reading remoteApi[cbId].
142+
const isCbInvoke = cbInvoke;
143+
cbInvoke = false;
116144
return function (...args) {
117145
const packet = {
118146
apiFunc: thisArg,
119147
cbId: false,
120148
args,
121149
argsTransform: allowNestedFunctions ? {} : []
122150
};
151+
// taggedPackets: mark whether apiFunc is a named method call or a callback id.
152+
// Added only when the option is enabled so the default wire-format stays
153+
// byte-identical to 9.2.2.
154+
if (taggedPackets) {
155+
packet.kind = isCbInvoke ? 'cb' : 'call';
156+
}
123157
if (!noWait) packet.cbId = genCbid();
124158
if (allowNestedFunctions) {
125159
recuFilter(args, packet.argsTransform, resolve => {
@@ -159,18 +193,36 @@
159193
const delayPromise = new Promise((resolve, reject) => {
160194
if (pending > maxPendingResponses) clbsTrim();
161195
pending++;
196+
let timer = null;
197+
if (callTimeout > 0) {
198+
timer = setTimeout(() => {
199+
if (!(packet.cbId in clbs)) return;
200+
delete clbs[packet.cbId];
201+
pending--;
202+
// free the mirror record on the other side, if any
203+
noWait = true;
204+
remoteApi.iorpcUnbind(packet.cbId);
205+
const err = new Error('iorpc: call timeout');
206+
err.code = 'IORPC_TIMEOUT';
207+
reject(err);
208+
}, callTimeout);
209+
if (timer && typeof timer.unref === 'function') timer.unref();
210+
}
162211
clbs[packet.cbId] = {
163212
resolve: arg => {
213+
if (timer) clearTimeout(timer);
164214
delete clbs[packet.cbId];
165215
pending--;
166216
return resolve(arg);
167217
},
168218
reject,
219+
timer,
169220
cbId: packet.cbId,
170221
lastAck: Date.now()
171222
};
172223
});
173224
return delayPromise.catch(e => {
225+
if (e && e.code === 'IORPC_TIMEOUT') throw e;
174226
let err = e;
175227
if (e instanceof Error) {
176228
err = new RemoteError(e.stack, e.message);
@@ -190,6 +242,8 @@
190242
const routeInput = message => {
191243
if (message.apiFunc === 'iorpcThrowError') {
192244
const [cbId, e, stack] = message.args;
245+
const rec = clbs[cbId];
246+
if (rec && rec.timer) clearTimeout(rec.timer);
193247
if (stack) {
194248
const err = new Error(e);
195249
err.stack = stack;
@@ -202,12 +256,25 @@
202256
return;
203257
}
204258
if (message.apiFunc === 'iorpcUnbind') {
259+
const rec = clbs[message.args[0]];
260+
if (rec && rec.timer) clearTimeout(rec.timer);
205261
pending--;
206262
delete clbs[message.args[0]];
207263
return;
208264
}
209265
let fn;
210-
if (message.apiFunc in localApi) {
266+
// With taggedPackets both sides agree on routing via `kind`.
267+
// A 'cb' packet must never resolve a localApi method, and vice-versa.
268+
if (taggedPackets && (message.kind === 'call' || message.kind === 'cb')) {
269+
if (message.kind === 'call') {
270+
if (message.apiFunc in localApi) fn = localApi[message.apiFunc];
271+
} else {
272+
if (message.apiFunc in clbs) {
273+
clbs[message.apiFunc].lastAck = Date.now();
274+
fn = clbs[message.apiFunc].resolve;
275+
}
276+
}
277+
} else if (message.apiFunc in localApi) {
211278
fn = localApi[message.apiFunc];
212279
} else {
213280
if (message.apiFunc in clbs) {
@@ -220,6 +287,7 @@
220287
recuPatch(message.argsTransform, message.args, (at, a, k) => {
221288
const cbId = a[k];
222289
a[k] = function (...args) {
290+
cbInvoke = true;
223291
return remoteApi[cbId](...args);
224292
};
225293
a[k].unbind = () => {
@@ -231,6 +299,7 @@
231299
for (let i of message.argsTransform) {
232300
const cbId = message.args[i];
233301
message.args[i] = function (...args) {
302+
cbInvoke = true;
234303
return remoteApi[cbId](...args);
235304
};
236305
message.args[i].unbind = () => {
@@ -266,10 +335,12 @@
266335
if (suc) {
267336
if (message.cbId === false) return;
268337
if (retCb instanceof Function || typeof retCb === "function") {
338+
cbInvoke = true;
269339
remoteApi[message.cbId](retCb); // function as return
270340
} else {
271341
Promise.resolve(retCb).then(ret => {
272342
noWait = true;
343+
cbInvoke = true;
273344
remoteApi[message.cbId](ret);
274345
}).catch(e => {
275346
noWait = true;
@@ -284,7 +355,9 @@
284355
} else {
285356
noWait = true;
286357
let errMsg;
287-
if (isNaN(message.apiFunc)) {
358+
// Prefer the explicit kind tag when available; fall back to the isNaN heuristic.
359+
const isCallback = taggedPackets && (message.kind === 'cb' || message.kind === 'call') ? message.kind === 'cb' : !isNaN(message.apiFunc);
360+
if (!isCallback) {
288361
errMsg = `Function '${message.apiFunc}' is not registered for the iorpc API. Please verify it is properly defined and exposed.`;
289362
} else {
290363
if (ignoreCallbackUnavailable) return;
@@ -294,10 +367,45 @@
294367
remoteApi.iorpcThrowError(message.cbId, e.message, e.stack);
295368
}
296369
};
370+
/**
371+
* Reject every pending response promise and clear all bindings.
372+
* Used when the transport dies (e.g. ws 'close'). Sends nothing to the wire.
373+
*/
374+
const rejectAll = (err = new Error('iorpc: transport closed')) => {
375+
for (const cbId of Object.keys(clbs)) {
376+
const rec = clbs[cbId];
377+
if (rec && rec.timer) clearTimeout(rec.timer);
378+
if (rec && typeof rec.reject === 'function') {
379+
try {
380+
rec.reject(err);
381+
} catch (e) {/* ignore */}
382+
}
383+
delete clbs[cbId];
384+
}
385+
pending = 0;
386+
};
387+
/**
388+
* Diagnostic breakdown of `pending`:
389+
* - calls: response waiters (have a reject handler)
390+
* - callbacks: stored inbound callbacks (resolve-only records)
391+
*/
392+
const bindings = () => {
393+
let calls = 0;
394+
let callbacks = 0;
395+
for (const cbId of Object.keys(clbs)) {
396+
if (typeof clbs[cbId].reject === 'function') calls++;else callbacks++;
397+
}
398+
return {
399+
calls,
400+
callbacks
401+
};
402+
};
297403
return {
298404
remoteApi /* Object with remote functions */,
299405
routeInput /* Function to handle incoming messages */,
300-
pending: () => pending
406+
pending: () => pending,
407+
rejectAll,
408+
bindings
301409
};
302410
};
303411
class RemoteError extends Error {
@@ -349,13 +457,17 @@
349457
const {
350458
routeInput,
351459
remoteApi,
352-
pending
460+
pending,
461+
rejectAll,
462+
bindings
353463
} = createIorpc(send, local, options);
354464
on(routeInput);
355465
return {
356466
local,
357467
remote: remoteApi,
358-
pending
468+
pending,
469+
rejectAll,
470+
bindings
359471
};
360472
}
361473
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "iorpc",
3-
"version": "9.2.2",
3+
"version": "9.2.3",
44
"keywords": [
55
"rpc",
66
"callback",

readme.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,12 +417,21 @@ It should accept a callback which will receive parsed message objects.
417417
- `exposeErrors: boolean = true` – If true, forwards full remote error details (like stack traces). If false, replaces them with a generic message.
418418
- `injectToThis: boolean = true` – If true, replaces this inside called functions with `this.remoteApi`.
419419
- `ignoreCallbackUnavailable: boolean = false` – If true, errors due to missing callbacks will be ignored. Useful when integrating multiple interfaces over a single channel.
420+
- `callTimeout: number = 0` – Milliseconds to wait for a remote response before rejecting the call. `0` disables timeouts (default, no timers — identical to previous behaviour). On timeout the promise rejects with an `Error` whose `code === 'IORPC_TIMEOUT'`, the waiting entry is removed (`pending` decremented), and an `unbind` is sent to the other side so it can release its mirror record. `noWait` calls are never timed out.
421+
- `cbIdGenerator: () => number|string` – Optional custom generator for callback ids. Defaults to a random integer. The internal collision check against the local map is always applied. If you return **string** ids, you are responsible for ensuring they never clash with your `local` method names; pair it with `taggedPackets: true` for reliable routing.
422+
- `taggedPackets: boolean = false` – If true, each packet carries an explicit `kind: 'call' | 'cb'` field and `routeInput` routes by it instead of the `isNaN(apiFunc)` heuristic. **Both sides must use the same value.** Default (`false`) keeps the wire-format byte-identical to the previous version; the `kind` field is only added when enabled, so an older peer simply ignores it.
423+
- `onTrim: (removedIds: Array) => void` – Optional callback invoked whenever `maxPendingResponses` trimming evicts entries, receiving the list of removed callback ids. Useful for diagnosing binding leaks.
420424

421425
`Return` - An object with the following properties:
422426
- `remote: Object` – A proxy object. Accessing `remote.someFunction()` will trigger a remote call to `someFunction` on the other side.
423427
- `local: Object` – The original local API passed (can be extended dynamically).
424428
- `pending: Function` – Returns the current number of active, bound remote calls (i.e., the wait queue size).
425429
Useful to monitor memory usage or detect forgotten `unbind()` calls.
430+
- `rejectAll: Function``rejectAll(err = new Error('iorpc: transport closed')): void`. Rejects every pending response promise with `err`, drops all stored inbound callbacks, and resets `pending` to `0`. Sends **nothing** to the transport (intended for use when the transport is already dead). Example:
431+
```js
432+
ws.on('close', () => api.rejectAll(new Error('socket closed')))
433+
```
434+
- `bindings: Function``bindings(): { calls: number, callbacks: number }`. Diagnostic breakdown of `pending` into response waiters (`calls`) and stored inbound callbacks (`callbacks`). Helpful to detect forgotten `unbind()` calls. `calls + callbacks === pending()`.
426435

427436

428437
### async function remote\[functionName](...args): any
@@ -547,6 +556,15 @@ try {
547556
}
548557
```
549558

559+
## Transport constraints
560+
561+
`ioRPC` is transport-agnostic: it hands your `send` function a plain JSON-compatible **object** (`{ apiFunc, cbId, args, argsTransform }`, plus `kind` when `taggedPackets` is enabled) and expects your `on` to deliver the same object back on the other side. A few practical limits to keep in mind:
562+
563+
- **Serialization/framing is your responsibility.** `send` receives an object — you decide how to encode it (e.g. `JSON.stringify`) and how to frame it on the wire. Mirror that in `on` (e.g. `JSON.parse`).
564+
- **One packet = one transport message.** Keep a 1:1 mapping where possible. If your transport has a maximum message size (for example a WebRTC `RTCDataChannel` is commonly limited to ~256 KB per message), wrap `send`/`on` in a chunking/reassembly framer. Large WebRTC payloads (SDP + a burst of ICE candidates) can exceed such limits.
565+
- **No binary streams.** `ioRPC` is not designed to carry binary blobs or media (video/files). Keep those on a separate dedicated channel and use `ioRPC` only for control/signaling messages.
566+
- **Dead transports.** `ioRPC` does not observe transport state. When the underlying socket/channel closes, call [`rejectAll(err)`](#function-pair-send-on-local-options--remote-local-pending) so pending promises reject instead of hanging forever, and optionally set `callTimeout` to bound individual calls.
567+
550568
## Copyright
551569

552570
Distributed under the MIT License. See `LICENSE` for more information.

0 commit comments

Comments
 (0)