@epiphanysoft/watchable

1.1.0 • Public • Published

watchable

Build Status Coverage Status Dependencies Status npm version MIT Licence

An enhanced event firing module similar to event-emitter. While watchable can be (almost) a drop-in replacement for event-emitter, its goal is to provide a better API for common tasks and doing more involved work.

For example:

    // Listen to 'foo', 'bar' and 'zap' events:
    let token = watchable.on({
        foo () { /* ... */ },
        bar () { /* ... */ },
        zap () { /* ... */ }
    });
    
    // ...
    
    // Now remove all 3 listeners:
    token.destroy();

These are just a couple of the API improvements. There are many more... read on!

Installing

You can install watchable from the Nuclear Pizza Machine:

npm install @epiphanysoft/watchable --save

Or clone from GitHub.

Usage

You can generally drop in watchable as an enhanced event-emiiter module. There are some minor differences in how the extra utilities work (such as pipe, unify, all-off and has-listeners).

The event-emitter API is based on ES5 "classes", but watchable exports a proper ES6 class. Also, as with event-emitter, you can just create instances and use them directly.

API Summary

A summary of the Watchable API:

Watchable Base Class

The Watchable class is a proper ES6 class from which you can derive:

    const { Watchable } = require('@epiphanysoft/watchable');
    
    class MyClass extends Watchable {
        constructor () {
            super();
            //...
        }
    }
    
    let inst = new MyClass();
    let handler = x => console.log(x);
    
    inst.on('foo', handler);
    
    inst.fire('foo', 42);       // "emit" is an alias for "fire"
    
    inst.un('foo', handler);    // "off" is an alias for "un"

The use of un() instead of off() and fire() instead of emit() is a matter of preference since both are supported.

Configurable Instance

The Watchable class can also be created and configured directly:

    const { Watchable } = require('@epiphanysoft/watchable');
    
    let inst = new Watchable({
        // configs go here (see below)
    });

    let handler = x => console.log(x);
    
    inst.on('foo', handler);
    
    inst.fire('foo', 42);       // "emit" is an alias for "fire"
    
    inst.un('foo', handler);    // "off" is an alias for "un"

The config object passed to the constructor will be explained as the individual properties are explored below.

Replacing event-emitter

The following form is one suggested for event-emitter and is supported by watchable:

    const watchable = require('@epiphanysoft/watchable');
    
    function MyClass () { /* ... */ }
    
    watchable(MyClass.prototype);
    
    let inst = new MyClass();
    
    // ... use inst.on(), inst.off(), inst.once() and inst.emit()

While this form is less elegant and not recommended when using watchable, it is supported to allow as simple as possible migration for existing projects.

Listener Methods

When using classes, listener functions are inconvenient so watchable also supports listener methods:

    class MyClass extends Watchable {
        method () {
            //...
            this.fire('foo');
        }
    }
    
    class MyWatcher {
        constructor () {
            this.prefix = '[watcher]';
        }
        
        onFoo (a) {
            console.log(this.prefix, a);
        }
    }
    
    let watchable = new MyClass();
    let watcher = new MyWatcher();

    // NO:
    watchable.on('foo', watcher.onFoo.bind(watcher));

    // YES:
    watchable.on('foo', 'onFoo', watcher);

To remove a listener method you must supply the same instance:

    watchable.un('foo', 'onFoo', watcher);

Multiple Listeners

Listening to multiple events from an object is a common need, so watchable has made this simple:

    // listen to "foo" and "bar" events:
    watchable.on({
        foo () {
            //...
        },
        
        bar () {
            //...
        }
    });

The object passed to on() is called a "listener manifest". That same object can be passed to un() to remove all of the listeners just added, but there is an easier way:

    let token = watchable.on({
        foo () {
            //...
        },
        
        bar () {
            //...
        }
    });
    
    // ...
    
    token.destroy();

By destroying the returned token, all of the corresponding listeners will be removed.

The same applies to listener methods:

    let token = watchable.on({
        foo: 'onFoo',
        bar: 'onBar',
        
        this: watcher
    });

    //...

    token.destroy();

The special key this in the listener manifest is understood to be the target object of the listener methods.

A listener manifest can contain a mixture of method names and functions, but it is generally best to use a consistent form (at least on a per-call basis).

There is also the unAll method which removes all listeners from a watchable instance:

    watchable.unAll();

Scope Resolution

When using listener methods, it can be convenient to have a default or named scope.

