Repository: sindresorhus/emittery (js)

Issue:
Regresssion in PR#96
The https://github.com/sindresorhus/emittery/pull/96 introduces a regression when emittery is used as a sub-class. Lemme share a code sample.

```ts
const Emittery = require("emittery");

class MyEmitter extends Emittery {
  async emit(eventName, data) {
  	return super.emit(eventName, data)
  }
}

new MyEmitter().on('foo', () => {})
```

Adding a listener `foo` will raise an exception from this line https://github.com/sindresorhus/emittery/blob/main/index.js#L262, since it is calling `this.emit` with three arguments.

Now, I can update the `emit` method on my class to accept the 3rd argument and pass it to `super.emit`. However, then TypeScript complains that the `emit` method takes only two arguments. 

Pull request:
Different approach to preventing userland from emitting meta events
Fixes #97. I implemented @thetutlage's solution [here](https://github.com/sindresorhus/emittery/issues/97#issuecomment-1129591450).

Patch:
diff --git a/.github/funding.yml b/.github/funding.yml
new file mode 100644
index 0000000..6aed9c0
--- /dev/null
+++ b/.github/funding.yml
@@ -0,0 +1,4 @@
+github: sindresorhus
+open_collective: sindresorhus
+tidelift: npm/emittery
+custom: https://sindresorhus.com/donate
diff --git a/.github/security.md b/.github/security.md
new file mode 100644
index 0000000..5358dc5
--- /dev/null
+++ b/.github/security.md
@@ -0,0 +1,3 @@
+# Security Policy
+
+To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure.
diff --git a/index.d.ts b/index.d.ts
index de19515..4204a51 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -132,6 +132,13 @@ interface Options<EventData> {
 	debug?: DebugOptions<EventData>;
 }
 
+/**
+A promise returned from `emittery.once` with an extra `off` method to cancel your subscription.
+*/
+interface EmitteryOncePromise<T> extends Promise<T> {
+	off(): void;
+}
+
 /**
 Emittery is a strictly typed, fully async EventEmitter implementation. Event listeners can be registered with `on` or `once`, and events can be emitted with `emit`.
 
@@ -285,7 +292,7 @@ declare class Emittery<
 	static mixin(
 		emitteryPropertyName: string | symbol,
 		methodNames?: readonly string[]
-	): <T extends {new (): any}>(klass: T) => T; // eslint-disable-line @typescript-eslint/prefer-function-type
+	): <T extends {new (...arguments_: any[]): any}>(klass: T) => T; // eslint-disable-line @typescript-eslint/prefer-function-type
 
 	/**
 	Subscribe to one or more events.
@@ -436,7 +443,7 @@ declare class Emittery<
 	Subscribe to one or more events only once. It will be unsubscribed after the first
 	event.
 
-	@returns The event data when `eventName` is emitted.
+	@returns The promise of event data when `eventName` is emitted. This promise is extended with an `off` method.
 
 	@example
 	```
@@ -457,7 +464,7 @@ declare class Emittery<
 	emitter.emit('🐶', '🍖'); // Nothing happens
 	```
 	*/
