isotropic-pubsub
v0.17.0
Published
An event system with subscription stages, preventable events, and event distribution
Maintainers
Readme
isotropic-pubsub
A powerful and flexible event system for JavaScript applications that implements the publish-subscribe pattern with advanced lifecycle features.
Why Use This?
- Preventable Default Behavior: An event's default behavior runs inside the event, in a dedicated complete stage, so a subscriber can inspect it, prevent it, or replace it before it happens
- Staged Event Lifecycle: Before, on, complete, and after stages, each with its own subscribers
- Event Distribution: Distribute events through object hierarchies, with automatic teardown
- Event Encapsulation: Keep events entirely internal or expose them for others to observe
- Fine-Grained Control: Prevent, stop, or modify events during their lifecycle
- Filtered Subscriptions: Let a subscription choose which events it runs for
- Awaitable Events: Get a cancelable subscription promise that resolves aynchronously when an event is published
- Customizable Behavior: Configure dispatchers with custom behavior for each event type
- Multiple Integration Options: Use as standalone, base class, or mixin
Installation
npm install isotropic-pubsubBasic Usage
import _Pubsub from 'isotropic-pubsub';
{
// Create a pubsub instance
const pubsub = _Pubsub();
// Subscribe to an event
pubsub.on('userLoggedIn', event => {
console.log(`User logged in: ${event.data.username}`);
});
// Publish an event with data
pubsub.publish('userLoggedIn', {
username: 'john.doe'
});
}The Event Lifecycle
With some other event emitters, an object does its work and then announces it. By the time a listener is called, the thing has already happened. Listeners are spectators.
In isotropic-pubsub, events can work that way if that is the functionality you desire, but it's intended that an event's default behavior is part of the event itself. Publishing an event will run four stages in order:
- Before Stage: Subscribers inspect the event and may prevent it
- On Stage: The main stage for ordinary observers
- Complete Stage: Where the event's default behavior runs
- After Stage: The thing has already happened. This stage runs only if the event was not prevented
Since the default behavior lives in the complete stage rather than in the code that called publish, a subscriber gets to run before it. That is what makes the behavior preventable:
import _make from 'isotropic-make';
import _Pubsub from 'isotropic-pubsub';
const _Document = _make('Document', _Pubsub, {
save (contents) {
// The write is not performed here. It is published as an event.
return this._publish('save', {
contents
});
},
_eventSave (event) {
// This is the default behavior, and it runs in the complete stage
this._contents = event.data.contents;
}
}, {
_pubsub: {
save: {
completeFunction: '_eventSave'
}
}
});
{
const document = _Document();
document.before('save', event => {
if (!event.data.contents.trim()) {
// The write never happens
event.prevent();
}
});
document.before('save', event => {
// Rewrite what is about to be saved
event.data.contents = event.data.contents.trimEnd();
});
document.after('save', () => {
// Only reached when the save actually happened
console.log('saved');
})
}A subscriber can prevent the write, change the data to write, or observe that the write definitely happened.
event.prevent() prevents the complete stage, and the after stage is skipped along with it. The on stage still runs, because prevention is about the default behavior rather than about notification. (It is possible to prevent the on stage or control other aspects of event dispatch.)
Subscription Stages
You can subscribe to any stage of an event's lifecycle:
// Before stage - can prevent or modify the event
pubsub.before('save', event => {
if (!event.data.isValid) {
event.prevent(); // Prevents the complete stage
}
});
// On stage - main event handling
pubsub.on('save', event => {
console.log('Saving data:', event.data);
});
// After stage - logging, cleanup
pubsub.after('save', event => {
console.log('Save completed at:', Date.now());
});Event Control Methods
During event handling, subscribers can control the event flow:
event.prevent(stageName = 'complete'): Prevent the complete stage, or pass in a stage nameevent.stopEvent(): Stop all stages of the eventevent.stopDispatch(): Stop dispatch to further handlers in current stageevent.stopDistribution(): Stop distribution to other objects
One-time Subscriptions
Subscribe to an event once, then automatically unsubscribe:
// Subscribe once
pubsub.onceOn('notification', event => {
console.log('This handler will run only once:', event.data);
});
// These are equivalent
pubsub.on('notification', {
callbackFunction: event => console.log('Also runs once'),
once: true
});Filtering Subscriptions
Any subscription can declare a filterFunction. It runs before the callback function and decides whether this particular event is one the subscriber cares about. Returning a truthy value runs the callback function as usual. Returning a falsy value skips it entirely.
pubsub.on('dataChanged', {
callbackFunction: event => {
console.log('An even value:', event.data.value);
},
filterFunction: event => event.data.value % 2 === 0
});A filtered out event never reaches the call function, so a once subscription that filters an event out will remain subscribed. This lets a one-time subscription wait for the right event rather than merely the next one:
// Runs once, for the first event published by a particular object
pubsub.onceOn('dataChanged', {
callbackFunction: event => {
console.log('Got it:', event.data);
},
filterFunction: event => event.publisher === interestingObject
});Without a filter function, the same functionality requires an ordinary subscription that unsubscribes itself:
// Equivalent to the above
pubsub.on('dataChanged', event => {
if (event.publisher === interestingObject) {
event.unsubscribe();
console.log('Got it:', event.data);
}
});A filter function receives the event object and is called with the same host as the callback function, so a method name works too:
pubsub.on('dataChanged', {
callbackFunction: '_handleDataChanged',
filterFunction: '_isInterestingDataChange'
});Filter functions receive the event fully populated for the current stage, so event.distributor, event.publisher, and event.stageName are all available. A filter function may also call the event control methods. A filter function that calls event.stopDispatch() stops the stage even though its own callback function did not run.
Subscription
The Subscription Config
There are multiple ways of subscribing to events and all of them are a shorthand for the same underlying configuration object:
{
callbackFunction, // Function or method name to execute
eventName, // Name of the event
filterFunction, // Decides whether a given event is the one the subscriber wants
once, // Unsubscribe after it runs
stageName // Stage to subscribe to
}The subscribe method accepts this kind of config object directly:
pubsub.subscribe({
callbackFunction: event => {
console.log('Saving:', event.data);
},
eventName: 'save',
stageName: 'before'
});The subscribe method also accepts positional arguments: pubsub.subscribe(stageName, eventName, callbackFunction). In place of a callback function, a config object may be passed with callbackFunction, filterFunction, and once properties.
There are shortcut methods for subscribing to every stage. The method names match the stage name: after, before, and on. There are also methods for subscribing once: onceAfter, onceBefore, and onceOn. The stageName and once configs are supplied by the method name, so they don't need to be passed in. These staged subscription methods accept two arguments: the event name and the callback function. In place of a callback function, a config object may be passed with callbackFunction and filterFunction properties.
pubsub.after('save', {
callbackFunction: () => {
console.log('Important data was saved');
},
filterFunction: event => event.data.important
});
pubsub.before('save', event => {
console.log('About to save:', event.data);
});
pubsub.onceOn('save', () => {
console.log('This is the first save');
});A string can be passed as the callback function. The string is the name of the method to call.
An array (or iterable) of event names can be passed instead of a single event name. An array (or iterable) of callback functions can be passed instead of a single callback function. This sets up a bulk subscription.
Bulk Subscriptions
The bulkSubscribe method accepts the same subscribe config object, except that callbackFunction and eventName may each be an iterable:
pubsub.bulkSubscribe({
callbackFunction: event => {
console.log(`User ${event.name} event:`, event.data);
},
eventName: [
'userLogin',
'userLogout'
],
stageName: 'on'
});Pass an array of subscribe configs to set up unrelated subscriptions in one call:
pubsub.bulkSubscribe([{
callbackFunction: event => {
console.log(`User ${event.name} event:`, event.data);
},
eventName: [
'userLogin',
'userLogout'
],
stageName: 'on'
}, {
callbackFunction: 'validateForm',
eventName: 'formSubmit',
once: true,
stageName: 'before'
}]);A Bulk Subscription Is One Subscription
The bulkSubscribe method produces a single logical subscription, even when it covers multiple events or multiple callback functions. It returns one Subscription instance with an unsubscribe method that releases everything it created.
For each subscription config passed to bulkSubscribe, once means the callback function runs once, not the callback function runs once per event. When multiple event names are given, this sets up a race where the first event to be published (and pass a filter function) is the one that wins.
// Whichever event is published first wins. The rest are unsubscribed.
pubsub.bulkSubscribe({
callbackFunction: event => {
console.log(event.name);
},
eventName: [
'failure',
'success'
],
once: true,
stageName: 'on'
});When multiple callback functions are given, they form a group. The group of callback functions share the config's filterFunction and once properties. For a group, once means every callback function in the group runs once. If multiple event names are given, the first event to be published (and pass a filter function) is still the one that wins, but all of the callback functions in the group will be executed once. When a filterFunction is provided for a group of callback functions, the filter function is only run once per event.
Within a bulk subscribe config, in place of a callbackFunction property, a config property may be provided with an object with callbackFunction and filterFunction properties. This enables a specific callback function to have its own filter function in addition to the group's filter function.
Asynchronous Subscriptions
The until method subscribes to an event once and returns a promise that resolves with a snapshot of the event. This enables awaiting an event. It accepts an event name argument:
await pubsub.until('dataChanged');By default, until subscribes to the after stage. Pass a config object to choose a different stage or to supply a filter function:
const eventSnapshot = await pubsub.until({
eventName: 'dataChanged',
filterFunction: event => event.publisher === interestingObject,
stageName: 'before'
});The until method's config object does not accept a callbackFunction or once property. It also accepts more properties which are described under Canceling below.
Racing Several Events
until is a bulk subscription with once, so passing several event names produces a race. The promise resolves with whichever event is published first, and the rest are released:
const {
data,
name
} = await pubsub.until({
eventName: [
'failure',
'success'
]
});
if (name === 'failure') {
throw data.error;
}Combine it with a filter function to wait for a specific outcome:
// Resolves for whichever of these events reports the job we care about
const eventSnapshot = await pubsub.until({
eventName: [
'jobDone',
'jobFailed'
],
filterFunction: event => event.data.jobId === jobId
});Awaited Events Are Always Complete
An await always resumes asynchronously. A published event's dispatch is always entirely synchronous. By the time an awaiting function resumes, the event has already finished every stage it was going to reach. This has two consequences for the promise returned by the until method:
- The promise cannot influence the event. There is no opportunity to call
prevent(),stopDispatch(),stopDistribution(), orstopEvent(), no matter which stage was subscribed to.untilis for observing events, not controlling them. Use an ordinary subscription when the handler needs to participate in the event lifecycle. - Mutable event data reflects its final state. The resolved snapshot holds a reference to the same
dataobject the event carried. It is not cloned. If a later stage mutated that object, an awaiting function sees the mutated version even when it subscribed to thebeforestage.
You might ask: If the event lifecycle is finished no matter what, why would I ever call the until method to subscribe to any other event stage? The stage still determines whether the promise resolves at all. A prevented event runs its before stage but never reaches after, so a before stage promise settles for every publish attempt while an after stage promise only settles for events that completed.
The Event Snapshot
The resolved value is a frozen snapshot captured at the moment the subscription ran, not the live event instance. After dispatch, an event instance would report the last stage reached rather than the stage that was subscribed to. The snapshot has no event control methods. It contains:
- completed: Whether the event had completed its complete stage
- data: Data associated with the event
- distributor: Object that distributed the event
- name: Name of the event
- publisher: Object that published the event
- stageName: Stage when the subscription ran
Canceling
An until subscription is a cancelable task, and it uses the same interface as the rest of the isotropic ecosystem, provided by isotropic-timeout-cancel.
The returned promise carries a cancel method, a canceled getter, a subscribed getter, an unsubscribe method, and a Symbol.dispose method:
{
const promise = pubsub.until('dataChanged');
if (noLongerInterested) {
promise.cancel();
}
}
{
using promise = pubsub.until('dataChanged');
if (stillInterested) {
console.log((await promise).data);
}
// The subscription is released automatically at the end of this block
}There are two ways to release the subscription, and they differ only in whether the promise settles:
cancel(config)releases the subscription and rejects the promise with a standardized error. It returns the promise, so it can be chained.unsubscribe()releases the subscription and leaves the promise unsettled, exactly like unsubscribing any other subscription. It returns a Boolean.
Symbol.dispose behaves like unsubscribe, so a using declaration that goes out of scope never produces a rejection to handle.
Timeouts And Signals
The config object accepts the same cancellation options as any other isotropic cancelable task:
- details: An object included as the
detailsof generated errors - signal: An
AbortSignalthat cancels the subscription when it aborts. A signal that has already aborted cancels before any subscription is created. - silent: When
true, the promise never rejects. Every form of cancellation, including a timeout, simply releases the subscription and leaves the promise unsettled. Default:false - subject: The subject of generated error messages, as in
`${subject} timed out`. Default:'Event' - timeout: A number of milliseconds or a
Temporal.Duration. If the event has not been published by the time it elapses, the subscription is released and the promise rejects with aTimeoutError.
// Give up after five seconds
try {
const eventSnapshot = await pubsub.until({
eventName: 'dataChanged',
subject: 'Data change',
timeout: 5000
});
} catch (error) {
// Error: Data change timed out
}Publishing the event, unsubscribing, canceling, and disposing all clear a pending timeout, so a settled or released until never leaves a timer behind.
| Cause | Error name | Message |
| --- | --- | --- |
| The timeout elapsed | TimeoutError | `${subject} timed out` |
| An AbortSignal aborted | AbortError | `${subject} aborted` |
| cancel() with no reason | CanceledError | `${subject} canceled` |
| A reject event was published | RejectError | `${subject} rejected` |
Calling cancel({ reason }) delivers that reason as-is instead of a generated error, and cancel({ silent: true }) releases the subscription without rejecting.
These extra properties exist only on the promise returned by the until method. Chaining the promise with then returns an ordinary promise without them.
Rejecting On An Error Event
Racing a success event against a failure event resolves either way, which leaves the awaiting code to check which event it got. The reject config property moves the failure onto the promise's rejection channel instead, so ordinary try/catch handles it:
try {
const eventSnapshot = await pubsub.until({
eventName: 'success',
reject: 'failure'
});
console.log('Succeeded with', eventSnapshot.data);
} catch (error) {
// error.name is 'RejectError'
console.error('Failed with', error.details.eventSnapshot.data);
}This is a common pattern when an event's complete stage begins asynchronous work. The event dispatch is synchronous so listeners of that event are only notified that the work began. In order for listeners to know when the work is complete or to observe the outcome, it publishes a separate completion event when the work finishes, or a separate error event if it failed. An observer that wants the outcome had to subscribe to both and then sort out which arrived. With reject, the promise expresses the outcome directly.
The rejection is an isotropic-error named RejectError, with the message `${subject} rejected`. Its details object carries the eventSnapshot of the event that caused the rejection, alongside any details given to the until config.
reject accepts an event name, a config object, or an iterable of either:
// A single event name
await pubsub.until({
eventName: 'success',
reject: 'failure'
});
// Several event names
await pubsub.until({
eventName: 'success',
reject: [
'failure',
'canceled'
]
});
// A config object, for a different stage or a filter function
await pubsub.until({
eventName: 'jobDone',
reject: {
eventName: 'jobFailed',
filterFunction: event => event.data.jobId === jobId,
stageName: 'on'
}
});A reject config object accepts the same subscription properties as eventName does, other than callbackFunction and once. Its eventName may itself be an iterable. Each reject entry inherits the until config's stageName unless it sets its own, and it does not inherit the config's filterFunction or other subscription properties, since those describe the resolve event.
A few points of behavior:
- Resolution wins a tie. If the resolve event and a reject event are both spent one-time events that have already been published, the promise resolves. The resolve subscription is registered first, so it is the one that runs.
silentdoes not apply.silentsuppresses rejections from cancellation. A reject event is an outcome rather than a cancellation, so it rejects even whensilentistrue.- Settling releases everything. Resolving, rejecting, canceling, unsubscribing, or disposing releases the resolve subscription and every reject subscription together.
Events That Never Publish
If the event is never published, the promise never settles. Destroying the object releases the subscription but does not settle the promise, and neither does unsubscribe. This is deliberate: an until subscription that is still waiting is still live, and a promise that resolves only when the event actually happens is the purpose of the method.
Since the subscription is registered on the object, the object holds a reference to the promise for as long as it stays subscribed. It is not eligible for garbage collection during that time. That means an until that will never be satisfied is retained by a long-lived object until something releases it. This will leak memory if the promise goes out of scope but the object remains. Use timeout, signal, cancel(), unsubscribe(), or using for any until whose event is not guaranteed to be published, and you are no longer interested in waiting for it.
Awaiting A Once Event That Already Published
Subscribing to a publishOnce event that has already been published executes the subscription immediately, so until resolves whether or not the event has already happened. This makes it a reliable way to wait on one-time events, and it works when racing several event names too. If one of them has already been published, the promise resolves with it and the subscriptions to the others are released immediately.
Advanced Features
Event Distribution
An object can distribute its events to other objects, so that subscribers on those other objects are executed too.
a.addDistributor(b) means b also receives a's events. Read it as "add b to the set of objects my events are distributed to." Distribution flows from the object that publishes toward the distributors it was given, so to make child events reach a parent, the child adds the parent:
import _Pubsub from 'isotropic-pubsub';
{
// Create a hierarchy of pubsub objects
const child1 = _Pubsub(),
child2 = _Pubsub(),
grandchild = _Pubsub(),
root = _Pubsub();
// Each object distributes its events up to its parent
child1.addDistributor(root);
child2.addDistributor(root);
grandchild.addDistributor(child1);
// Subscribe only at the root
root.on('dataChanged', event => {
console.log(`Data changed by: ${event.publisher.id}, seen at: ${event.distributor.id}`);
});
// Events published anywhere in the hierarchy reach root
grandchild.publish('dataChanged', {
value: 'new value'
});
}Here grandchild.publish reaches grandchild, then child1, then root, so the root subscriber runs.
Each object appears in a distribution path at most once, so distributors may re-converge or even form cycles without an event being delivered twice.
Custom Event Dispatchers
Configure custom behavior for specific event types:
// Configure a custom event type
pubsub.defineDispatcher('formSubmit', {
// Allow publish from public methods
allowPublicPublish: true,
// Run this method when the event completes
completeFunction: 'processFormSubmit',
// Automatically provide data to all subscribers
data: {
formVersion: '1.2.0'
},
// Allow preventing this event
preventable: true
});
// Public method to publish the event
pubsub.publish('formSubmit', {
formData: {
email: '[email protected]',
name: 'John'
}
});When you publish or subscribe to an event that hasn't been explicitly defined using defineDispatcher, a default configuration is used automatically. However, best practice is to explicitly define events.
Note: the automatic default configuration sets
allowPublicPublish: true, but a dispatcher you define yourself defaults toallowPublicPublish: false. If you define a dispatcher for an event that application code publishes with the publicpublish()method, setallowPublicPublish: trueexplicitly.
Construction Configuration
The constructor accepts an optional config object with three optional properties that set up the event system before the instance is used: pubsub defines dispatchers, distributors establishes event distribution, and subscribe registers subscriptions.
const pubsub = _Pubsub({
distributors: [
parent
],
pubsub: {
dataChanged: {
allowPublicPublish: true
}
},
subscribe: {
dataChanged: event => {
console.log('Data changed:', event.data);
}
}
});distributors
Distributes this object's events to one or more other objects, equivalent to calling addDistributor immediately after construction. It accepts a single object or any iterable of objects.
const child = _Pubsub({
distributors: new Set([
parentA,
parentB
])
});This matters for subclasses that publish events while initializing. Distribution paths are resolved when an event is published. By passing distributors to the constructor, it ensures they are in place before any events are published.
subscribe
Registers subscriptions on the new instance, keyed by event name. Each value may be a callback function, a method name, a subscription config object, or an iterable of any of those:
const pubsub = _Pubsub({
subscribe: {
// A callback function, subscribed to the on stage
dataChanged: event => {
console.log('Data changed:', event.data);
},
// A method name
dataLoaded: '_handleDataLoaded',
// A config object, which may specify a stage name
dataSaved: {
callbackFunction: event => {
console.log('Saved:', event.data);
},
once: true,
stageName: 'after'
},
// Several subscriptions to the same event
dataRemoved: [
'_handleDataRemoved',
{
callbackFunction: '_validateRemoval',
filterFunction: '_isRemovable',
stageName: 'before'
}
]
}
});Subscriptions default to the on stage. Any other subscription config is passed through.
These are public subscriptions so events configured with allowPublicSubscription: false are not subscribed this way. A subclass that needs to subscribe to its own protected events should do so with _bulkSubscribe in its _init method instead.
Using as a Base Class
Extend the Pubsub class to create event-aware components:
import _make from 'isotropic-make';
import _Pubsub from 'isotropic-pubsub';
const _UserManager = _make('UserManager', _Pubsub, {
addUser (user) {
// Store user
// ...
// Publish event
this._publish('userAdded', {
user
});
return this;
},
removeUser (userId) {
// Remove user
// ...
// Publish event
this._publish('userRemoved', {
userId
});
return this;
},
_init (...args) {
Reflect.apply(_Pubsub.prototype._init, this, args);
return this;
}
});
{
const userManager = _UserManager();
// Subscribe to user events
userManager.on('userAdded', event => {
console.log('New user added:', event.data.user);
});
}Using as a Mixin
Use Pubsub as a mixin to add event capabilities to existing classes:
import _make from 'isotropic-make';
import _Pubsub from 'isotropic-pubsub';
// Define a class with Pubsub as a mixin
const _DataStore = _make('DataStore', [
_Pubsub
], {
get (key) {
return this._data[key];
},
set (key, value) {
const oldValue = this._data[key];
this._data[key] = value;
// Publish change event
this.publish('dataChanged', {
key,
newValue: value,
oldValue
});
return this;
},
_init (...args) {
// Initialize Pubsub functionality
Reflect.apply(_Pubsub.prototype._init, this, args);
this._data = {};
return this;
}
});
{
const store = _DataStore();
// Subscribe to data changes
store.on('dataChanged', event => {
console.log(`Data changed: ${event.data.key} = ${event.data.newValue}`);
});
// Set data triggers the event
store.set('username', 'john.doe');
}Advanced Event Subscription
When subscribing to events in isotropic-pubsub, you have several options for specifying callbacks and controlling their execution context.
Callback Functions and Host Context
You can specify a callback function in three ways:
- Direct function reference:
pubsub.on('dataChanged', event => {
console.log(`Data changed: ${event.data.key}`);
});- Configuration object with function reference:
pubsub.on('dataChanged', {
callbackFunction: event => {
console.log(`Data changed: ${event.data.key}`);
}
});- Method name as string or symbol:
// Using a string method name
pubsub.on('dataChanged', {
callbackFunction: 'handleDataChange',
host: this
});
// Or using a Symbol
const dataChangeHandlerSymbol = Symbol('dataChangeHandler');
this[dataChangeHandlerSymbol] = event => {
console.log(`Data changed: ${event.data.key}`);
};
pubsub.on('dataChanged', {
callbackFunction: dataChangeHandlerSymbol,
host: this
});The host parameter is particularly useful when you want to execute the callback in a specific context. If not specified, the host defaults to the dispatcher itself. The host becomes the value of this within the callback function.
import _make from 'isotropic-make';
import _Pubsub from 'isotropic-pubsub';
// The DataStore class
const _DataStore = _make('DataStore', [
_Pubsub
], {
handleDataChange (event) {
// 'this' refers to the DataStore instance
console.log(`Data changed in ${this.name}: ${event.data.key}`);
},
_init (config) {
this.name = config.name || 'DefaultStore';
// Subscribe using method name and this as host
this.on('dataChanged', 'handleDataChange');
return this;
}
});The Binding Pattern
In some cases, you might need to bind a function to a specific context, especially when using callbacks in event handlers:
const logger = _Logger(),
store = _DataStore();
// Bind the logger's log method to the logger instance
store.on('dataChanged', logger.log.bind(logger));This pattern is useful when working with libraries or objects that expect their methods to be called with a specific this context. The host does not need to be provided when the function is already bound.
Return Values from Event Handlers
When you subscribe to an event in isotropic-pubsub, the subscription methods return a subscription object that allows you to manage that subscription.
Subscription Objects
Subscription objects have the following structure:
{
subscribed: true, // Boolean indicating if the subscription is active
unsubscribe: Function // Method to cancel the subscription
}Here's how to use subscription objects:
// Create a subscription
const subscription = pubsub.on('dataChanged', event => {
console.log('Data changed:', event.data);
});
// Check if the subscription is active
console.log(subscription.subscribed); // true
// Unsubscribe when done
subscription.unsubscribe();
// The subscription is no longer active
console.log(subscription.subscribed); // falseBulk Subscription Returns
When using bulkSubscribe, the return value depends on how many subscriptions were created:
// Single subscription in bulk form
const singleSub = pubsub.bulkSubscribe({
eventName: 'event1',
stageName: 'on',
config: callback
});
// Returns a single subscription object
// Multiple subscriptions
const multiSub = pubsub.bulkSubscribe([
{
eventName: 'event1',
stageName: 'on',
config: callback1
},
{
eventName: 'event2',
stageName: 'before',
config: callback2
}
]);
// Returns a composite subscription objectThe composite subscription object from multiple subscriptions has:
{
subscribed: true, // True if ANY of the subscriptions are active
subscriptions: [/* array of individual subscription objects */],
unsubscribe: Function // Unsubscribes ALL contained subscriptions
}Unsubscribing Within Handlers
You can unsubscribe a handler from within itself using the event object:
// One-time handler that unsubscribes itself
pubsub.on('notification', event => {
console.log('Got notification:', event.data);
// Unsubscribe this handler
event.unsubscribe();
});
// Equivalent to using the built-in once methods
pubsub.onceOn('notification', callback);Managing Subscription Lifecycles
It's a good practice to store subscription objects for later cleanup, especially in components with a lifecycle:
import _make from 'isotropic-make';
import _Pubsub from 'isotropic-pubsub';
const _Component = _make('Component', [
_Pubsub
], {
_destroy (...args) {
// Clean up all subscriptions
this._subscriptions.forEach(subscription => subscription.unsubscribe());
this._subscriptions = [];
// Call parent destroy
Reflect.apply(_Pubsub.prototype._destroy, this, args);
},
_init (...args) {
// Call parent _init
Reflect.apply(_Pubsub.prototype._init, this, args);
// Store subscriptions
this._subscriptions = [];
// Add subscriptions
this._subscriptions.push(
this.on('event1', '_handleEvent1'),
this.on('event2', '_handleEvent2')
);
return this;
}
});Example Patterns
Form Validation
import _Pubsub from 'isotropic-pubsub';
class FormController {
constructor () {
this.pubsub = _Pubsub();
// Set up validation before submission
this.pubsub.before('submit', event => {
const {
formData
} = event.data;
// Validate required fields
if (!formData.name || !formData.email) {
console.error('Required fields missing');
event.prevent();
return;
}
// Validate email format
if (!/^.+@.+\..+$/.test(formData.email)) {
console.error('Invalid email format');
event.prevent();
return;
}
});
// Process form on submission
this.pubsub.on('submit', event => {
console.log('Processing form submission:', event.data.formData);
// Process form...
});
// Log after submission
this.pubsub.after('submit', () => {
console.log('Form submission completed at:', Date.now());
});
}
submitForm (formData) {
this.pubsub.publish('submit', {
formData
});
}
}
const controller = new FormController();
controller.submitForm({
email: '[email protected]',
message: 'Hello world!',
name: 'John Doe'
});Communication Between Components
import _make from 'isotropic-make';
import _Pubsub from 'isotropic-pubsub';
// Shared event bus
const eventBus = _Pubsub();
// Cart component
const _Cart = _make('Cart', {
addItem (item) {
this.items.push(item);
eventBus.publish('itemAdded', {
item,
totalItemCount: this.items.length
});
return this;
},
removeItem (itemId) {
const index = this.items.findIndex(item => item.id === itemId);
if (index !== -1) {
const item = this.items.splice(index, 1)[0];
eventBus.publish('itemRemoved', {
item,
totalItemCount: this.items.length
});
}
return this;
},
_init () {
this.items = [];
return this;
}
}),
// Header component with cart indicator
_CartIndicator = _make('CartIndicator', {
updateDisplay () {
console.log(`Cart indicator updated: ${this.count} items`);
// Update UI...
},
_init () {
this.count = 0;
// Subscribe to cart events
eventBus.on([
'itemAdded',
'itemRemoved'
], event => {
this.count = event.data.totalItemCount;
this.updateDisplay();
});
return this;
}
});
{
// Instantiate components
const cart = _Cart(),
indicator = _CartIndicator();
// Use components
cart.addItem({
id: 1,
name: 'Product 1',
price: 9.99
});
// Cart indicator updated: 1 items
cart.addItem({
id: 2,
name: 'Product 2',
price: 19.99
});
// Cart indicator updated: 2 items
cart.removeItem(1);
// Cart indicator updated: 1 items
}Cancelable Operations
import _later from 'isotropic-later';
import _Pubsub from 'isotropic-pubsub';
class FileUploader {
constructor () {
this.pubsub = _Pubsub();
// Configure the upload event
this.pubsub.defineDispatcher('upload', {
allowPublicPublish: true,
eventStoppable: true,
preventable: true
});
// Before upload - validate file
this.pubsub.before('upload', event => {
const {
file
} = event.data;
if (file.size > 10 * 1024 * 1024) { // 10MB
console.error('File too large');
event.prevent();
}
});
// On upload - start operation
this.pubsub.on('upload', event => {
const {
file
} = event.data;
console.log(`Starting upload of ${file.name}`);
// Start upload process
this.currentUpload = {
cancel: () => {
console.log('Upload canceled');
clearInterval(this.progressInterval);
event.stopEvent();
},
file,
progress: 0
};
// Simulate upload progress
this.progressInterval = setInterval(() => {
this.currentUpload.progress += 10;
this.pubsub.publish('uploadProgress', {
file: this.currentUpload.file,
progress: this.currentUpload.progress
});
if (this.currentUpload.progress >= 100) {
clearInterval(this.progressInterval);
this.pubsub.publish('uploadComplete', {
file: this.currentUpload.file
});
}
}, 500);
});
}
uploadFile (file) {
this.pubsub.publish('upload', {
file
});
return {
cancel: () => {
if (this.currentUpload) {
this.currentUpload.cancel();
}
}
};
}
}
{
// Usage
const uploader = new FileUploader(),
upload = uploader.uploadFile({
name: 'document.pdf',
size: 5 * 1024 * 1024
});
// Cancel after 1.5 seconds
_later(1500, () => {
upload.cancel();
});
}Event Distribution Hierarchy
import _make from 'isotropic-make';
import _Pubsub from 'isotropic-pubsub';
// Component base class
const _Component = _make('Component', _Pubsub, {
addChild (id) {
const child = _Component({
id,
parent: this
});
this.children.push(child);
return child;
},
_init (...args) {
Reflect.apply(_Pubsub.prototype._init, this, args);
const {
id,
parent = null
} = typeof args[0] === 'string' ?
{
id: args[0]
} :
args[0];
this.children = [];
this.id = id;
this.parent = parent;
// Set up distribution to parent
if (parent) {
this.addDistributor(parent);
}
return this;
}
});
{
// Create a component tree
const app = _Component('app'),
footer = app.addChild('footer'),
header = app.addChild('header'),
main = app.addChild('main'),
mainContentArea = main.addChild('content'),
sidebar = main.addChild('sidebar'),
topNav = header.addChild('main-nav'),
userMenu = header.addChild('user-menu');
// Subscribe at the root
app.on('userAction', event => {
console.log(`User action in ${event.publisher.id}: ${event.data.action}`);
});
// Trigger events from leaf nodes
userMenu.publish('userAction', {
action: 'logout'
});
// User action in user-menu: logout
mainContentArea.publish('userAction', {
action: 'save'
});
// User action in content: save
}Custom Event Dispatchers with Before/After Hooks
import _Pubsub from 'isotropic-pubsub';
class DataService {
constructor () {
this.data = {};
this.pubsub = _Pubsub();
// Configure CRUD event dispatchers
this.pubsub.defineDispatcher({
// Create operation
'create': {
allowPublicPublish: true,
completeFunction: '_handleCreate',
lifecycleHost: this
},
// Delete operation
'delete': {
allowPublicPublish: true,
completeFunction: '_handleDelete',
lifecycleHost: this
},
// Read operation
'read': {
allowPublicPublish: true,
completeFunction: '_handleRead',
lifecycleHost: this
},
// Update operation
'update': {
allowPublicPublish: true,
completeFunction: '_handleUpdate',
lifecycleHost: this
}
});
// Add validation for all operations
this.pubsub.before({
create: {
callbackFunction: '_validateCreate',
host: this
},
delete: {
callbackFunction: '_validateDelete',
host: this
},
update: {
callbackFunction: '_validateUpdate',
host: this
}
});
// Add logging for all operations
this.pubsub.after([
'create',
'delete',
'read',
'update'
], {
callbackFunction: '_logOperation',
host: this
});
}
create ({
data,
id
}) {
return this.pubsub.publish('create', {
data,
id
});
}
delete ({
id
}) {
return this.pubsub.publish('delete', {
id
});
}
read ({
id
}) {
return this.pubsub.publish('read', {
id
});
}
update ({
data,
id
}) {
return this.pubsub.publish('update', {
data,
id
});
}
// Complete handlers
_handleCreate (event) {
const {
data,
id
} = event.data;
this.data[id] = data;
console.log(`Created: ${id}`);
}
_handleDelete (event) {
const {
id
} = event.data;
delete this.data[id];
console.log(`Deleted: ${id}`);
}
_handleRead (event) {
const {
id
} = event.data;
return this.data[id];
}
_handleUpdate (event) {
const {
data,
id
} = event.data;
this.data[id] = {
...this.data[id],
...data
};
console.log(`Updated: ${id}`);
}
// Logging hook
_logOperation (event) {
console.log(`[LOG] ${event.name} - ${JSON.stringify(event.data)}`);
}
// Validation hooks
_validateCreate (event) {
const {
data,
id
} = event.data;
if (this.data[id]) {
console.error(`Id ${id} already exists`);
event.prevent();
}
if (!data || typeof data !== 'object') {
console.error(`Invalid data provided`);
event.prevent();
}
}
_validateDelete (event) {
const {
id
} = event.data;
if (!this.data[id]) {
console.error(`Id ${id} does not exist`);
event.prevent();
}
}
_validateUpdate (event) {
const {
data,
id
} = event.data;
if (!this.data[id]) {
console.error(`Id ${id} does not exist`);
event.prevent();
}
if (!data || typeof data !== 'object') {
console.error(`Invalid data provided`);
event.prevent();
}
}
}
{
// Usage
const service = new DataService();
service.create({
data: {
email: '[email protected]',
name: 'John'
},
id: 'user1'
});
// Created: user1
// [LOG] create - {"data":{"email":"[email protected]","name":"John"},"id":"user1"}
service.update({
data: {
name: 'John Doe'
},
id: 'user1'
});
// Updated: user1
// [LOG] update - {"data":{"name":"John Doe"},"id":"user1"}
// Try to update non-existent record
service.update({
data: {
name: 'Jane'
},
id: 'user2'
});
// Id user2 does not exist
}Inheritance and Event Configuration
While the examples in the previous sections show how to use defineDispatcher directly, the recommended approach for complex applications is to use class inheritance with isotropic-make. This allows for better organization and reusability of event configurations.
Using the Static _pubsub Property
The isotropic-pubsub module integrates with isotropic-property-chainer to provide a clean inheritance pattern for event configurations:
import _make from 'isotropic-make';
import _Pubsub from 'isotropic-pubsub';
// Base service with common event configurations
const _BaseService = _make('BaseService', _Pubsub, {
// Instance methods
_handleCreate (event) {
console.log('Creating resource:', event.data);
// Implementation...
},
_init (...args) {
Reflect.apply(_Pubsub.prototype._init, this, args);
this._before('create', '_validateCreate');
return this;
},
_validateCreate (event) {
if (!event.data.id) {
console.error('Missing ID');
event.prevent();
}
}
}, {
// Static properties including event configurations
_pubsub: {
// Define the 'create' event with base configuration
create: {
allowPublicPublish: true,
completeFunction: '_handleCreate',
preventable: true
}
}
}),
// Derived service with additional event configurations
_UserService = _make('UserService', _BaseService, {
// Instance methods
_handleLogin (event) {
console.log('New login:', event.data);
// Implementation...
},
_init (...args) {
Reflect.apply(_BaseService.prototype._init, this, args);
this._before('create', '_validateEmail');
return this;
},
_validateEmail (event) {
if (!event.data.email || !event.data.email.includes('@')) {
console.error('Invalid email format');
event.prevent();
}
}
}, {
// Static properties with extended event configurations
_pubsub: {
// The create event doesn't need to be specified again.
// it gets inherited from _BaseService
// Add a user-specific event
login: {
allowPublicPublish: true,
completeFunction: '_handleLogin'
}
}
});In this example:
_BaseServicedefines a basecreateevent with base configuration._UserServiceinherits this configuration and adds another event.
This inheritance pattern allows you to build complex event systems while maintaining a clean separation of concerns.
Benefits of Using the Static _pubsub Property
- Automatic Inheritance: Event configurations are automatically inherited and can be extended or overridden in derived classes.
- Better Organization: Event definitions are centralized in the class definition rather than scattered throughout instance methods.
- Reusability: Common event patterns can be defined once and reused across multiple derived classes.
- Encapsulation: Event handling logic is kept within the class that owns it.
API Reference
Pubsub Class
Constructor
const pubsub = _Pubsub(options);- distributors: An object or iterable of objects to distribute this object's events to
- pubsub: Event dispatcher definitions, as passed to
defineDispatcher - subscribe: Subscriptions to register, keyed by event name
Instance Methods
- addDistributor(distributor): Add an object to distribute events to
- after(eventName, config): Subscribe to the after stage of an event
- before(eventName, config): Subscribe to the before stage of an event
- bulkSubscribe(config): Subscribe to multiple events at once
- bulkUnsubscribe([stageName], [eventName]): Unsubscribe from multiple events
- defineDispatcher(eventName, config): Define a custom event dispatcher
- destroy(...args): Destroy the pubsub instance
- getOnceEventSnapshot(eventName): Return the event snapshot of a spent
completeOnceorpublishOnceevent, ornull - hasDistributor(distributor): Check if distributor has been added
- on(eventName, config): Subscribe to the on stage of an event
- onceAfter(eventName, config): Subscribe once to the after stage
- onceBefore(eventName, config): Subscribe once to the before stage
- onceOn(eventName, config): Subscribe once to the on stage
- publish(eventName, data): Publish an event with optional data
- removeDistributor(distributor): Remove a distributor
- subscribe(config): Subscribe to an event at a specific stage
- subscribe(stageName, eventName, config): Alternative way to subscribe to an event as a specific stage
- until(eventNameOrConfig): Subscribe once and return a promise that resolves with an event snapshot
Subscription Config
Accepted by after, before, bulkSubscribe, on, onceAfter, onceBefore, onceOn, subscribe, until and the subscribe construction config. Shortcut methods supply some of these properties automatically.
- callbackFunction: Function or method name executed when the event is dispatched
- eventName: Name of the event
- filterFunction: Function or method name that decides whether the callback function runs for a given event
- host: The value of
thiswithin the callback function and filter function, and the object a method name is resolved against - once: Whether to unsubscribe after the callback function runs
- stageName: Stage to subscribe to
bulkSubscribe and the stage shortcut methods accept an iterable of event names and/or an iterable of callback functions. bulkSubscribe additionally accepts a config property in place of callbackFunction.
Until Config
until accepts an event name, or a config object with eventName, filterFunction, host, and stageName from the subscription config (but not callbackFunction or once), plus the cancellation options:
- details: An object included as the
detailsof generated errors - reject: An event name, a subscription config object, or an iterable of either. Publishing one of these events rejects the promise with a
RejectError. - signal: An
AbortSignalthat cancels the subscription when it aborts - silent: When
true, cancellation never rejects the promise. Does not apply torejectevents. Default:false - subject: The subject of generated error messages. Default:
'Event' - timeout: A number of milliseconds or a
Temporal.Durationafter which the promise rejects with aTimeoutError
stageName defaults to 'after' rather than 'on'. Each reject entry inherits it unless it sets its own.
Event Object
Event objects are passed to subscribers and contain:
- completed: Whether the event has completed its complete stage
- data: Data associated with the event
- dispatchStopped: Whether dispatch is stopped
- distributionStopped: Whether distribution is stopped
- distributor: Object distributing the event
- eventStopped: Whether event is stopped
- name: Name of the event
- publisher: Object that published the event
- snapshot: A frozen copy of event state
- stageName: Current stage name
Event Control Methods
- isPrevented(stageName='complete'): Returns whether the given stage is prevented
- prevent(stageName='complete'): Prevents the given stage
- stopDispatch(): Stop dispatch to further handlers in current stage
- stopDistribution(): Stop distribution to other objects
- stopEvent(): Stop all stages of the event
- unsubscribe(): Unsubscribe the current handler
Event Snapshot
A promise returned by the until method will resolve with an event snapshot. It is a frozen object that captures the event's state at the time the subscription ran. It has no event control methods.
- completed: Whether the event has completed its complete stage
- data: Data associated with the event
- distributor: Object that distributed the event
- name: Name of the event
- publisher: Object that published the event
- stageName: Stage when the subscription ran
Subscription Object
Returned when subscribing to events:
- subscribed: Whether the subscription is active
- unsubscribe(): Method to unsubscribe
Until Promise
Returned by the until method. A promise that resolves with an event snapshot, with subscription management and cancellation added:
- cancel(config): Releases the subscription and rejects the promise. Accepts
reason,signal, andsilent, as in isotropic-cancel. Returns the promise. - canceled: Whether the subscription was canceled
- subscribed: Whether the subscription is active
- unsubscribe(): Releases the subscription without settling the promise. Returns a Boolean.
- Symbol.dispose: Releases the subscription without settling the promise, for
usingdeclarations
Advanced Configuration
Event Dispatcher Options
pubsub.defineDispatcher('eventName', {
// Allow duplicate subscriptions
allowDuplicateSubscription: true,
// Control whether public publish is allowed
allowPublicPublish: true,
// Control whether public subscribe is allowed
allowPublicSubscription: true,
// Control whether public unsubscribe is allowed
allowPublicUnsubscription: true,
// Function to run on event completion
completeFunction: 'functionOrMethodName',
// Complete the event only once
completeOnce: false,
// Default data to merge with event data
data: { /* ... */ },
// Control if dispatch can be stopped
dispatchStoppable: true,
// Control if event should be distributed
distributable: true,
// Control if distribution can be stopped
distributionStoppable: true,
// Control if event can be stopped
eventStoppable: true,
// Custom host for lifecycle functions
lifecycleHost: null,
// Control whether events can be prevented
preventable: true,
// Function to run when event stage is prevented
preventFunction: 'functionOrMethodName',
// Publish the event only once
publishOnce: false,
// Custom event stages
stages: ['before', 'on', 'complete', 'after'],
// Function to run when dispatch is stopped
stopDispatchFunction: 'functionOrMethodName',
// Function to run when distribution is stopped
stopDistributionFunction: 'functionOrMethodName',
// Function to run when event is stopped
stopEventFunction: 'functionOrMethodName',
// Function to run when a listener subscribes
subscribeFunction: 'functionOrMethodName',
// Function to run when a listener unsubscribes
unsubscribeFunction: 'functionOrMethodName'
});completeOnce vs publishOnce
publishOnce: When set totrue, the event can only be published once during the lifetime of the object. Any subsequent attempts to publish the event will be ignored.
// This event can only be published once
pubsub.defineDispatcher('initialize', {
publishOnce: true
});
pubsub.publish('initialize', { data: 123 }); // Works
pubsub.publish('initialize', { data: 456 }); // IgnoredcompleteOnce: When set totrue, the event may be published repeatedly until one of those publishes actually completes. Once the complete stage has run, the event is closed and further publishes are ignored entirely.
// This event can be published multiple times, but the complete function only runs once
pubsub.defineDispatcher('load', {
allowPublicPublish: true,
completeFunction: () => console.log('Loading resources'),
completeOnce: true
});
pubsub.before('load', event => {
if (!readyToLoad) {
event.prevent(); // The complete stage does not run, so the event is not spent
}
});
pubsub.publish('load'); // Prevented. Nothing completes, so the event stays open
pubsub.publish('load'); // Prints "Loading resources". The event is now closed
pubsub.publish('load'); // Ignored entirely, no stage runsThe difference between the two is what a prevented publish costs you. publishOnce spends the event on the first publish attempt, whether or not it completes. completeOnce only spends it on the publish that reaches the complete stage, so prevented attempts don't count. Neither one runs any stage once the event is spent.
For both completeOnce and publishOnce, after the event has been spent, any new subscriber is executed immediately with the event that spent it.
Reading Retained Once Event State
The getOnceEventSnapshot(eventName) method returns the event snapshot of a spent completeOnce or publishOnce event, or null if the event has not been spent. This makes the retained state readable synchronously, without subscribing.
It returns null for an event that is neither completeOnce nor publishOnce, since no state is retained for those, and for any event on a destroyed object. The public method returns null for an event that does not allow public subscription. The protected _getOnceEventSnapshot does not apply that restriction.
A publishOnce event is spent by the publish attempt itself, so its snapshot becomes readable even if the event was prevented before completing. Read the snapshot's completed property to distinguish the two. A completeOnce event is only spent by a publish that reaches the complete stage, and its snapshot is readable from within its own completeFunction.
Event Lifecycle Functions
isotropic-pubsub provides a set of special functions that are called at specific points in an event's lifecycle. These lifecycle functions offer powerful hooks to customize event behavior, respond to state changes, and implement cross-cutting concerns like logging or monitoring. By utilizing these functions effectively, you can implement sophisticated event patterns while maintaining clean separation of concerns.
Available Lifecycle Functions
| Lifecycle Function | Called When | Purpose |
|-------------------|-------------|---------|
| completeFunction | The event reaches its completion stage | Execute the primary action for the event |
| preventFunction | Any stage of the event is prevented | React to prevention of an event stage |
| stopDispatchFunction | event.stopDispatch() is called | React to dispatch being halted |
| stopDistributionFunction | event.stopDistribution() is called | React to distribution being halted |
| stopEventFunction | event.stopEvent() is called | React to the entire event being stopped |
| subscribeFunction | A new subscription is created | Validate or modify subscriptions |
| unsubscribeFunction | A subscription is removed | Clean up or react to unsubscriptions |
Configuring Lifecycle Functions
You can specify lifecycle functions when defining an event dispatcher:
pubsub.defineDispatcher('saveData', {
// The primary action function
completeFunction: event => {
// Save the data
saveToDatabase(event.data);
},
// Called when the event is prevented
preventFunction: event => {
console.warn('Save operation prevented:', event);
},
// Called when a new subscription is added
subscribeFunction: ({
config,
dispatcher
}) => {
console.log('New subscription to saveData:', config);
}
});Using Method Names Instead of Functions
As with event handlers, you can use method names instead of functions:
import _make from 'isotropic-make';
import _Pubsub from 'isotropic-pubsub';
const _DataService = _make('DataService', _Pubsub, {
// Lifecycle handler methods
_handleSaveComplete (event) {
this.lastSavedData = event.data;
this.saveCount += 1;
},
_init (...args) {
Reflect.apply(_Pubsub.prototype._init, this, args);
this.lastSavedData = null;
this.saveCount = 0;
this._authorizedSubscriberSet = new Set();
return this;
},
_validateSaveSubscription ({
config
}) {
// Only allow certain components to subscribe
if (!this._authorizedSubscriberSet.has(config.host)) {
console.warn('Unauthorized subscription attempt');
return false; // Prevents the subscription
}
}
}, {
_pubsub: {
saveData: {
completeFunction: '_handleSaveComplete',
subscribeFunction: '_validateSaveSubscription'
}
}
});Using the lifecycleHost Property
By default, lifecycle functions are executed in the context of the dispatcher itself. You can specify a different context using the lifecycleHost property:
const logger = {
logPrevention (event) {
console.log(