Repository: sindresorhus/emittery (js)

Issue:
Support AbortController
It would be awesome if it was possible to pass an AbortController to Emittery as part of the options for `on()`, the same way `addEventListener` allows. This would make complex listener removal scenarios much easier to work with, such as removing all event listeners when a specific event listener fires.

Pull request description:
Add support for `AbortController`
Fixes #116 

References:

Web API：https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#signal
Mobx：https://mobx.js.org/reactions.html#signal

Patch:
diff --git a/index.d.ts b/index.d.ts
index a21cbeb..c2ca0d1 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -341,7 +341,8 @@ export default class Emittery<
 	*/
 	on<Name extends keyof AllEventData>(
 		eventName: Name | readonly Name[],
-		listener: (eventData: AllEventData[Name]) => void | Promise<void>
+		listener: (eventData: AllEventData[Name]) => void | Promise<void>,
+		options?: {signal?: AbortSignal}
 	): UnsubscribeFunction;
 
 	/**
@@ -520,7 +521,8 @@ export default class Emittery<
 		listener: (
 			eventName: keyof EventData,
 			eventData: EventData[keyof EventData]
-		) => void | Promise<void>
+		) => void | Promise<void>,
+		options?: {signal?: AbortSignal}
 	): UnsubscribeFunction;
 
 	/**
diff --git a/index.js b/index.js
index 6f47a08..7e9c5ce 100644
--- a/index.js
+++ b/index.js
@@ -273,7 +273,7 @@ export default class Emittery {
 		}
 	}
 
-	on(eventNames, listener) {
+	on(eventNames, listener, {signal} = {}) {
 		assertListener(listener);
 
 		eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
@@ -295,7 +295,18 @@ export default class Emittery {
 			}
 		}
 
-		return this.off.bind(this, eventNames, listener);
+		const off = () => {
+			this.off(eventNames, listener);
+			signal?.removeEventListener('abort', off);
+		};
+
+		signal?.addEventListener('abort', off, {once: true});
+
+		if (signal?.aborted) {
+			off();
+		}
+
+		return off;
 	}
 
 	off(eventNames, listener) {
@@ -405,14 +416,26 @@ export default class Emittery {
 		/* eslint-enable no-await-in-loop */
 	}
 
-	onAny(listener) {
+	onAny(listener, {signal} = {}) {
 		assertListener(listener);
 
 		this.logIfDebugEnabled('subscribeAny', undefined, undefined);
 
 		anyMap.get(this).add(listener);
 		emitMetaEvent(this, listenerAdded, {listener});
-		return this.offAny.bind(this, listener);
+
+		const offAny = () => {
+			this.offAny(listener);
+			signal?.removeEventListener('abort', offAny);
+		};
+
+		signal?.addEventListener('abort', offAny, {once: true});
+
+		if (signal?.aborted) {
+			offAny();
+		}
+
+		return offAny;
 	}
 
 	anyEvent() {
diff --git a/readme.md b/readme.md
index 25c2b23..5e596f4 100644
--- a/readme.md
+++ b/readme.md
@@ -197,7 +197,7 @@ emitter.emit('test');
 //=> [subscribe]: test
 ```
 
-#### on(eventName | eventName[], listener)
+#### on(eventName | eventName[], listener, options?: {signal?: AbortSignal})
 
 Subscribe to one or more events.
 
@@ -222,6 +222,21 @@ emitter.emit('🦄', '🌈'); // log => '🌈' x2
 emitter.emit('🐶', '🍖'); // log => '🍖'
 ```
 
+You can pass an [abort signal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) to unsubscribe too:
+
+```js
+import Emittery from 'emittery';
+
+const abortController = new AbortController();
+
+emitter.on('🐗', data => {
+	console.log(data);
+}, {signal: abortController.signal});
+
+abortController.abort();
+emitter.emit('🐗', '🍞'); // nothing happens
+```
+
 ##### Custom subscribable events
 
 Emittery exports some symbols which represent "meta" events that can be passed to `Emitter.on` and similar methods.
@@ -399,11 +414,11 @@ Same as above, but it waits for each listener to resolve before triggering the n
 
 If any of the listeners throw/reject, the returned promise will be rejected with the error and the remaining listeners will *not* be called.
 
-#### onAny(listener)
+#### onAny(listener, options?: {signal?: AbortSignal})
 
 Subscribe to be notified about any event.
 
-Returns a method to unsubscribe.
+Returns a method to unsubscribe. Abort signal is respected too.
 
 ##### listener(eventName, data)
 


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index 59cd4e3..2d2b12b 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -19,6 +19,7 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
 	ee.on('anEvent', async () => {});
 	ee.on('anEvent', data => undefined);
 	ee.on('anEvent', async data => {});
+	ee.on('anEvent', async data => {}, {signal: new AbortController().signal});
 	ee.on(['anEvent', 'anotherEvent'], async data => undefined);
 	ee.on(Emittery.listenerAdded, ({eventName, listener}) => {
 		expectType<PropertyKey | undefined>(eventName);
@@ -180,6 +181,8 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
 		expectType<string | number | undefined>(data);
 	});
 
+	ee.onAny(() => {}, {signal: new AbortController().signal});
+
 	const listener = (name: string) => {};
 	ee.onAny(listener);
 	ee.offAny(listener);
diff --git a/test/index.js b/test/index.js
index 9c78b19..bd928df 100644
--- a/test/index.js
+++ b/test/index.js
@@ -205,6 +205,26 @@ test('on() - isDebug logs output', t => {
 	t.is(eventStore[0].eventName, 'test');
 });
 
+test('on() - use abort signal', async t => {
+	const emitter = new Emittery();
+	const abortController = new AbortController();
+
+	const calls = [];
+	const listener = () => {
+		calls.push(1);
+	};
+
+	emitter.on('abc', listener, {signal: abortController.signal});
+
+	await emitter.emit('abc');
+	t.deepEqual(calls, [1]);
+
+	abortController.abort();
+	await emitter.emit('abc');
+
+	t.deepEqual(calls, [1]);
+});
+
 test.serial('events()', async t => {
 	const emitter = new Emittery();
 	const iterator = emitter.events('🦄');
@@ -769,6 +789,24 @@ test('onAny() - must have a listener', t => {
 	}, {instanceOf: TypeError});
 });
 
+test('onAny() - use abort signal', async t => {
+	t.plan(4);
+
+	const emitter = new Emittery();
+	const eventFixture = {foo: true};
+	const abortController = new AbortController();
+
+	emitter.onAny((eventName, data) => {
+		t.is(eventName, '🦄');
+		t.deepEqual(data, eventFixture);
+	}, {signal: abortController.signal});
+
+	await emitter.emit('🦄', eventFixture);
+	await emitter.emitSerial('🦄', eventFixture);
+	abortController.abort();
+	await emitter.emit('🦄', eventFixture);
+});
+
 test.serial('anyEvent()', async t => {
 	const emitter = new Emittery();
 	const iterator = emitter.anyEvent();