Skip to content

Commit

Permalink
Allow event names to be numbers (#96)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukehorvat authored May 2, 2022
1 parent 3cf4a0a commit c010e90
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 55 deletions.
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.

### 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

0 comments on commit c010e90

Please sign in to comment.