em-async-events

Easier and more useful events system that has a Vue2.x plugin. Features include async events and listeners, expirable, lingering and catch-up async events and listeners (auto-removed), and a customizable atomic API.

Usage no npm install needed!

<script type="module">
  import emAsyncEvents from 'https://cdn.skypack.dev/em-async-events';
</script>

README

em-async-events

npm npm

Easier and more useful event bus with features that include expirable, lingering and catch-up async events and listeners, and a customizable atomic API. Has a Vue plugin.

Features

  • stoppable events and listeners; stop an event from firing on other callbacks when it hits a specific callback or on the first callback
  • automated event management:
    • auto-removal of listeners on destruction.
    • expirable listeners that listen for a specified time before they are removed.
  • async events that get responses from listeners. Returns promise.
  • async listeners that can wait for callback(s) to fire the first time before proceeding. Returns promise.
  • lingering events; these are events that are fired and used by current listeners, but wait for newer listeners until a specified time before being discarded or wait until its listener is added (bait mode).
  • multiple callbacks and events registrations:
    • handle multiple events with one callback.
    • invoke/fire multiple callbacks from multiple events.
    • register these using atomic statements.
  • you can use it without of Vue!
  • excellent debugging and logging support: trace and debug everything to quickly see what's going on. Tip: it's so useful to turn on debug.all option during development, then turn on trace/verbose on each event/listener. It helps a lot if you can't figure out what's going on.

Installation

Installation is simple:

yarn add em-async-events
# or using npm
npm install em-async-events

Basic Usage

Straight usage without Vue:

import AsyncEvents from 'em-async-events';

const eventsBus = new AsyncEvents();
// you then use it as 
eventsBus.onEvent('foo', (payload) => {/* do something */});
// ... later on
eventsBus.emitEvent('foo', { bar: 'zoo' });

As a Vue plugin:

import Vue from 'vue';
import AsyncEvents from 'em-async-events';

Vue.use(new AsyncEvents());
// you then use in components as 
this.$onEvent('foo', (payload) => {/* do something */});
// ... later on
this.$emitEvent('foo', { bar: 'zoo' });

As you can see, everything works the same way whether with or without Vue except for a few differences where components are at play, read on.

Events management

This package aims to address the above features and avoid some thorny issues that other event buses have. It was once called vue-hooked-em-async-events because it mainly focused on Vue, but it was so good at solving many common event problems that the author decided to make it work without Vue and created em-async-events. You can trace events using the trace and verbose options.

Methods

There are several methods used to manage events with super duper conveniences like async events/listeners/callbacks. Examples are going to be given in Vue syntax, but you can adopt to any object you attach this to, such as events object in the initialization example above. Just replace the this.$ like so: this.$onEvent to events.onEvent.

Listening to events:

Listening to event or events:

  • most options can be mixed to get the desired behaviour
  • callback arguments: payload and listener options (used to add the listener)
  • $localListeners are listeners in the current scope (listeners in Vue Component). This is meant for Vue and you can see these in devtools (computed).

onEvent() and onceEvent()

run callback on event emission.

  this.$onEvent('some-event', eventCallback);

function eventCallback1 (payload, metadata) {
  return /* whatever response you want to return to the event; see below */
}
metadata

metadata is information about the event and the listener itself passed to the callback function as the second argument, eg:

metadata == {
  extra:        'extra payload (not event related) from listener adding line. see below',
  eventMeta:    {
    payloads:        [/* array of all previous event callbacks' outcomes (if there're multiple listeners), see below */],
    eventName:       "some-event",
    eventOptions:    {/*opts passed to event*/ },
    listenerOptions: {/*opts passed to listener*/ },
    eventOrigin:     VueComponent /*vue compo that emitted the event when applicable */,
    listenersTally:  6 // number of listeners for this event
  },
  listenerMeta: {
    // ... todo document both when stable
  }
};
extra information to callback

Send extra info to a callback from emission side.

  • callback should extract extra info from the second argument.
  this.$onEvent('some-event', eventCallbackExtra, { extra: { blah: 'bloh' } });