Consider these two cases:

    watchable.on({
        foo: 'onFoo',
        bar: 'onBar'
    });

    watchable.on({
        foo: 'onFoo',
        bar: 'onBar',
        
        this: 'parent'
    });

To enable the above, the watchable instance must implement resolveListenerScope:

    class MyClass extends Watchable {
        //...

        resolveListenerScope (scope) {
            return (scope === 'parent') ? this.parent : this;
        }
    }
    
    // Or using an instance:
    
    let watchable = new Watchable({
        resolveScope (scope) {
            //...
        }
    });

When using an instance in the second part above, the resolveScope property of the config object is used to set the resolveListenerScope method on the watchable instance.

The full parameter list passed to resolveListenerScope is as below:

  • scope: The value of this on the listener manifest (if any).
  • fn: The handler function or method name (e.g., 'onFoo').
  • listener: The internal object tracking the on() request.

The listener argument is an Array that holds those values needed by the watchable mechanism. The object can be useful to the resolveListenerScope method for holding cached results on behalf of this particular listener. The resolveListenerScope method, however, should not do any of the following with the listeners object:

  • Add or remove array elements.
  • Change any of the array element values.
  • Depend on any of the array element values.
  • Overwrite any of the array prototype methods.

Basically, watchable treats the listeners as the array it is and so long as that view onto the object is preserved and handled as read-only, the resolveListenerScope implementor is free to place expando properties on the same object for its own benefit.

Listener Detection and Notification

Some expensive actions can be avoided using the hasListeners method:

    if (watchable.hasListeners('change')) {
        //... expensive stuff

        watchable.fire('change');
    }

Setting up to fire some events (say file-system observations) can come at a cost. In these cases it is helpful to know when listeners are added or removed so that these expensive setups can be delayed or avoided as well as cleaned up when no longer needed.

    // When using the base class:
    class MyClass extends Watchable {
        //...

        onEventWatch (event) {
            // ... event had no listeners and now has one ...
        }
        
        onEventUnwatch (event) {
            // ... event had one listener and now has none ...
        }
    }

    // When creating an instance:
    let watchable = new Watchable({
        onWatch (event) {
            // ... event had no listeners and now has one ...
        },
        
        onUnwatch (event) {
            // ... event had one listener and now has none ...
        }
    });

The onEventWatch and onEventUnwatch methods are optional and will be called if they are implemented.

When configuring a Watchable instance, the onWatch and onUnwatch properties of the config object set the onEventWatch and onEventUnwatch methods, respectively.

Relaying Events

When relaying one event between watchable instances, there is always the manual solution:

    let watchable1 = new Watchable();
    let watchable2 = new Watchable();
    
    watchable1.on({
        foo (...args) {
            watchable2.fire('foo', ...args);
        }
    });

To relay all events fired by watchable1, however, requires a different approach. The solution provided by watchable is an event relayer:

    const relayEvents = require('@epiphanysoft/watchable/relay');

The above require returns a function that can be used to create event relayers but it also enables the latent relayEvents method which is already defined on all watchable objects.

These are equivalent:

    relayEvents(watchable1, watchable2);
    
    watchable1.relayEvents(watchable2);

