Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow event names to be numbers #96

Merged
merged 7 commits into from
May 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/* eslint-disable no-redeclare */

/**
Emittery accepts strings and symbols as event names.
Emittery accepts strings, symbols, and numbers as event names.

Symbol event names can be used to avoid name collisions when your classes are extended, especially for internal events.
Symbol event names are preferred given that they can be used to avoid name collisions when your classes are extended, especially for internal events.
*/
type EventName = string | symbol;
type EventName = PropertyKey;

// Helper type for turning the passed `EventData` type map into a list of string keys that don't require data alongside the event name when emitting. Uses the same trick that `Omit` does internally to filter keys by building a map of keys to keys we want to keep, and then accessing all the keys to return just the list of keys we want to keep.
type DatalessEventNames<EventData> = {
Expand Down Expand Up @@ -90,7 +90,7 @@ interface DebugOptions<EventData> {
(type, debugName, eventName, eventData) => {
eventData = JSON.stringify(eventData);

if (typeof eventName === 'symbol') {
if (typeof eventName === 'symbol' || typeof eventName === 'number') {
eventName = eventName.toString();
}

Expand Down Expand Up @@ -142,7 +142,7 @@ Emittery is a strictly typed, fully async EventEmitter implementation. Event lis
import Emittery = require('emittery');

const emitter = new Emittery<
// Pass `{[eventName: <string | symbol>]: undefined | <eventArg>}` as the first type argument for events that pass data to their listeners.
// Pass `{[eventName: <string | symbol | number>]: undefined | <eventArg>}` as the first type argument for events that pass data to their listeners.
// A value of `undefined` in this map means the event listeners should expect no data, and a type other than `undefined` means the listeners will receive one argument of that type.
{
open: string,
Expand All @@ -164,7 +164,7 @@ emitter.emit('other');
```
*/
declare class Emittery<
EventData = Record<string, any>, // When https://github.com/microsoft/TypeScript/issues/1863 ships, we can switch this to have an index signature including Symbols. If you want to use symbol keys right now, you need to pass an interface with those symbol keys explicitly listed.
EventData = Record<EventName, any>,
AllEventData = EventData & _OmnipresentEventData,
DatalessEvents = DatalessEventNames<EventData>
> {
Expand Down
52 changes: 30 additions & 22 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ const producersMap = new WeakMap();
const anyProducer = Symbol('anyProducer');
const resolvedPromise = Promise.resolve();

// Define symbols for "meta" events.
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 isGlobalDebugEnabled = false;

function assertEventName(eventName) {
if (typeof eventName !== 'string' && typeof eventName !== 'symbol') {
throw new TypeError('eventName must be a string or a symbol');
function assertEventName(eventName, allowMetaEvents) {
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`');
}
}

Expand All @@ -33,7 +41,7 @@ function getListeners(instance, eventName) {
}

function getEventProducers(instance, eventName) {
const key = typeof eventName === 'string' || typeof eventName === 'symbol' ? eventName : anyProducer;
const key = typeof eventName === 'string' || typeof eventName === 'symbol' || typeof eventName === 'number' ? eventName : anyProducer;
const producers = producersMap.get(instance);
if (!producers.has(key)) {
producers.set(key, new Set());
Expand Down Expand Up @@ -147,7 +155,7 @@ function defaultMethodNamesOrAssert(methodNames) {
return methodNames;
}

const isListenerSymbol = symbol => symbol === listenerAdded || symbol === listenerRemoved;
const isMetaEvent = eventName => eventName === listenerAdded || eventName === listenerRemoved;

class Emittery {
static mixin(emitteryPropertyName, methodNames) {
Expand Down Expand Up @@ -223,7 +231,7 @@ class Emittery {
eventData = `Object with the following keys failed to stringify: ${Object.keys(eventData).join(',')}`;
}

if (typeof eventName === 'symbol') {
if (typeof eventName === 'symbol' || typeof eventName === 'number') {
eventName = eventName.toString();
}

Expand All @@ -245,13 +253,13 @@ class Emittery {

eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
for (const eventName of eventNames) {
assertEventName(eventName);
assertEventName(eventName, metaEventsAllowed);
getListeners(this, eventName).add(listener);

this.logIfDebugEnabled('subscribe', eventName, undefined);

if (!isListenerSymbol(eventName)) {
this.emit(listenerAdded, {eventName, listener});
if (!isMetaEvent(eventName)) {
this.emit(listenerAdded, {eventName, listener}, metaEventsAllowed);
}
}

Expand All @@ -263,13 +271,13 @@ class Emittery {

eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
for (const eventName of eventNames) {
assertEventName(eventName);
assertEventName(eventName, metaEventsAllowed);
getListeners(this, eventName).delete(listener);

this.logIfDebugEnabled('unsubscribe', eventName, undefined);

if (!isListenerSymbol(eventName)) {
this.emit(listenerRemoved, {eventName, listener});
if (!isMetaEvent(eventName)) {
this.emit(listenerRemoved, {eventName, listener}, metaEventsAllowed);
}
}
}
Expand All @@ -286,14 +294,14 @@ class Emittery {
events(eventNames) {
eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
for (const eventName of eventNames) {
assertEventName(eventName);
assertEventName(eventName, metaEventsAllowed);
}

return iterator(this, eventNames);
}

async emit(eventName, eventData) {
assertEventName(eventName);
async emit(eventName, eventData, allowMetaEvents) {
assertEventName(eventName, allowMetaEvents);

this.logIfDebugEnabled('emit', eventName, eventData);

Expand All @@ -302,7 +310,7 @@ class Emittery {
const listeners = getListeners(this, eventName);
const anyListeners = anyMap.get(this);
const staticListeners = [...listeners];
const staticAnyListeners = isListenerSymbol(eventName) ? [] : [...anyListeners];
const staticAnyListeners = isMetaEvent(eventName) ? [] : [...anyListeners];

await resolvedPromise;
await Promise.all([
Expand All @@ -319,8 +327,8 @@ class Emittery {
]);
}

async emitSerial(eventName, eventData) {
assertEventName(eventName);
async emitSerial(eventName, eventData, allowMetaEvents) {
assertEventName(eventName, allowMetaEvents);

this.logIfDebugEnabled('emitSerial', eventName, eventData);

Expand Down Expand Up @@ -351,7 +359,7 @@ class Emittery {
this.logIfDebugEnabled('subscribeAny', undefined, undefined);

anyMap.get(this).add(listener);
this.emit(listenerAdded, {listener});
this.emit(listenerAdded, {listener}, metaEventsAllowed);
return this.offAny.bind(this, listener);
}

Expand All @@ -364,7 +372,7 @@ class Emittery {

this.logIfDebugEnabled('unsubscribeAny', undefined, undefined);

this.emit(listenerRemoved, {listener});
this.emit(listenerRemoved, {listener}, metaEventsAllowed);
anyMap.get(this).delete(listener);
}

Expand All @@ -374,7 +382,7 @@ class Emittery {
for (const eventName of eventNames) {
this.logIfDebugEnabled('clear', eventName, undefined);

if (typeof eventName === 'string' || typeof eventName === 'symbol') {
if (typeof eventName === 'string' || typeof eventName === 'symbol' || typeof eventName === 'number') {
getListeners(this, eventName).clear();

const producers = getEventProducers(this, eventName);
Expand Down Expand Up @@ -414,7 +422,7 @@ class Emittery {
}

if (typeof eventName !== 'undefined') {
assertEventName(eventName);
assertEventName(eventName, metaEventsAllowed);
}

count += anyMap.get(this).size;
Expand Down
15 changes: 4 additions & 11 deletions index.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
ee.on('anEvent', async data => Promise.resolve());
ee.on(['anEvent', 'anotherEvent'], async data => undefined);
ee.on(Emittery.listenerAdded, ({eventName, listener}) => {
expectType<string | symbol | undefined>(eventName);
expectType<PropertyKey | undefined>(eventName);
expectType<AnyListener>(listener);
});
ee.on(Emittery.listenerRemoved, ({eventName, listener}) => {
expectType<string | symbol | undefined>(eventName);
expectType<PropertyKey | undefined>(eventName);
expectType<AnyListener>(listener);
});
}
Expand All @@ -47,11 +47,11 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
const test = async () => {
await ee.once('anEvent');
await ee.once(Emittery.listenerAdded).then(({eventName, listener}) => {
expectType<string | symbol | undefined>(eventName);
expectType<PropertyKey | undefined>(eventName);
expectType<AnyListener>(listener);
});
await ee.once(Emittery.listenerRemoved).then(({eventName, listener}) => {
expectType<string | symbol | undefined>(eventName);
expectType<PropertyKey | undefined>(eventName);
expectType<AnyListener>(listener);
});
};
Expand Down Expand Up @@ -102,13 +102,6 @@ type AnyListener = (eventData?: unknown) => void | Promise<void>;
expectAssignable<typeof ee.debug.logger>(myLogger);
}

// Userland can't emit the meta events
{
const ee = new Emittery();
expectError(ee.emit(Emittery.listenerRemoved));
expectError(ee.emit(Emittery.listenerAdded));
}

// Strict typing for emission
{
const ee = new Emittery<{
Expand Down
8 changes: 4 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ emitter.emit(myUnicorn, '🦋'); // Will trigger printing 'Unicorns love 🦋'

### eventName

Emittery accepts strings and symbols as event names.
Emittery accepts strings, symbols, and numbers as event names.

Symbol event names can be used to avoid name collisions when your classes are extended, especially for internal events.
Symbol event names are preferred given that they can be used to avoid name collisions when your classes are extended, especially for internal events.
sindresorhus marked this conversation as resolved.
Show resolved Hide resolved

### isDebugEnabled

Expand Down Expand Up @@ -160,7 +160,7 @@ Default:
eventData = JSON.stringify(eventData);
}

if (typeof eventName === 'symbol') {
if (typeof eventName === 'symbol' || typeof eventName === 'number') {
eventName = eventName.toString();
}

Expand Down Expand Up @@ -222,7 +222,7 @@ emitter.emit('🐶', '🍖'); // log => '🍖'

##### Custom subscribable events

Emittery exports some symbols which represent custom events that can be passed to `Emitter.on` and similar methods.
Emittery exports some symbols which represent "meta" events that can be passed to `Emitter.on` and similar methods.

- `Emittery.listenerAdded` - Fires when an event listener was added.
- `Emittery.listenerRemoved` - Fires when an event listener was removed.
Expand Down
44 changes: 32 additions & 12 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,15 @@ test('on() - listenerAdded offAny', async t => {
t.is(eventName, undefined);
});

test('on() - eventName must be a string or a symbol', t => {
test('on() - eventName must be a string, symbol, or number', t => {
const emitter = new Emittery();

emitter.on('string', () => {});
emitter.on(Symbol('symbol'), () => {});
emitter.on(42, () => {});

t.throws(() => {
emitter.on(42, () => {});
emitter.on(true, () => {});
}, TypeError);
});

Expand Down Expand Up @@ -327,14 +328,15 @@ test('off() - multiple event names', async t => {
t.deepEqual(calls, [1, 1]);
});

test('off() - eventName must be a string or a symbol', t => {
test('off() - eventName must be a string, symbol, or number', t => {
const emitter = new Emittery();

emitter.on('string', () => {});
emitter.on(Symbol('symbol'), () => {});
emitter.on(42, () => {});

t.throws(() => {
emitter.off(42);
emitter.off(true);
}, TypeError);
});

Expand Down Expand Up @@ -362,13 +364,14 @@ test('once() - multiple event names', async t => {
t.is(await promise, fixture);
});

test('once() - eventName must be a string or a symbol', async t => {
test('once() - eventName must be a string, symbol, or number', async t => {
const emitter = new Emittery();

emitter.once('string');
emitter.once(Symbol('symbol'));
emitter.once(42);

await t.throwsAsync(emitter.once(42), TypeError);
await t.throwsAsync(emitter.once(true), TypeError);
});

test.cb('emit() - one event', t => {
Expand Down Expand Up @@ -407,13 +410,21 @@ test.cb('emit() - multiple events', t => {
emitter.emit('🦄');
});

test('emit() - eventName must be a string or a symbol', async t => {
test('emit() - eventName must be a string, symbol, or number', async t => {
const emitter = new Emittery();

emitter.emit('string');
emitter.emit(Symbol('symbol'));
emitter.emit(42);

await t.throwsAsync(emitter.emit(42), TypeError);
await t.throwsAsync(emitter.emit(true), TypeError);
});

test('emit() - userland cannot emit the meta events', async t => {
const emitter = new Emittery();

await t.throwsAsync(emitter.emit(Emittery.listenerRemoved), TypeError);
await t.throwsAsync(emitter.emit(Emittery.listenerAdded), TypeError);
});

test.cb('emit() - is async', t => {
Expand Down Expand Up @@ -584,13 +595,21 @@ test.cb('emitSerial()', t => {
emitter.emitSerial('🦄', 'e');
});

test('emitSerial() - eventName must be a string or a symbol', async t => {
test('emitSerial() - eventName must be a string, symbol, or number', async t => {
const emitter = new Emittery();

emitter.emitSerial('string');
emitter.emitSerial(Symbol('symbol'));
emitter.emitSerial(42);

await t.throwsAsync(emitter.emitSerial(true), TypeError);
});

test('emitSerial() - userland cannot emit the meta events', async t => {
const emitter = new Emittery();

await t.throwsAsync(emitter.emitSerial(42), TypeError);
await t.throwsAsync(emitter.emitSerial(Emittery.listenerRemoved), TypeError);
await t.throwsAsync(emitter.emitSerial(Emittery.listenerAdded), TypeError);
});

test.cb('emitSerial() - is async', t => {
Expand Down Expand Up @@ -1002,15 +1021,16 @@ test('listenerCount() - works with empty eventName strings', t => {
t.is(emitter.listenerCount(''), 1);
});

test('listenerCount() - eventName must be undefined if not a string nor a symbol', t => {
test('listenerCount() - eventName must be undefined if not a string, symbol, or number', t => {
const emitter = new Emittery();

emitter.listenerCount('string');
emitter.listenerCount(Symbol('symbol'));
emitter.listenerCount(42);
emitter.listenerCount();

t.throws(() => {
emitter.listenerCount(42);
emitter.listenerCount(true);
}, TypeError);
});

Expand Down