Skip to content

Commit 0e45c8f

Browse files
committed
util: allow safely adding listener to abortSignal
1 parent e8810b9 commit 0e45c8f

File tree

3 files changed

+125
-0
lines changed

3 files changed

+125
-0
lines changed

doc/api/util.md

+54
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,60 @@ it:
1414
const util = require('node:util');
1515
```
1616

17+
## `util.addSafeAbortSignalAbortListener(signal, resource)`
18+
19+
<!-- YAML
20+
added: REPLACEME
21+
-->
22+
23+
> Stability: 1 - Experimental
24+
25+
* `signal` {AbortSignal}
26+
* `listener` {Function|EventListener}
27+
* `options` {Object}
28+
* `passive` {boolean} When `true`, serves as a hint that the listener will
29+
not call the `Event` object's `preventDefault()` method.
30+
**Default:** `false`.
31+
* `signal` {AbortSignal} The listener will be removed when the given
32+
AbortSignal object's `abort()` method is called.
33+
* Returns: {Disposable} that removes the `abort` listener.
34+
35+
Listens once to the `abort` event on the provided `signal`.
36+
The listeners will be triggered even if the `abort` event's propagation has
37+
been stopped.
38+
39+
```cjs
40+
const { addSafeAbortSignalAbortListener } = require('node:util');
41+
42+
function example(signal) {
43+
let disposable;
44+
try {
45+
signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
46+
disposable = addSafeAbortSignalAbortListener(signal, (e) => {
47+
// Do something when signal is aborted.
48+
});
49+
} finally {
50+
disposable?.[Symbol.dispose]();
51+
}
52+
}
53+
```
54+
55+
```mjs
56+
import { addSafeAbortSignalAbortListener } from 'node:util';
57+
58+
function example(signal) {
59+
let disposable;
60+
try {
61+
signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
62+
disposable = addSafeAbortSignalAbortListener(signal, (e) => {
63+
// Do something when signal is aborted.
64+
});
65+
} finally {
66+
disposable?.[Symbol.dispose]();
67+
}
68+
}
69+
```
70+
1771
## `util.callbackify(original)`
1872
1973
<!-- YAML

lib/util.js

+28
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const {
4343
ObjectValues,
4444
ReflectApply,
4545
StringPrototypePadStart,
46+
SymbolDispose,
4647
} = primordials;
4748

4849
const {
@@ -63,6 +64,7 @@ const {
6364
} = require('internal/util/inspect');
6465
const { debuglog } = require('internal/util/debuglog');
6566
const {
67+
validateAbortSignal,
6668
validateFunction,
6769
validateNumber,
6870
} = require('internal/validators');
@@ -73,6 +75,7 @@ const {
7375
deprecate,
7476
getSystemErrorMap,
7577
getSystemErrorName: internalErrorName,
78+
kEmptyObject,
7679
promisify,
7780
toUSVString,
7881
defineLazyProperties,
@@ -86,6 +89,7 @@ function lazyAbortController() {
8689
}
8790

8891
let internalDeepEqual;
92+
let kResistStopPropagation;
8993

9094
/**
9195
* @deprecated since v4.0.0
@@ -345,11 +349,35 @@ function getSystemErrorName(err) {
345349
return internalErrorName(err);
346350
}
347351

352+
function addSafeAbortSignalAbortListener(signal, listener, options = kEmptyObject) {
353+
if (signal === undefined) {
354+
throw new ERR_INVALID_ARG_TYPE('signal', 'AbortSignal', signal);
355+
}
356+
validateAbortSignal(signal, 'signal');
357+
kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
358+
// TODO(atlowChemi) add { subscription: true } and return directly
359+
signal.addEventListener(
360+
'abort',
361+
listener,
362+
{ __proto__: null, ...options, once: true, [kResistStopPropagation]: true },
363+
);
364+
const removeEventListener = () => {
365+
signal.removeEventListener('abort', listener, options);
366+
};
367+
return {
368+
__proto__: null,
369+
[SymbolDispose]() {
370+
removeEventListener();
371+
},
372+
};
373+
}
374+
348375
// Keep the `exports =` so that various functions can still be monkeypatched
349376
module.exports = {
350377
_errnoException: errnoException,
351378
_exceptionWithHostPort: exceptionWithHostPort,
352379
_extend,
380+
addSafeAbortSignalAbortListener,
353381
callbackify,
354382
debug: debuglog,
355383
debuglog,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as common from '../common/index.mjs';
2+
import * as util from 'node:util';
3+
import * as assert from 'node:assert';
4+
import { describe, it } from 'node:test';
5+
6+
describe('util.addSafeAbortSignalAbortListener', () => {
7+
it('should throw if signal not provided', () => {
8+
assert.throws(() => util.addSafeAbortSignalAbortListener(), { code: 'ERR_INVALID_ARG_TYPE' });
9+
});
10+
11+
it('should throw if provided signal is invalid', () => {
12+
assert.throws(() => util.addSafeAbortSignalAbortListener(undefined), { code: 'ERR_INVALID_ARG_TYPE' });
13+
assert.throws(() => util.addSafeAbortSignalAbortListener(null), { code: 'ERR_INVALID_ARG_TYPE' });
14+
assert.throws(() => util.addSafeAbortSignalAbortListener({}), { code: 'ERR_INVALID_ARG_TYPE' });
15+
});
16+
17+
it('should return a Disposable', () => {
18+
const { signal } = new AbortController();
19+
const disposable = util.addSafeAbortSignalAbortListener(signal, common.mustNotCall());
20+
21+
assert.strictEqual(typeof disposable[Symbol.dispose], 'function');
22+
});
23+
24+
it('should execute the listener even when event propagation stopped', () => {
25+
const controller = new AbortController();
26+
const { signal } = controller;
27+
28+
signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
29+
util.addSafeAbortSignalAbortListener(
30+
signal,
31+
common.mustCall((e) => assert.strictEqual(e.target, signal)),
32+
);
33+
34+
controller.abort();
35+
});
36+
37+
it('should remove event listeners when disposed', () => {
38+
const controller = new AbortController();
39+
const disposable = util.addSafeAbortSignalAbortListener(controller.signal, common.mustNotCall());
40+
disposable[Symbol.dispose]();
41+
controller.abort();
42+
});
43+
});

0 commit comments

Comments
 (0)