async function eventCallbackExtra (payload, { extra }) {
  // metadata can contain extra payload that comes from where listener was defined if specified, see above. Allows for a more cleaner and interesting API
  // - passed to every event callback of that listener
}
Listen once and remove
  this.$onceEvent('some-event', eventCallback2);
this.$onEvent('some-event', eventCallback1, { once: true });

async function eventCallback1 (payload) {
  // you can ignore metadata
  return /* whatever response you want to return to the event; see below */
}

async function eventCallback2 (payload, metadata) {
  return { blah: 'any new payload of any type for this callback to pass back' };
  // you can get reponses from all callbacks in metadata.payloads[]
}

Exclusive listener support
Relative to component

Only allow this listener for this event on this component (any subsequent listeners are ignored)

  this.$onEvent('some-event', eventCallback1, { isLocallyExclusive: true });
// or just replace any existing exclusive listener 
this.$onEvent('some-event', eventCallback1, { isLocallyExclusive: true, replace: true });
globally

Only allow this listener for this event globally (any subsequent listeners are ignored).

  • this means that no listener of that name can be registered globally.
  this.$onEvent('some-event', eventCallback1, { isGloballyExclusive: true });
// or just replace any existing exclusive listener 
this.$onEvent('some-event', eventCallback1, { isGloballyExclusive: true, replace: true });
Async listener registration

Only continue after event has been used by callback using async nature of lib.

  • This is particularly useful for putting up code that doesn't execute as long as a certain event has not yet happened, as well as use the usual callbacks approach. Any further events will be handled by callback(s).
  • isAsync option was deprecated and no longer required as all events and listeners are now async by default
  let result = await this.$onceEvent('some-event', (payload, metadata) => {
  return 'whatever else as the final payload';
});

// the above statement will wait for and give out result from callbacks

result = await this.$onceEvent('some-event');
// will wait again for the event to happen without any callbacks associated with it
Expiring listeners
  // automatically stop listening after 5000 milliseconds
this.$onEvent('some-event', eventCallback3, { expire: 5000 });
// automatically run expiry callback function before unlistening after 10000 milliseconds 
this.$onEvent('some-event', eventCallback3, {
  expire: 10000, expiryCallback: async () => {
    // do something when event listener expires
  }
});


function eventCallback3 (payload, metadata) {
  // you can also change how the event will behave by modifying the listenerOptions
  // - not all options are modifiable
  // eg: stop invoking any subsequent callbacks on the event
  metadata.listenerOptions.stopHere = true;
}
Serial callbacks

Run a single instance of the associated callback(s) at a time. It means callbacks will wait for each other to complete before even starting. They don't pass outcomes to each here, they are just independent calls.

  • useful if you don't want to have certain data modified by multiple calls at the same time or something happening at the same time using the same callback function.
this.$onEvent('some-event', eventCallback3, { callbacks: { serialExecution: true } });
this.$emitEvent('some-event', 'payload');
this.$emitEvent('some-event', 'payload1');
this.$emitEvent('some-event', 'payload2');

eventCallback3 will be executed in a serial fashion, waiting for each callback to complete before invoking the next event's callback instance.

Atomic listener API:
  • multiple events being listened to and handled by one callback
  • multiple events being listened to and handled by multiple callbacks
 this.$onEvent(['second-event', 'third-event'], (payload) => {/*...*/});
// fire multiple callbacks 
this.$onEvent('some-other-event', [eventCallback, eventCallback2, (payload) => {/*...*/}]);
// (even for multiple events)
this.$onEvent(['second-event', 'third-event', 'fourth-event'], [eventCallback1, eventCallback2]);

function eventCallback (payload) {
  // payload is data given at initial event emission. If chain is true in eventOptions, then
  // payload is the data being passed by the event or from previous callback listener in the chain of callbacks
  // for the event. EG: if this callback returns then the response is the next callback's payload.
}


async function eventCallback1 (payload, metadata) {
  return /* whatever response you want to return to the event; see below */
}

const eventCallback2 = eventCallback1;

function eventCallback3 (payload, metadata) {
  // you can also change how the event will behave by modifying the listenerOptions
  // - not all options are modifiable
  // eg: stop invoking any subsequent callbacks on the event
  metadata.listenerOptions.stopHere = true;
}