-	once<Name extends keyof AllEventData>(eventName: Name | Name[]): Promise<AllEventData[Name]>;
+	once<Name extends keyof AllEventData>(eventName: Name | Name[]): EmitteryOncePromise<AllEventData[Name]>;
 
 	/**
 	Trigger an event asynchronously, optionally with some data. Listeners are called in the order they were added, but executed concurrently.
diff --git a/index.js b/index.js
index 09c2fcc..1612c7d 100644
--- a/index.js
+++ b/index.js
@@ -10,19 +10,13 @@ const resolvedPromise = Promise.resolve();
 const listenerAdded = Symbol('listenerAdded');
 const listenerRemoved = Symbol('listenerRemoved');
 
-// Define a symbol that allows internal code to emit meta events, but prevents userland from doing so.
-const metaEventsAllowed = Symbol('metaEventsAllowed');
-
+let canEmitMetaEvents = false;
 let isGlobalDebugEnabled = false;
 
-function assertEventName(eventName, allowMetaEvents) {
+function assertEventName(eventName) {
 	if (typeof eventName !== 'string' && typeof eventName !== 'symbol' && typeof eventName !== 'number') {
 		throw new TypeError('`eventName` must be a string, symbol, or number');
 	}
-
-	if (isMetaEvent(eventName) && allowMetaEvents !== metaEventsAllowed) {
-		throw new TypeError('`eventName` cannot be meta event `listenerAdded` or `listenerRemoved`');
-	}
 }
 
 function assertListener(listener) {
@@ -157,6 +151,17 @@ function defaultMethodNamesOrAssert(methodNames) {
 
 const isMetaEvent = eventName => eventName === listenerAdded || eventName === listenerRemoved;
 
+function emitMetaEvent(emitter, eventName, eventData) {
+	if (isMetaEvent(eventName)) {
+		try {
+			canEmitMetaEvents = true;
+			emitter.emit(eventName, eventData);
+		} finally {
+			canEmitMetaEvents = false;
+		}
+	}
+}
+
 class Emittery {
 	static mixin(emitteryPropertyName, methodNames) {
 		methodNames = defaultMethodNamesOrAssert(methodNames);
@@ -253,13 +258,13 @@ class Emittery {
 
 		eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
 		for (const eventName of eventNames) {
-			assertEventName(eventName, metaEventsAllowed);
+			assertEventName(eventName);
 			getListeners(this, eventName).add(listener);
 
 			this.logIfDebugEnabled('subscribe', eventName, undefined);
 
 			if (!isMetaEvent(eventName)) {
-				this.emit(listenerAdded, {eventName, listener}, metaEventsAllowed);
+				emitMetaEvent(this, listenerAdded, {eventName, listener});
 			}
 		}
 
@@ -271,37 +276,46 @@ class Emittery {
 
 		eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
 		for (const eventName of eventNames) {
-			assertEventName(eventName, metaEventsAllowed);
+			assertEventName(eventName);
 			getListeners(this, eventName).delete(listener);
 
 			this.logIfDebugEnabled('unsubscribe', eventName, undefined);
 
 			if (!isMetaEvent(eventName)) {
-				this.emit(listenerRemoved, {eventName, listener}, metaEventsAllowed);
+				emitMetaEvent(this, listenerRemoved, {eventName, listener});
 			}
 		}
 	}
 
 	once(eventNames) {
-		return new Promise(resolve => {
-			const off = this.on(eventNames, data => {
-				off();
+		let off_;
+
+		const promise = new Promise(resolve => {
+			off_ = this.on(eventNames, data => {
+				off_();
 				resolve(data);
 			});
 		});
+
+		promise.off = off_;
+		return promise;
 	}
 
 	events(eventNames) {
 		eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
 		for (const eventName of eventNames) {
-			assertEventName(eventName, metaEventsAllowed);
+			assertEventName(eventName);
 		}
 
 		return iterator(this, eventNames);
 	}
 
-	async emit(eventName, eventData, allowMetaEvents) {
-		assertEventName(eventName, allowMetaEvents);
+	async emit(eventName, eventData) {
+		assertEventName(eventName);
+
+		if (isMetaEvent(eventName) && !canEmitMetaEvents) {
+			throw new TypeError('`eventName` cannot be meta event `listenerAdded` or `listenerRemoved`');
+		}
 
 		this.logIfDebugEnabled('emit', eventName, eventData);
 
@@ -327,8 +341,12 @@ class Emittery {
 		]);
 	}
 
-	async emitSerial(eventName, eventData, allowMetaEvents) {
-		assertEventName(eventName, allowMetaEvents);
+	async emitSerial(eventName, eventData) {
+		assertEventName(eventName);
+
+		if (isMetaEvent(eventName) && !canEmitMetaEvents) {
+			throw new TypeError('`eventName` cannot be meta event `listenerAdded` or `listenerRemoved`');
+		}
 
 		this.logIfDebugEnabled('emitSerial', eventName, eventData);
 
@@ -359,7 +377,7 @@ class Emittery {
 		this.logIfDebugEnabled('subscribeAny', undefined, undefined);
 
 		anyMap.get(this).add(listener);
-		this.emit(listenerAdded, {listener}, metaEventsAllowed);
+		emitMetaEvent(this, listenerAdded, {listener});
 		return this.offAny.bind(this, listener);
 	}
 
@@ -372,7 +390,7 @@ class Emittery {
 
 		this.logIfDebugEnabled('unsubscribeAny', undefined, undefined);
 
-		this.emit(listenerRemoved, {listener}, metaEventsAllowed);
+		emitMetaEvent(this, listenerRemoved, {listener});
 		anyMap.get(this).delete(listener);
 	}
 
@@ -422,7 +440,7 @@ class Emittery {
 			}
 
 			if (typeof eventName !== 'undefined') {
-				assertEventName(eventName, metaEventsAllowed);
+				assertEventName(eventName);
 			}
 
 			count += anyMap.get(this).size;
diff --git a/package.json b/package.json
index b7349b1..5f0b81e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "emittery",
-	"version": "0.11.0",
+	"version": "0.12.0",
 	"description": "Simple and modern async event emitter",
 	"license": "MIT",
 	"repository": "sindresorhus/emittery",
diff --git a/readme.md b/readme.md
index eba9168..e93700b 100644
--- a/readme.md
+++ b/readme.md
@@ -284,7 +284,7 @@ const listener = data => console.log(data);
 
 Subscribe to one or more events only once. It will be unsubscribed after the first event.
 
-Returns a promise for the event data when `eventName` is emitted.
+Returns a promise for the event data when `eventName` is emitted. This promise is extended with an `off` method.
 
 ```js
 const Emittery = require('emittery');


Test patch:
diff --git a/index.test-d.ts b/index.test-d.ts
index 8fb41a2..7b28577 100644
--- a/index.test-d.ts
+++ b/index.test-d.ts
@@ -54,6 +54,9 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
 			expectType<PropertyKey | undefined>(eventName);
 			expectType<AnyListener>(listener);
 		});
+		const oncePromise = ee.once('anotherEvent');
+		oncePromise.off();
+		await oncePromise;
 	};
 }
 
@@ -276,3 +279,8 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
 Emittery.mixin('emittery')(class {
 	test() {}
 });
+
+// Mixin type - arguments in constructor
+Emittery.mixin('emittery')(class { // eslint-disable-line @typescript-eslint/no-extraneous-class
+	constructor(argument: string) {} // eslint-disable-line @typescript-eslint/no-useless-constructor
+});
diff --git a/test/index.js b/test/index.js
index 566c47d..7de7788 100644
--- a/test/index.js
+++ b/test/index.js
@@ -374,6 +374,30 @@ test('once() - eventName must be a string, symbol, or number', async t => {
 	await t.throwsAsync(emitter.once(true), TypeError);
 });
 
+test('once() - returns a promise with an unsubscribe method', async t => {
+	const fixture = '🌈';
+	const emitter = new Emittery();
+	const oncePromise = emitter.once('🦄');
+
+	const testFailurePromise = Promise.race([
+		(async () => {
+			await oncePromise;
+			t.fail();
+		})(),
+		new Promise(resolve => {
+			setTimeout(() => {
+				resolve(false);
+			}, 100);
+		})
+	]);
+
+	oncePromise.off();
+	emitter.emit('🦄', fixture);
+
+	await testFailurePromise;
+	t.pass();
+});
+
 test.cb('emit() - one event', t => {
 	t.plan(1);