bare-rpc 1.5.0 → 2.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/README.md CHANGED
@@ -94,6 +94,40 @@ An incoming stream asks the peer to pause once it buffers its `highWaterMark`, w
94
94
 
95
95
  The body of a request and the reply to a request can each be read once. Calling `createRequestStream()` on a request of the peer again, or `createResponseStream()` or `reply()` on a request you sent again, throws an `RPCError` with the code `ALREADY_RECEIVED`.
96
96
 
97
+ ## Cancellation
98
+
99
+ A request you sent can be cancelled with `req.cancel(reason)`. Its reply rejects with `reason`, its streams are destroyed with it, and the peer is told to stop handling the request. `reason` defaults to an `RPCError` with the code `CANCELLED`. Cancelling a request that has finished does nothing, and cancelling one that has not been sent yet keeps it from being sent. A request has finished once its answer has arrived: a reply, an error, or the end of a streamed reply, whether it ends cleanly or with an error. A request stream that is still open then stays open, so destroy it to stop it. Destroying the response stream before the answer has arrived cancels the request, with the error it was destroyed with if there is one.
100
+
101
+ The peer receives the `message`, `code` and `errno` of `reason`. If `reason` has no `code`, the peer receives the code `TIMEOUT` when `reason` is a `TimeoutError`, such as the reason of a signal from `AbortSignal.timeout()`, and `CANCELLED` otherwise. This only changes what the peer receives; the reply still rejects with `reason` as is.
102
+
103
+ `rpc.request(command, opts)` can also cancel the request for you:
104
+
105
+ - `signal`: An `AbortSignal` that cancels the request with its reason when it aborts.
106
+ - `timeout`: Milliseconds after the request is sent to cancel it with an `RPCError` with the code `TIMEOUT`.
107
+
108
+ ```js
109
+ const req = rpc.request(1, { timeout: 5000 })
110
+ req.send('ping')
111
+
112
+ await req.reply() // Rejects with the code TIMEOUT if no reply arrives within 5 seconds
113
+ ```
114
+
115
+ On the answering side, `req.signal` is an `AbortSignal` that aborts when the peer cancels the request, with the `message`, `code` and `errno` it cancelled with. It also aborts when the session ends while the handler still runs, with the error it ended with, even if the handler has already replied. Events have a `signal` too, which only aborts when the session ends. Once the peer has cancelled the request, its streams are destroyed with that error, `reply()` sends nothing, and an error thrown by the handler is not sent either.
116
+
117
+ `RPC` does not create signals on its own. Pass an `AbortController` class as the `AbortController` option, such as the one from [`bare-abort-controller`](https://github.com/holepunchto/bare-abort-controller). Without it, `req.signal` is `null`.
118
+
119
+ ```js
120
+ const AbortController = require('bare-abort-controller')
121
+
122
+ const rpc = new RPC(stream, router, { AbortController })
123
+
124
+ router.respond(1, async (req, data) => {
125
+ return await search(data, { signal: req.signal })
126
+ })
127
+ ```
128
+
129
+ Cancelling a request needs a peer that supports it. A peer without support fails the session with the code `UNKNOWN_MESSAGE` when it receives a cancel.
130
+
97
131
  ## Termination
98
132
 
99
133
  The session ends with an `RPCError` with the code `CHANNEL_CLOSED` when `stream` closes. It fails with an error instead when:
@@ -103,7 +137,7 @@ The session ends with an `RPCError` with the code `CHANNEL_CLOSED` when `stream`
103
137
  - An event handler throws, with that error.
104
138
  - The peer sends a bad frame, as described under [Untrusted peers](#untrusted-peers).
105
139
 
106
- Pending replies then reject with the error, and so do later replies. Open streams are destroyed with it, and so are streams created later. Events sent after the end are dropped.
140
+ Pending replies then reject with the error, and so do later replies. Open streams are destroyed with it, and so are streams created later. The signals of requests and events still being handled abort with it. Events sent after the end are dropped.
107
141
 
108
142
  `RPC` does not close `stream` when the session ends cleanly. When it fails for any reason but an error of `stream`, `RPC` destroys `stream` with the error, so the frames still queued in it are not written. To end the session yourself, destroy `stream`. `rpc.idle` tells whether no requests or streams are in flight, so that it is safe to do so.
109
143
 
@@ -113,7 +147,7 @@ Pending replies then reject with the error, and so do later replies. Open stream
113
147
 
114
148
  - `FRAME_TOO_LARGE`: The frame, with its 4-byte length prefix, is larger than `maxFrameSize`, 16 MiB by default. This is checked as soon as the length prefix arrives, before the rest of the frame is read.
115
149
  - `UNKNOWN_MESSAGE`: The frame has a message type that `RPC` does not know.
116
- - `INVALID_MESSAGE`: The frame cannot be decoded, `stream` ends partway through a frame, or the frame is one that a valid peer never sends. Examples are a response or stream message with an id of 0, a stream message with an invalid set of flags, and a new request that reuses the id of a request that is still in flight.
150
+ - `INVALID_MESSAGE`: The frame cannot be decoded, `stream` ends partway through a frame, or the frame is one that a valid peer never sends. Examples are a response, stream or cancel message with an id of 0, a stream message with an invalid set of flags, and a new request that reuses the id of a request that is still in flight.
117
151
  - `STREAM_OVERFLOW`: An incoming stream holds more than `maxBufferedBytes` of data that has not been read. This only happens when the peer keeps sending after it was asked to pause.
118
152
 
119
153
  A frame that refers to a request or stream that has already finished, or that was never known, is ignored. The same goes for frames that arrive for a stream that has already ended or been destroyed.
package/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import AbortController, { AbortSignal } from 'bare-abort-controller'
1
2
  import Buffer, { BufferEncoding } from 'bare-buffer'
2
3
  import { Readable, ReadableOptions, Writable, WritableOptions, Duplex } from 'bare-stream'
3
4
 
4
5
  declare const constants: {
5
- type: { REQUEST: 1; RESPONSE: 2; STREAM: 3 }
6
+ type: { REQUEST: 1; RESPONSE: 2; STREAM: 3; CANCEL: 4 }
6
7
  stream: {
7
8
  OPEN: number
8
9
  CLOSE: number
@@ -23,6 +24,11 @@ interface RPCIncomingEvent {
23
24
  readonly command: number
24
25
  /** The data buffer sent with the request. */
25
26
  readonly data: Buffer | null
27
+ /**
28
+ * Aborted when the connection ends while the event is still being handled, with the error it
29
+ * ended with. `null` unless `RPC` was given an `AbortController`.
30
+ */
31
+ readonly signal: AbortSignal | null
26
32
  }
27
33
 
28
34
  declare class RPCIncomingEvent {
@@ -43,11 +49,17 @@ interface RPCIncomingRequest {
43
49
  readonly sent: boolean
44
50
  /** A boolean for whether the request has received a reply. */
45
51
  readonly received: boolean
52
+ /**
53
+ * Aborted when the remote end cancels the request, with the error it cancelled with, or when the
54
+ * connection ends, with the error it ended with. `null` unless `RPC` was given an
55
+ * `AbortController`.
56
+ */
57
+ readonly signal: AbortSignal | null
46
58
 
47
59
  /**
48
60
  * Send a reply to the request. `data` may be a buffer or a string encoded using `encoding`.
49
61
  * @param data - The payload to send: a `Buffer`, or a string encoded using `encoding`. Omit to
50
- * send no data.
62
+ * send no data. Once the remote end has cancelled the request, nothing is sent.
51
63
  * @param encoding - The encoding used when `data` is a string (defaults to `utf8`).
52
64
  * @throws {ALREADY_SENT} a response has already been sent for this request.
53
65
  */
@@ -139,6 +151,15 @@ interface RPCOutgoingRequest {
139
151
  * @throws {ALREADY_SENT} the request has already been sent.
140
152
  */
141
153
  createRequestStream(opts?: WritableOptions): RPCOutgoingStream
154
+ /**
155
+ * Cancel the request. The reply rejects with `reason`, the streams of the request are destroyed
156
+ * with it, and the remote end is told to stop handling the request. Does nothing once the request
157
+ * has finished, which it has once its answer has arrived, even while its request stream is open.
158
+ * @param reason - The error to cancel with (defaults to an `RPCError` with the code `CANCELLED`).
159
+ * If it has no `code`, the remote end receives the code `TIMEOUT` when it is a `TimeoutError` and
160
+ * `CANCELLED` otherwise.
161
+ */
162
+ cancel(reason?: unknown): void
142
163
  /**
143
164
  * Create a [`Readable`](https://github.com/mafintosh/streamx#readable-stream) stream for
144
165
  * receiving data in reply to the request. If the remote end replies without a stream, the reply
@@ -257,6 +278,25 @@ interface RPCOptions {
257
278
  * `STREAM_OVERFLOW`. Defaults to 16 MiB.
258
279
  */
259
280
  maxBufferedBytes?: number
281
+ /**
282
+ * The `AbortController` class to create the `signal` of each incoming request with. Without it,
283
+ * `signal` is `null`.
284
+ */
285
+ AbortController?: typeof AbortController
286
+ }
287
+
288
+ interface RPCRequestOptions {
289
+ /**
290
+ * Cancels the request with the reason of the signal when it aborts. If the reason has no `code`,
291
+ * the remote end receives the code `TIMEOUT` when it is a `TimeoutError`, as from
292
+ * `AbortSignal.timeout()`, and `CANCELLED` otherwise.
293
+ */
294
+ signal?: AbortSignal
295
+ /**
296
+ * Cancels the request with an `RPCError` with the code `TIMEOUT` if its answer has not arrived
297
+ * this many milliseconds after it is sent. Defaults to no timeout.
298
+ */
299
+ timeout?: number
260
300
  }
261
301
 
262
302
  interface RPC {
@@ -275,10 +315,11 @@ interface RPC {
275
315
  /**
276
316
  * @param command - A unique number identifying the request; the remote end differentiates
277
317
  * requests by it.
318
+ * @param opts - Options for cancelling the request.
278
319
  * @returns an `RPCOutgoingRequest`; call `send()` on it to dispatch the request and `await
279
320
  * reply()` for the response.
280
321
  */
281
- request(command: number): RPCOutgoingRequest
322
+ request(command: number, opts?: RPCRequestOptions): RPCOutgoingRequest
282
323
  }
283
324
 
284
325
  declare class RPC {
@@ -323,6 +364,7 @@ declare namespace RPC {
323
364
  RPCCommandRouter as CommandRouter,
324
365
  type RPCCommandRouterOptions as CommandRouterOptions,
325
366
  type RPCRespondOptions as RespondOptions,
367
+ type RPCRequestOptions as RequestOptions,
326
368
  type RPCRequestHandler as RequestHandler,
327
369
  type RPCValueEncoding as ValueEncoding,
328
370
  type RPCOptions as Options
package/index.js CHANGED
@@ -37,12 +37,16 @@ for (const direction of [s.REQUEST, s.RESPONSE]) {
37
37
 
38
38
  module.exports = exports = class RPC {
39
39
  constructor(stream, onrequest = null, opts = {}) {
40
- const { maxFrameSize = DEFAULT_MAX_FRAME_SIZE, maxBufferedBytes = DEFAULT_MAX_BUFFERED_BYTES } =
41
- opts
40
+ const {
41
+ maxFrameSize = DEFAULT_MAX_FRAME_SIZE,
42
+ maxBufferedBytes = DEFAULT_MAX_BUFFERED_BYTES,
43
+ AbortController = null
44
+ } = opts
42
45
 
43
46
  this._stream = stream
44
47
  this._maxFrameSize = maxFrameSize
45
48
  this._maxBufferedBytes = maxBufferedBytes
49
+ this._AbortController = AbortController
46
50
  this._id = 0
47
51
 
48
52
  this._outgoingRequests = new Map()
@@ -50,7 +54,8 @@ module.exports = exports = class RPC {
50
54
  this._incomingRequests = new Map()
51
55
  this._incomingResponses = new Map()
52
56
  this._pendingResponses = new Set()
53
- this._unansweredRequests = new Set()
57
+ this._handling = new Set()
58
+ this._unansweredRequests = new Map()
54
59
  this._highestRequestId = 0
55
60
 
56
61
  this._buffer = []
@@ -102,8 +107,8 @@ module.exports = exports = class RPC {
102
107
  return new OutgoingEvent(this, command)
103
108
  }
104
109
 
105
- request(command) {
106
- return new OutgoingRequest(this, ++this._id, command)
110
+ request(command, opts) {
111
+ return new OutgoingRequest(this, ++this._id, command, opts)
107
112
  }
108
113
 
109
114
  _sendMessage(message, cb) {
@@ -138,12 +143,13 @@ module.exports = exports = class RPC {
138
143
  }
139
144
 
140
145
  _sendRequest(request, data = null) {
141
- // Once the channel has been torn down there is nowhere left to send the
142
- // request. Skip registering it so that it never leaks into the maps; the
143
- // awaiting reply is rejected through `RPCOutgoingRequest.reply()` instead.
144
- if (this._closed) return
146
+ // Once the channel has been torn down or the request cancelled there is
147
+ // nowhere left to send the request. Skip registering it so that it never
148
+ // leaks into the maps; the reply has already been rejected by the cancel,
149
+ // or is rejected through `RPCOutgoingRequest.reply()` after teardown.
150
+ if (this._requestError(request) !== null) return
145
151
 
146
- this._outgoingRequests.set(request.id, request)
152
+ this._trackOutgoingRequest(request)
147
153
 
148
154
  this._sendMessage({
149
155
  type: t.REQUEST,
@@ -158,11 +164,13 @@ module.exports = exports = class RPC {
158
164
  if (isInitiator) {
159
165
  request._requestStream = new OutgoingStream(this, request, t.REQUEST, opts)
160
166
 
161
- // Once the channel has been torn down there is nowhere left to open the
162
- // stream, so destroy it right away rather than leave it hanging.
163
- if (this._closed) return request._requestStream.destroy(this._error)
167
+ // Once the channel has been torn down or the request cancelled there is
168
+ // nowhere left to open the stream, so destroy it right away rather than
169
+ // leave it hanging.
170
+ const err = this._requestError(request)
171
+ if (err !== null) return request._requestStream.destroy(err)
164
172
 
165
- this._outgoingRequests.set(request.id, request)
173
+ this._trackOutgoingRequest(request)
166
174
 
167
175
  // Announce the stream right away rather than when it opens, so that the
168
176
  // remote end also learns of a stream that is destroyed before it opens.
@@ -172,7 +180,8 @@ module.exports = exports = class RPC {
172
180
  } else {
173
181
  request._requestStream = new IncomingStream(this, request, t.REQUEST, opts)
174
182
 
175
- if (this._closed) return request._requestStream.destroy(this._error)
183
+ const err = this._requestError(request)
184
+ if (err !== null) return request._requestStream.destroy(err)
176
185
 
177
186
  this._incomingRequests.set(request.id, request)
178
187
 
@@ -181,11 +190,11 @@ module.exports = exports = class RPC {
181
190
  }
182
191
 
183
192
  _sendResponse(request, data) {
184
- // Once the channel has been torn down there is nowhere left to send the
185
- // response, so drop it rather than encode and write a message that would
186
- // only be discarded. The reply is fire-and-forget, so there is nothing to
187
- // settle.
188
- if (this._closed) return
193
+ // Once the channel has been torn down or the request cancelled there is
194
+ // no one left to read the response, so drop it rather than write a message
195
+ // that would only be discarded. The reply is fire-and-forget, so there is
196
+ // nothing to settle.
197
+ if (!this._isUnanswered(request)) return
189
198
 
190
199
  this._onanswered(request)
191
200
 
@@ -202,9 +211,11 @@ module.exports = exports = class RPC {
202
211
  if (isInitiator) {
203
212
  request._responseStream = new OutgoingStream(this, request, t.RESPONSE, opts)
204
213
 
205
- // Once the channel has been torn down there is nowhere left to open the
206
- // stream, so destroy it right away rather than leave it hanging.
207
- if (this._closed) return request._responseStream.destroy(this._error)
214
+ // Once the channel has been torn down or the request cancelled there is
215
+ // nowhere left to open the stream, so destroy it right away rather than
216
+ // leave it hanging.
217
+ const err = this._requestError(request)
218
+ if (err !== null) return request._responseStream.destroy(err)
208
219
 
209
220
  this._outgoingResponses.set(request.id, request)
210
221
 
@@ -216,20 +227,24 @@ module.exports = exports = class RPC {
216
227
  } else {
217
228
  request._responseStream = new IncomingStream(this, request, t.RESPONSE, opts)
218
229
 
219
- if (this._closed) return request._responseStream.destroy(this._error)
230
+ const err = this._requestError(request)
231
+ if (err !== null) return request._responseStream.destroy(err)
220
232
 
221
233
  this._incomingResponses.set(request.id, request)
222
234
 
223
235
  request._responseStream.on('close', () => {
224
236
  this._incomingResponses.delete(request.id)
225
237
 
226
- request._responded = true
227
- this._gcOutgoingRequest(request)
238
+ // A response stream destroyed before the answer has arrived leaves no
239
+ // way to read it, so the request is cancelled.
240
+ request.cancel(request._responseStream.errored)
228
241
  })
229
242
  }
230
243
  }
231
244
 
232
245
  _sendError(request, err) {
246
+ if (!this._isUnanswered(request)) return
247
+
233
248
  this._onanswered(request)
234
249
 
235
250
  this._sendMessage({
@@ -241,23 +256,68 @@ module.exports = exports = class RPC {
241
256
  })
242
257
  }
243
258
 
259
+ _isUnanswered(request) {
260
+ return this._unansweredRequests.get(request.id) === request
261
+ }
262
+
244
263
  // Returns whether the remote end had already opened its response stream.
245
264
  _onanswered(request) {
246
265
  this._unansweredRequests.delete(request.id)
247
266
  return this._pendingResponses.delete(request.id)
248
267
  }
249
268
 
269
+ _onresponded(request) {
270
+ request._responded = true
271
+ request._detach()
272
+ this._gcOutgoingRequest(request)
273
+ }
274
+
250
275
  _gcOutgoingRequest(request) {
251
276
  // The entry must stay reachable for as long as an incoming message might
252
277
  // still target it; until the response has settled and, if the request was
253
278
  // streamed, until the outgoing request stream has closed. Either can be the
254
279
  // last to arrive, so only remove the entry once both have completed.
255
280
  if (!request._responded) return
281
+
256
282
  if (request._requestStream !== null && !request._requestStream.destroyed) return
257
283
 
258
284
  this._outgoingRequests.delete(request.id)
259
285
  }
260
286
 
287
+ _trackOutgoingRequest(request) {
288
+ this._outgoingRequests.set(request.id, request)
289
+
290
+ request._attach()
291
+ }
292
+
293
+ // The error that keeps a request from using the channel, or `null` if it may.
294
+ _requestError(request) {
295
+ return this._closed ? this._error : request._reason
296
+ }
297
+
298
+ _cancelRequest(request, reason) {
299
+ // A request that has its answer has finished, even while its request stream
300
+ // is still open, and so has one that was sent but is no longer tracked.
301
+ if (request._reason !== null || request._responded) return
302
+ if (request.sent && this._outgoingRequests.get(request.id) !== request) return
303
+
304
+ request._reason = reason
305
+
306
+ if (request.sent) {
307
+ request._cancelled = true
308
+
309
+ this._outgoingRequests.delete(request.id)
310
+
311
+ request._detach()
312
+
313
+ this._sendMessage({ type: t.CANCEL, id: request.id, error: cancelError(reason), data: null })
314
+ }
315
+
316
+ request._reject(reason)
317
+
318
+ destroyStreams(request, reason)
319
+ }
320
+
261
321
  _onerror(err) {
262
322
  this._teardown(err)
263
323
  }
@@ -280,9 +340,16 @@ module.exports = exports = class RPC {
280
340
  // Reject any request awaiting a plain reply. Requests whose response is
281
341
  // delivered over a stream are torn down through that stream below.
282
342
  for (const request of this._outgoingRequests.values()) {
343
+ request._detach()
344
+
283
345
  if (request.received && request._responseStream === null) request._reject(err)
284
346
  }
285
347
 
348
+ for (const request of this._unansweredRequests.values()) request._abort(err)
349
+ for (const request of this._handling) request._abort(err)
350
+ for (const request of this._incomingRequests.values()) request._abort(err)
351
+ for (const request of this._outgoingResponses.values()) request._abort(err)
352
+
286
353
  // Collect the streams first, deduplicating as a single request may appear
287
354
  // in more than one map, then destroy them once the maps have been cleared.
288
355
  const streams = new Set()
@@ -401,6 +468,9 @@ module.exports = exports = class RPC {
401
468
  break
402
469
  case t.STREAM:
403
470
  this._onstream(message)
471
+ break
472
+ case t.CANCEL:
473
+ this._oncancel(message)
404
474
  }
405
475
  } catch (err) {
406
476
  this._fail(err)
@@ -425,10 +495,12 @@ module.exports = exports = class RPC {
425
495
  } else {
426
496
  request = new IncomingRequest(this, message.id, message.command, message.data)
427
497
 
428
- this._unansweredRequests.add(message.id)
498
+ this._unansweredRequests.set(message.id, request)
429
499
  this._highestRequestId = Math.max(this._highestRequestId, message.id)
430
500
  }
431
501
 
502
+ this._handling.add(request)
503
+
432
504
  try {
433
505
  await this._onrequest(request)
434
506
  } catch (err) {
@@ -437,6 +509,8 @@ module.exports = exports = class RPC {
437
509
  if (message.id === 0) this._stream.destroy(err)
438
510
  else if (request._responseStream !== null) request._responseStream.destroy(err)
439
511
  else if (!request.sent) this._sendError(request, err)
512
+ } finally {
513
+ this._handling.delete(request)
440
514
  }
441
515
  }
442
516
 
@@ -479,8 +553,7 @@ module.exports = exports = class RPC {
479
553
  }
480
554
 
481
555
  _settle(request, err, data) {
482
- request._responded = true
483
- this._gcOutgoingRequest(request)
556
+ this._onresponded(request)
484
557
 
485
558
  if (err) request._reject(err)
486
559
  else request._resolve(data)
@@ -494,6 +567,23 @@ module.exports = exports = class RPC {
494
567
  if (stream !== null && !stream._acknowledged) stream.destroy(err)
495
568
  }
496
569
 
570
+ _oncancel(message) {
571
+ const request =
572
+ this._unansweredRequests.get(message.id) ??
573
+ this._incomingRequests.get(message.id) ??
574
+ this._outgoingResponses.get(message.id)
575
+
576
+ if (request === undefined) return
577
+
578
+ request._cancelled = true
579
+
580
+ this._onanswered(request)
581
+
582
+ request._abort(message.error)
583
+
584
+ destroyStreams(request, message.error)
585
+ }
586
+
497
587
  _onstream(message) {
498
588
  if (message.stream & s.OPEN) this._onstreamopen(message)
499
589
  else if (message.stream & s.CLOSE) this._onstreamclose(message)
@@ -537,6 +627,8 @@ module.exports = exports = class RPC {
537
627
  const stream = this._incomingStream(message)
538
628
  if (stream === null) return
539
629
 
630
+ this._onstreamfinish(stream)
631
+
540
632
  if (message.error) stream.destroy(message.error)
541
633
  else stream.push(null)
542
634
  }
@@ -578,7 +670,17 @@ module.exports = exports = class RPC {
578
670
 
579
671
  _onstreamend(message) {
580
672
  const stream = this._incomingStream(message)
581
- if (stream !== null) stream.push(null)
673
+ if (stream === null) return
674
+
675
+ this._onstreamfinish(stream)
676
+
677
+ stream.push(null)
678
+ }
679
+
680
+ // A streamed reply has its answer once its end arrives, even if the response
681
+ // stream has not been read to the end yet.
682
+ _onstreamfinish(stream) {
683
+ if (stream._type === t.RESPONSE) this._onresponded(stream._request)
582
684
  }
583
685
 
584
686
  _onstreamdestroy(message) {
@@ -629,9 +731,30 @@ function protocolViolation(message) {
629
731
  if (message.id === 0) return 'Stream message without an id'
630
732
  if (!streamFlags.has(message.stream)) return `Invalid stream flags '${message.stream}'`
631
733
  return null
734
+
735
+ case t.CANCEL:
736
+ if (message.id === 0) return 'Cancel without an id'
737
+ return null
632
738
  }
633
739
  }
634
740
 
741
+ function cancelError(reason) {
742
+ const err = m.toError(reason)
743
+
744
+ if (typeof err.code === 'string' && err.code !== '') return err
745
+
746
+ return {
747
+ message: err.message,
748
+ code: err.name === 'TimeoutError' ? 'TIMEOUT' : 'CANCELLED',
749
+ errno: err.errno
750
+ }
751
+ }
752
+
753
+ function destroyStreams(request, err) {
754
+ if (request._requestStream !== null) request._requestStream.destroy(err)
755
+ if (request._responseStream !== null) request._responseStream.destroy(err)
756
+ }
757
+
635
758
  function findStream(message, requests, responses) {
636
759
  if (message.stream & s.REQUEST) {
637
760
  const request = requests.get(message.id)
@@ -40,7 +40,7 @@ module.exports = class RPCCommandRouter {
40
40
 
41
41
  let data = await this._dispatch(responder, req)
42
42
 
43
- if (req.sent) return
43
+ if (req.sent || req._reason !== null) return
44
44
 
45
45
  const { responseEncoding } = responder
46
46
 
package/lib/constants.js CHANGED
@@ -2,7 +2,8 @@ module.exports = {
2
2
  type: {
3
3
  REQUEST: 1,
4
4
  RESPONSE: 2,
5
- STREAM: 3
5
+ STREAM: 3,
6
+ CANCEL: 4
6
7
  },
7
8
  stream: {
8
9
  OPEN: 0x1,
package/lib/errors.d.ts CHANGED
@@ -8,6 +8,8 @@ declare class RPCError extends Error {
8
8
  static CHANNEL_CLOSED(msg: string): RPCError
9
9
  static UNEXPECTED_STREAM(msg: string): RPCError
10
10
  static STREAM_OVERFLOW(msg: string): RPCError
11
+ static CANCELLED(msg: string): RPCError
12
+ static TIMEOUT(msg: string): RPCError
11
13
  }
12
14
 
13
15
  export = RPCError
package/lib/errors.js CHANGED
@@ -47,4 +47,12 @@ module.exports = class RPCError extends Error {
47
47
  static STREAM_OVERFLOW(msg) {
48
48
  return new RPCError(msg, 'STREAM_OVERFLOW', RPCError.STREAM_OVERFLOW)
49
49
  }
50
+
51
+ static CANCELLED(msg) {
52
+ return new RPCError(msg, 'CANCELLED', RPCError.CANCELLED)
53
+ }
54
+
55
+ static TIMEOUT(msg) {
56
+ return new RPCError(msg, 'TIMEOUT', RPCError.TIMEOUT)
57
+ }
50
58
  }
@@ -1,6 +1,9 @@
1
- module.exports = class RPCIncomingEvent {
1
+ const RPCIncoming = require('./incoming')
2
+
3
+ module.exports = class RPCIncomingEvent extends RPCIncoming {
2
4
  constructor(rpc, command, data) {
3
- this.rpc = rpc
5
+ super(rpc)
6
+
4
7
  this.command = command
5
8
  this.data = data
6
9
  }
@@ -1,9 +1,11 @@
1
1
  const c = require('compact-encoding')
2
2
  const errors = require('./errors')
3
+ const RPCIncoming = require('./incoming')
3
4
 
4
- module.exports = class RPCIncomingRequest {
5
+ module.exports = class RPCIncomingRequest extends RPCIncoming {
5
6
  constructor(rpc, id, command, data) {
6
- this.rpc = rpc
7
+ super(rpc)
8
+
7
9
  this.id = id
8
10
  this.command = command
9
11
  this.data = data
@@ -12,6 +14,8 @@ module.exports = class RPCIncomingRequest {
12
14
 
13
15
  this._requestStream = null
14
16
  this._responseStream = null
17
+
18
+ this._cancelled = false
15
19
  }
16
20
 
17
21
  reply(data, encoding) {
@@ -10,9 +10,13 @@ module.exports = class RPCIncomingStream extends Readable {
10
10
  this._request = request
11
11
  this._type = type
12
12
  this._mask = type === t.REQUEST ? s.REQUEST : s.RESPONSE
13
+
14
+ this._opened = false
13
15
  }
14
16
 
15
17
  _open(cb) {
18
+ this._opened = true
19
+
16
20
  this._rpc._sendMessage(
17
21
  {
18
22
  type: t.STREAM,
@@ -36,7 +40,11 @@ module.exports = class RPCIncomingStream extends Readable {
36
40
  }
37
41
 
38
42
  _destroy(err, cb) {
39
- if (err) {
43
+ // A request cancelled before it was sent only has to take back an open
44
+ // that already went out, as the remote end never learns of the cancel.
45
+ if (this._request._reason !== null && (this._request._cancelled || !this._opened)) {
46
+ cb(null)
47
+ } else if (err) {
40
48
  this._rpc._sendMessage(
41
49
  {
42
50
  type: t.STREAM,
@@ -0,0 +1,30 @@
1
+ module.exports = class RPCIncoming {
2
+ constructor(rpc) {
3
+ this.rpc = rpc
4
+
5
+ this._reason = null
6
+ this._controller = null
7
+ }
8
+
9
+ get signal() {
10
+ const { _AbortController: AbortController } = this.rpc
11
+
12
+ if (AbortController === null) return null
13
+
14
+ if (this._controller === null) {
15
+ this._controller = new AbortController()
16
+
17
+ if (this._reason !== null) this._controller.abort(this._reason)
18
+ }
19
+
20
+ return this._controller.signal
21
+ }
22
+
23
+ _abort(reason) {
24
+ if (this._reason !== null) return
25
+
26
+ this._reason = reason
27
+
28
+ if (this._controller !== null) this._controller.abort(reason)
29
+ }
30
+ }
package/lib/messages.js CHANGED
@@ -65,6 +65,10 @@ exports.header = {
65
65
 
66
66
  if (m.stream & s.ERROR) error.preencode(state, m.error)
67
67
  break
68
+
69
+ case t.CANCEL:
70
+ error.preencode(state, m.error)
71
+ break
68
72
  }
69
73
 
70
74
  if (hasData(m)) c.uint.preencode(state, m.data ? m.data.byteLength : 0)
@@ -97,6 +101,10 @@ exports.header = {
97
101
 
98
102
  if (m.stream & s.ERROR) error.encode(state, m.error)
99
103
  break
104
+
105
+ case t.CANCEL:
106
+ error.encode(state, m.error)
107
+ break
100
108
  }
101
109
 
102
110
  const data = hasData(m)
@@ -160,6 +168,9 @@ exports.message = {
160
168
  return { type, id, stream, error: null, data: null }
161
169
  }
162
170
 
171
+ case t.CANCEL:
172
+ return { type, id, error: error.decode(state), data: null }
173
+
163
174
  default:
164
175
  throw errors.UNKNOWN_MESSAGE(`Unknown message '${type}'`)
165
176
  }
@@ -2,7 +2,9 @@ const c = require('compact-encoding')
2
2
  const errors = require('./errors')
3
3
 
4
4
  module.exports = class RPCOutgoingRequest {
5
- constructor(rpc, id, command) {
5
+ constructor(rpc, id, command, opts = {}) {
6
+ const { signal = null, timeout = 0 } = opts
7
+
6
8
  this.rpc = rpc
7
9
  this.id = id
8
10
  this.command = command
@@ -10,6 +12,13 @@ module.exports = class RPCOutgoingRequest {
10
12
  this.received = false
11
13
 
12
14
  this._responded = false
15
+ this._reason = null
16
+ this._cancelled = false
17
+
18
+ this._signal = signal
19
+ this._timeout = timeout
20
+ this._timer = null
21
+ this._onabort = signal === null ? null : () => this.cancel(signal.reason)
13
22
 
14
23
  this._promise = new Promise((resolve, reject) => {
15
24
  this._resolve = resolve
@@ -34,11 +43,17 @@ module.exports = class RPCOutgoingRequest {
34
43
  ? c.raw.utf8
35
44
  : null
36
45
 
46
+ this._abortIfSignalled()
47
+
37
48
  this.sent = true
38
49
 
39
50
  this.rpc._sendRequest(this, encoding ? c.encode(encoding, data) : data)
40
51
  }
41
52
 
53
+ cancel(reason) {
54
+ this.rpc._cancelRequest(this, reason ?? errors.CANCELLED('Request was cancelled'))
55
+ }
56
+
42
57
  reply(encoding) {
43
58
  if (this.received) {
44
59
  throw errors.ALREADY_RECEIVED('Response is already being received')
@@ -62,6 +77,8 @@ module.exports = class RPCOutgoingRequest {
62
77
  throw errors.ALREADY_SENT('Request has already been sent')
63
78
  }
64
79
 
80
+ this._abortIfSignalled()
81
+
65
82
  this.sent = true
66
83
 
67
84
  this.rpc._createRequestStream(this, true, opts)
@@ -80,6 +97,29 @@ module.exports = class RPCOutgoingRequest {
80
97
 
81
98
  return this._responseStream
82
99
  }
100
+
101
+ _abortIfSignalled() {
102
+ if (this._signal !== null && this._signal.aborted) this._onabort()
103
+ }
104
+
105
+ _attach() {
106
+ if (this._signal !== null) this._signal.addEventListener('abort', this._onabort)
107
+
108
+ if (this._timeout > 0) this._timer = setTimeout(ontimeout, this._timeout, this)
109
+ }
110
+
111
+ _detach() {
112
+ if (this._signal !== null) this._signal.removeEventListener('abort', this._onabort)
113
+
114
+ if (this._timer !== null) {
115
+ clearTimeout(this._timer)
116
+ this._timer = null
117
+ }
118
+ }
119
+ }
120
+
121
+ function ontimeout(request) {
122
+ request.cancel(errors.TIMEOUT(`Request timed out after ${request._timeout} ms`))
83
123
  }
84
124
 
85
125
  function noop() {}
@@ -103,7 +103,7 @@ module.exports = class RPCOutgoingStream extends Writable {
103
103
  }
104
104
 
105
105
  _destroy(err, cb) {
106
- if (this._ended) {
106
+ if (this._ended || this._request._reason !== null) {
107
107
  cb(null)
108
108
  } else if (err) {
109
109
  this._rpc._sendMessage(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-rpc",
3
- "version": "1.5.0",
3
+ "version": "2.0.0",
4
4
  "description": "librpc ABI compatible RPC for Bare",
5
5
  "exports": {
6
6
  ".": {
@@ -42,6 +42,7 @@
42
42
  "safety-catch": "^1.0.2"
43
43
  },
44
44
  "devDependencies": {
45
+ "bare-abort-controller": "^1.1.2",
45
46
  "bare-buffer": "^3.0.1",
46
47
  "bare-ipc": "^1.1.0",
47
48
  "bare-rpc-test": "^0.1.0",
@@ -51,9 +52,13 @@
51
52
  "prettier-config-holepunch": "^2.0.0"
52
53
  },
53
54
  "peerDependencies": {
55
+ "bare-abort-controller": "*",
54
56
  "bare-buffer": "*"
55
57
  },
56
58
  "peerDependenciesMeta": {
59
+ "bare-abort-controller": {
60
+ "optional": true
61
+ },
57
62
  "bare-buffer": {
58
63
  "optional": true
59
64
  }