Emitting events:

Emitting events is simple, but this package takes it to another level of usability with async events, which are promises returned by eventEmiters for finer flow control on events as they happen and how they are listened and responded to.

  • most options can be mixed to get the desired behaviour
    // send payload/data to listeners of 'some-event'
this.$emitEvent('some-event', { test: 'one' });

Use lingered events

Why use linger? bust race conditions. it doesn't matter how your order your events and listeners when using this it will make sure that events can fire and wait for listeners to pop in within a certain timespan.

  • Each event is actually lingered 500ms by default. See eventsOptions.linger in options below.
  • To Disable lingering on a specific event, set linger: false on event options when emitting. Figures < 0 or falsy will cause the default options eventsOptions.linger time to be applied. And if falsy also, it will disable lingering altogether.
  • You can regulate each listener's linger catching up using catchUp time on listeners' options.
  • globalLinger - was deprecated in favour of using the default options' eventsOptions.linger option.
  • $localLingeredEvents are lingering events in the current scope (in Vue Component). This is meant for Vue and you can see these in devtools (computed).

eg: Linger for 5000ms for new listeners of the event.

    this.$emitEvent('some-event3', { test: 'one' }, { linger: 5000 });
CatchUp

To adjust how long a listener can catch up to an event use catchUp time defined in listener options.

  • the event doesn't have to be a lingered event coz every event is lingered by default.
  • if catchUp is falsy, then the listener won't catch up to any lingering event at all.
  • if catchUp is true, then the listener will catch up to any lingering event regardless of when it was emitted.

For example to catch up an event that happened not more than 100 milliseconds ago (without using linger option when emitting the event):

  this.$onEvent('some-event', (payload) => {/*...*/}, { catchUp: 100 });
// or catch up no matter what
this.$onEvent('some-event', (payload) => {/*...*/}, { catchUp: true });