They both create an event relayer and register it with watchable1. The second form is generally preferred since most of the operations provided by watchable are instance methods. Basically, as long as some module issues a require('.../watchable/relay) then the relayEvent method on all watchable instance will work properly.

The valid arguments to relayEvents are:

    watchable.relayEvents(target);            // relay all events 
    watchable.relayEvents(target, String...); // relay events in arguments
    watchable.relayEvents(target, String[]);  // relay all events in array
    watchable.relayEvents(target, Object);    // specify relay "options"
    watchable.relayEvents(target, Function);  // specify relay function
    watchable.relayEvents(relayer);           // add a relayer instance 

Removing a relayer is similar to removing a listener manifest:

    let token = watchable1.relayEvents(watchable2);
    
    // ...
    
    token.destroy();

To relay multiple events, but not all events:

    watchable1.relayEvents(watchable2, 'foo', 'bar');

    // Or
    watchable1.relayEvents(watchable2, [ 'foo', 'bar' ]);

The options object form accepts an object whose keys are event names. The following is equivalent to the above:

    watchable1.relayEvents(watchable2, {
        foo: true,
        bar: true
    });

To relay all events except bar:

    watchable1.relayEvents(watchable2, {
        '*': true,
        bar: false
    });

The special '*' pseudo event is used to change the default mapping of events not given in the options object.

The values in the options object can be used to rename or transform individual events.

To relay foo without modification but rename the bar event to barish:

    watchable1.relayEvents(watchable2, {
        foo: true,
        bar: 'barish'
    });

To instead transform the bar event:

    watchable1.relayEvents(watchable2, {
        foo: true,
        
        bar (event, args) {
            return watchable2.fire('barish', ...args);
        }
    });

To relay all events and only transform bar:

    watchable1.relayEvents(watchable2, {
        '*': true,
        
        bar (event, args) {
            return watchable2.fire('barish', ...args);
        }
    });

To transform all fired events, you can specify a relay function:

    watchable1.relayEvents(null, (event, args) => {
        return watchable2.fire(event, ...args);
    });

The null argument is required to differentiate this case from a watchable class (which is just a constructor function). Further, the relayer function does not have to be an => function:

    function relayer (event, args) {
        return this.target.fire(event, ...args);
    }
    
    watchable1.relayEvents(watchable2, relayer);
    watchable1.relayEvents(watchable3, relayer);

In this case, this in the relayer function refers to the relayer instance that is created to hold the target of the relay.

For maximum flexibility, a custom relayer class can be written and an instance passed as the first and only parameter to relayEvents:

    const { Relayer } = require('@epiphanysoft/watchable/relay');

    class MyRelayer extends Relayer {
        constructor (target) {
            super();
            this.target = target;
        }
        
        relay (event, args) {
            //... called for all events fired
        }
    }

    watchable1.relayEvents(new MyRelayer(watchable2));

In this case, watchable1 will call the relay() method for all events it fires. The relay method can then decide the particulars.

The filtering and renaming features described above can be leveraged by instead implementing doRelay (as long as the constructor passes the options object to its super()):

    const { Relayer } = require('@epiphanysoft/watchable/relay');

    class MyRelayer extends Relayer {
        constructor (target) {
            super();
            this.target = target;
        }
        
        doRelay (event, args) {
            // ... called only for events that should be relayed
            // ... and with the potentially renamed event
            this.target.fire(event, ...args);
        }
    }

    watchable1.relayEvents(new MyRelayer(watchable2));

Event Logging

Logging is a special form of relaying that (by default) logs events to the console:

    const logEvents = require('@epiphanysoft/watchable/log');

    logEvents(watchable);
    
    watchable.fire('foo', 42, 'abc');

The above will generate console.log() calls for all events fired by the watchable:

> foo 42 "abc"

There are also logOptions that can be specified:

  • level
  • mask
  • prefix
  • to

Using level

By default, events are logged using console.log(). Some events, however, may be more appropriate to log using console.error() or other "level" method. This is handled by specifying a level option:

    logEvents(watchable, {
        level: {
            foo: 'error'
        }
    });
    
    watchable.fire('foo', 42, 'abc');

Given the above logEvents() call, all events will still use console.log() except for the foo event. This event will use console.error(). Instead of 'error', the value of the properties in the level object are any console API that is callably equivalent to console.log() and console.error(). For example, 'warn' and 'info'.

Using mask

In some cases, perhaps only the event name should be logged, or maybe certain event arguments are just noise in the log. The mask option can be used to tune the output for all events or on a per-event basis:

    logEvents(watchable, {
        mask: 0b011
    });
    
    watchable.fire('foo', 42, 'abc', window);

The value of the mask is a number whose bits are matched to the arguments. The least significant bit controls arguments[0], the next least bit controls arguments[1] and so on. In the above 0b011 (an ES6 binary literal) has the two least significant bits set and so only the first two arguments will be logged:

> foo 42 'abc'

A mask of 0 will prevent all argument logging. To assign a mask value based on the event name, mask can be an object:

    logEvents(watchable, {
        mask: {
            '*': 0,
            foo: 0b011
        }
    });

In the above case, the default mask (using the '*' key) is 0 while foo events have a value of 0b011. When mask is a number, it is equivalent to an object with '*' as the only key holding that value.

Using prefix

To make logged events more readable, each line can be decorated with a prefix:

    logEvents(watchable, {
        prefix: 'w1:'
    });
    
    watchable.fire('foo', 42, 'abc');

Using prefix each call to console.log() will be more explicit:

> w1:foo 42 "abc"

Using to

Unit tests and the like can benefit by logging to an array:

    let events = [];

    logEvents(watchable, events);
    
    watchable.fire('foo', 42, 'abc');
    
    expect(events).to.equal([
        [ 'foo', [ 42, 'abc' ]]
    ]);

To combine other options, the array can be passed as the to property:

    let events = [];

    logEvents(watchable1, {
        prefix: 'w1:',
        to: events
    });
    logEvents(watchable2, {
        prefix: 'w2:',
        to: events
    });
    
    watchable1.fire('foo', 42, 'abc');
    watchable2.fire('foo', 42, 'abc');
    
    expect(events).to.equal([
        [ 'w1:foo', [ 42, 'abc' ]],
        [ 'w2:foo', [ 42, 'abc' ]]
    ]);

The above expect checks use assertly but, of course, other assertion frameworks can compare arrays in a similar manner.

Other Relaying Options

In addition to the logger options above, normal event relayer options can be passed as a 3rd argument:

    let events = [];

    logEvents(watchable, events, {
        foo: 'FOO'
    });
    
    watchable.fire('foo', 42, 'abc');
    
    expect(events).to.equal([
        [ 'FOO', [ 42, 'abc' ]]
    ]);

Stopping Logs

The logEvents method returns token that can be used to stop logging (like a normal event relayer):

    let token = logEvents(watchable);
    
    watchable.fire('foo', 42, 'abc');
    
    token.destroy();  // no more logging

Utility Methods

The watchable module provides several helper functions that are directly exported. These are mostly to mimic the event-emitter API since many equivalent capabilities are available as described above.

These methods are:

    const { hasListeners, is, unAll } = require('@epiphanysoft/watchable');
    const pipe = require('@epiphanysoft/watchable/pipe');
    const unify = require('@epiphanysoft/watchable/unify');

The hasListeners and unAll methods are also available as instance methods of watchable objects.

hasListeners

    hasListeners (watchable, event);

Returns true if the watchable instance has one ore more listeners for event.

is

    is (candidate);

Returns true if the candidate is a watchable object.

pipe

    pipe (watchable1, watchable2);

Relays all events fired on watchable1 to watchable2. This is an event-emitter name for the relayEvents method described above:

    pipe (watchable1, watchable2) {
        return watchable1.relayEvents(watchable2);
    }

unAll

    unAll (watchable);

Removes all listeners on the watchable instance.

unify

    unify (watchable1, watchable2);

This (non-reversibly) connects the listeners of the two watchable instances. This is done by sharing the listener registrations. This means that listeners registered on one instance will be in fact be registered on both. This has the effect that which ever of these instances is used to fire() an event, all listeners will be invoked, regardless of the instance on which they appear to be registered.

To unify() multiple watchable instances, it is important to always pass one of the current group members as the first argument:

    // OK
    unify (watchable1, watchable2);
    unify (watchable1, watchable3);

    // BAD
    unify (watchable1, watchable2);
    unify (watchable3, watchable2);

This is because preference is given to the first watchable object when merging.

Extending Watchable

For all of its features, watchable is rather small so there is room for enhancement by other modules.

To facilitate such enhancements, the entire Mocha test suite is exported to allow such modules to verify compliance with the full watchable interface contract:

    const { Watchable } = require('@epiphanysoft/watchable');

    class MyWatchable extends Watchable {
    }

    //---

    const watchableTestSuite = require('@epiphanysoft/watchable/test/suite');

    describe('MyWatchable', function () {
        watchableTestSuite(MyWatchable);
    });

Design Goals

If you have ideas, I am wide open to hear them. Some things to keep in mind when deciding if an idea belongs in watchable proper (or separate module) are sketched out in this section.

Small Module Size

While there many features in the watchable module, the core (as of 1.1.0) is only about 350 lines of code. The most advanced features (like unify, relaying and logging) are all implemented as "sub-modules" (separately required files in the same npm module).

Elegant API

As much as possible, the watchable API tries to behave "intuitively". This means that when a call signature looks intuitive, watchable tries to support it. This principal can be seen in the on() method and its support for these call signatures:

    watchable.on('foo', x => { /* ... */ });

    watchable.on({
        foo (x) {
            /* ... */
        }
    });

Compatibility with event-emitter

The event-emitter module is the de facto standard for this type of problem, so while the API can be extended and improved, watchable strives to be as close as possible to a drop-in replacement.

License

MIT

Package Sidebar

Install

npm i @epiphanysoft/watchable

Weekly Downloads

2

Version

1.1.0

License

MIT

Last publish

Collaborators

  • evantrimboli
  • lowlevelbass
  • israelroldan
  • dongryphon