For example to NOT catch up an event at all (if we missed the event don't use the lingered one):

  this.$onEvent('some-event', (payload) => {/*...*/}, { catchUp: 0 });
// or
this.$onEvent('some-event', (payload) => {/*...*/}, { catchUp: false });
Exclusive events

When an event is lingered and isGloballyExclusive: true, newer events will will be ignored until the lingered event expires, unless replace: true, which will replace the exclusive event with a fresh one. eg: exclusively linger this event; no other events of the same event name ('some-event5') will be lingered until after 5000ms

    this.$emitEvent('some-event5', { test: 'one' }, { linger: 5000, isGloballyExclusive: true });

// hint: this creates an event based state updated by emissions and read by listeners
//  - may actually create an easy API around this ;D
Baited events

Setting the bait: true option will cause the event to linger forever until consumed by a listener.

  • A baited listener will not linger at all if consumed. Meaning, if there were listeners of that event when you try to bait, it will just invoke associated callbacks and expire immediately.
// If no listeners were listening when this was requested then it lingers waiting for one to listen and expires as soon as it is listened to.
this.$emitEvent('some-event', { test: 'one' }, { bait: true });

Event Range (Vue specific)

Event range options offer precise control over which listeners are invoked. You target them using tokens based on the hierarchic position/level of component that emits the event (event origin).

why? EG: Consider the following components hierachy all listening to the same event: grandparent=>parent=>child=>grandchild=>greatGrandchild if event was fired at grandchild then all listeners from parent to grandparent will handle event.

Options are separated by a hyphen and constructed from the following tokens (eg: parent-descendents will invoke the parent and any descendents):

  • self: will only invoke listeners in event origin (component that fired the event). Every other token will begin from origin as well.
  • child or children: will invoke listeners that are below event, whether they are direct descendents of event origin or not. Note, this won't go all the way down, just the next level only.
  • descendent or descendents: will invoke listeners that are below origin, whether they are direct descendents of event origin or not. Note, this will invoke up to the last-most-bottom listener.
  • child or children: will invoke listeners that are below event, whether they are direct descendents of event origin or not. Note, this won't go all the way down, just the next level only.
  • descendent or descendents: will invoke listeners that are below origin, whether they are direct descendents of event origin or not. Note, this will invoke up to the last-most-bottom listener.
  • same goes for ancestors or parent... but in the opposite direction.
  • broadcast - invokes every listener listening for event regardless of location.

By level we mean the following. Consider there is a listener where there is an 👂🏽:

(todo need to use graphics here and cleanup docs)

compo0👂🏽=>compo1👂🏽=>compo2👂🏽=>compo3=>compo4=>compo5(origin)=>compo6=>compo7👂🏽=>compo8👂🏽=>compo9=>compo10👂🏽

sib0👂🏽=>sib👂🏽=>sib2👂🏽=>sib3=>sib4=>sib5(sibling)=>sib6=>sib7👂🏽=>sib8👂🏽=>sib9

Relative to origin, where event is emitted:

  • compo2 is level -1 and is parent(for the first one only) or parents (for all components on the same level but in other hierarchies.
  • compo1 is level -2 and is ancestor (for the first one only) or ancestors (see above explanation), but including all the way up to any listeners before it eg: compo0.
  • compo7 is level 1 and is child (for the first one only) or children (for all components on the same level but in other hierarchies.
  • compo8 is level 2 and is descendent (for the first one only) or descendents (see children explanation), but including all the way down to any listeners after it eg: compo10.
Range Examples:

Fire event callbacks of a specific range (default is first-parent)

    this.$emitEvent('some-event2', { test: 'one' }, { range: 'ancestors' });

Stop on the first listener callback (guaranteeing event is handled only once, by the immediate parent listener)

    this.$emitEvent('some-event2', { test: 'one' }, { range: 'first-parent' });

Use the other above-listed tokens to achieve required results.

Remove Event Listeners

Removing event from events object for all listeners (example):

export default {
  methods: {
    dontWannaListenAnymore () {
      this.$eraseEvent('some-event'); // now no component will listen to this event
      this.$eraseEvent(['second-event', 'third-event']);
    }
  }
}

Remove Events manually (example):

export default {
  methods: {
    leaveMeAlone () {
      // nice, but it is also done automatically inside "beforeDestroy" hook
      this.$fallSilent();
    }
  }
}

Remove specific callback for specific event (example):

export default {
  methods: {
    leaveMeWithoutSpecificCallback () {
      this.$fallSilent('some-event', this.specificCallback);
    }
  }
}

Remove array of callbacks for specific event (example):

export default {
  methods: {
    leaveMeWithoutSpecificCallbacks () {
      this.$fallSilent('some-event', [this.callbackOne, this.callbackTwo]);
    }
  }
}

Unsubscribe component from specific event or events

  • all component's callbacks for these events will be removed:
export default {
  methods: {
    notListenToOne () {
      this.$fallSilent('some-event');
    },
    notListenToMany () {
      this.$fallSilent(['some-event', 'another-event']);
    }
  }
}

Utilities

There are several methods for doing other things.

// will return true if there is any listener with either ids: "event-id-1" or "event-id-2"
this.$hasListeners(['event-id-1', 'event-id-2']);
// will return true if there is a listener with event id: "event-id-1"
this.$hasListener('event-id-1');
// will return true if there is any lingering events with either ids: "event-id-1" or "event-id-2"
this.$hasLingeringEvents(['event-id-1', 'event-id-2']);
// will return true if there is any lingering events with event id: "event-id-1"
this.$hasLingeringEvent('event-id-1'); 

Customization

  • renaming functions:
    • If you use some plugins, which have some conflicting function names (or you just don't like default ones), you can rename all of them according to your preferences. NOTE: use this feature at your own risk as it will warn you only for Vue basic properties.
  • default callback and event options
    "$options", "$parent", "$root", "$children", "$refs", "$vnode", "$slots", "$scopedSlots", "$createElement", "$attrs", "$listeners", "$el"
    import Vue from 'vue';
import AsyncEvents from 'em-async-events';

Vue.use(new AsyncEvents({
  onEvent:    '$hear',
  onceEvent:  '$hearOnce',
  emitEvent:  '$fireEvent',
  eraseEvent: '$deleteEvent',
  fallSilent: '$noMore',
  
  listenersOptions: { stopHere: true, /*...*/ },
  eventsOptions:    { range: 'ancestors', /*...*/ },
  
  // debugging options, useful when you want to see what's going on. below are the defaults
  debug: {
    all:                    false, // toggles all debugging, but Vue.config.devtools option, which is usually true at development, turns it to false if it is false.
    addListener:            true, // show add listener debug messages
    invokeListener:         true, // show debug messages when listener is invoked
    emitEvent:              true, // show emit events debug messages
    eraseEvent:             true, // show erase event listeners debug messages
    lingerEvent:            true, // show linger events debug messages
    chainListenerCallbacks: true, // show chainListenerCallbacks debug messages
    removeListener:         true  // show remove listener debug messages
  }

}));

export default {
  // later in component...
  created () {
    this.$hear('some-event', this.callbackMethod);
    
    // you can also change options in userland
    this.$asyncEvents.options.debug.all = true;
    
    // or change it per event or listener or all those debug options listed above; 
    // use trace option and...
    // add verbose to show actual code trace info (very useful to figure out what code is emitting event)
    this.$hear('some-event', () => { /*do something */ }, { trace: true, verbose: true });
  },
  methods: {
    doSmth () {
      this.$fireEvent('some-event', 'payload', { trace: true });
    },
    unsubscribe () {
      this.$noMore('some-event');
    }
  }
}

Default options

Default options that you don't have to set all the time or that control certain things. Note that these are also options that you pass to individual listeners and events when creating them.

defaultOptions === {
  listenersOptions: {
    extra:               undefined, // pass extra information to emitters or other callbacks using this. Get it in listeners from the second param (metadata). Get it from events through eventOptions.extras array; so define an options obj you can access.
    callbacks:           {
      serialExecution:     false, // don't execute callbacks at once; queue them up.
      debounce:            null, // lodash debounce opts; {wait, leading, trailing, maxWait}
      throttle:            null, // lodash throttle opts; {wait, leading, trailing}
      isLocallyExclusive:  false, // make this the only listener that runs THIS CALLBACK for this event in local scope (eg: Vue component)
      isGloballyExclusive: false, // make this the only listener that runs THIS CALLBACK  for this event everywhere
      replace:             false, // replace/hijack any existing global or local listeners defined earlier.
    },
    stopHere:            false, // stop invoking other callbacks when we hit this listener
    expire:              0,     // stop listening for the event after this much time (ms)
    expiryCallback:      undefined, // call this callback when we stop listening through expire time.
    catchUp:             100, // catup time (ms) to consider events that occured earlier; false to disable
    once:                false, // only listen for this event once
    isLocallyExclusive:  false, // make this the only listener for this event in local scope (eg: Vue component)
    isGloballyExclusive: false, // make this the only listener for this event everywhere
    replace:             false, // replace/hijack any existing global or local listeners defined earlier.
    trace:               false, // show debug info about event or listener.
    verbose:             false, // show more information (including emitter info), uses warning messages as they show callstack.
  },
  
  eventsOptions:    {
    chain:               false, // cause listeners' callbacks to pass each other's outcome as payload; based on range, but not guaranteed
    linger:              500,   // time (ms) to wait for other listeners that may want to catch up to this event.
    bait:                false, // emit and linger this event forever, waiting for just 1 listener that may catch it. sort of a "once" for emitters.
    isLocallyExclusive:  false, // make this the only lingering event with this name in local scope (eg: Vue component)
    isGloballyExclusive: false, // make this the only lingering event with this name everywhere
    replace:             false, // replace/hijack any existing global or local lingering events defined earlier.
    range:               'first-parent', // how to propergate the event and where it will reach
    trace:               false, // show debug info about event.
    verbose:             false, // show extra info, turning this to TRUE in causes target listerners to trace also
    rejectUnconsumed:    false, // throw an error in the lingering event promise when the event is never consumed.
  },
  
  maxCachedPayloads: 5,
  
  // fine tune which processes will log trace information
  debug: {
    all:                    true,
    addListener:            false,
    emitEvent:              false,
    eraseEvent:             false,
    invokeListener:         false,
    lingerEvent:            false,
    chainListenerCallbacks: false,
    removeListener:         false,
  },
}

Author

Emmanuel Mahuni