๐จ This is a pre-release version, event-tram
is still under active development and the API might change ๐จ
EventTram is a topic-based publish/subscribe library with full Typescript support. Even though it is inspired by
Backbone Events
and Backbone Radio
, EventTram
introduces some new concepts aiming to improve flexibility, predictability, maintainability and type-safety:
- Channels: A channel is a way to group events. This is useful when you want to namespace your events but also make sure that these events can only be published/subscribed only by the consumers that need them. Channels can be used to isolate topics and consumers from each other.
- Readonly and writeonly channels: By leveraging Typescript types, EventTram allows you to create channels that can only be published to or subscribed to. This is useful when you want to enforce a unidirectional data flow in your application.
- Queries: Queries provide uniform way for unrelated parts of the system to communicate with each other, either by requesting data or triggering actions to be performed. The different between queries and normal events is that, queries are pull-based. If a part of the codebase requires immediate access to data or an action, it can use a query instead of depending on a different module. This lets EventTram be the common dependency, decoupling modules from each other even when direct access is required.
- Cross-tab communication: EventTram provides a way to communicate between tabs with the same origin. This is useful when you want to synchronize state between tabs or trigger actions in other tabs.
- Typed contracts: All events, payloads, channels and queries are defined on the type-level by default. There is no need to use constants as your event identifiers or use event factories.
- Register new channels and events on the fy: EventTram allows you to register new channels and events on the fly. This is useful when you want to dynamically add new features, eg: plugins, to your application that your main EventTram instance does not know about during the initialisation phase.
- Synchronisation decoupling: Even though EventTram supports synchronous publishing of events using the
publishSync
method, the default behavior is to publish events asynchronously to enable proper synchronisation decoupling. All events will be published at the end of the current event loop. This helps keep the codebase predictable as the originator of topics will not be blocked while consumers process them.
The library was written with bundle-size in mind. The final bundle-size is now approximately 600bytes in size (minified and gzipped) and another ~300bytes for the cross-tab support strategy, if required. All strict checks exist on the TS-level only so nothing ends up in the final bundle. Events, channels and queries are defined on the type level as well. Adding a new event, channel or query has minimal impact on the bundle size, especially when the final bundle is minified and gzipped.
Simply run
npm i event-tram
๐ Note: The EventTram is a Channel that also let's you create other channels. Anything that can be used on a Channel, can also be used on the root EventTram instance, eg:
eventTram.channel('auth').on(...)
andeventTram.on(...)
Simply define the types, channels and queries using the type helper and instantiate an EventTram
object. This object will usually be exported from a well-known module in your application. You can create as many EventTram instances as you need, or use a single instance and channels to namespace your events.
For more details about the configuration options, refer to the inline JSDoc comments in the code.
Use Event
to define an event - an event is described by a key and an optional payload. Use EventMap
to define a group of events.
import type { Event, EventMap } from 'event-tram';
import { EventTram } from 'event-tram';
// Use Event and EventMap to define your events
type TokenEvents = EventMap<
| Event<'token:fetchStart'> //
| Event<'token:fetchEnd', { token: string }> //
>;
export const eventTram = new EventTram<never,TokenEvents>();
// Listen to an event
eventTram.on('token:fetchEnd', ({ token }) => {
// The argument is typed based on the event payload
});
// Publish an event
// The event payload is typed based on the event definition
eventTram.publish('token:fetchEnd', { token: '123' });
Similar to the above, but with channels. Use Channel
to define a channel - a channel is described by a key, an EventMap
and an optional QueryMap
. Use ChannelMap
to define a group of channels.
import type { Event, EventMap } from 'event-tram';
import { EventTram } from 'event-tram';
// Use Event and EventMap to define your events
type TokenEvents = EventMap<
| Event<'token:fetchStart'> //
| Event<'token:fetchEnd', { token: string }> //
>;
type AuthEvents = EventMap<
| Event<'auth:login'> //
| Event<'auth:logout'> //
>;
// Use Channel and ChannelMap to define your channels
export type AllChannels = ChannelMap<
| Channel<'token', TokenEvents> //
| Channel<'auth', AuthEvents> //
>
export const eventTram = new EventTram<AllChannels>();
// Access a channel, TS here will only allow known channels
// Same as before, everything is typed based on the Event and Channel definitions
eventTram.channel('token').on('token:fetchEnd', ({ token }) => {});
eventTram.channel('token').publish('token:fetchEnd', { token: '123' });
Most of the time, one service will be producing events and many other services will be consumers of those events. To enforce this unidirectional data flow, EventTram allows you to create channels that can only be published to or subscribed to.
Use the ReadonlyChannel
and WriteonlyChannel
, or the .readonly
and .writeonly
methods on any channels to control the access to the channel.
๐ Note
Notice that in the examples below, the services depend on the channel interface and not the actual implementation. This allows for better decoupling and testability, as implementation can be injected at runtime. When testing, there is no need to mock the main EventTram instance of your app, simply pass your own mock implementation.
The following example expands on the previous example and shows alternative syntax for creating a channel and accessing it.
Define a consumer-service.ts
service that can only subscribe to events:
//
import type { ReadonlyChannel } from 'event-tram';
import type { AllChannels } from './event-bus';
class ConsumerService {
constructor(private tokenChannel: ReadonlyChannel<AllChannels['token']>) {
// Only allowed to subscribe to events, cannot publish
this.tokenChannel.on('token:fetchEnd', ({ token }) => {});
}
}
Define a publisher-service.ts
service that can only publish events:
// pulisher-service.ts
import type { AllChannels } from './event-bus';
class PublisherService {
// Alternative syntax, can also be written as WriteonlyChannel<AllChannels['token']>
constructor(private tokenChannel: AllChannels['token']['writeonly']) {
// Only allowed to publish events, cannot subscribe
this.tokenChannel.publish('token:fetchEnd', { token: '123' });
}
}
Finally, wire everything up:
import { eventTram } from './event-bus';
import { ConsumerService } from './consumer-service';
import { PublisherService } from './publisher-service';
// .writeonly and .readonly methods are optional here and can be skipped
// as strictness is enforced on the ctor level
const publisherService = new PublisherService(eventTram.channel('token').writeonly);
const consumerService = new ConsumerService(eventTram.channel('token').readonly);
Similar to the channel example, use Query
to define a query - a query is described by a key and a typed function. The function type is used to 1. type the reply
method and 2. type the query
return value. Use QueryMap
to define a group of queries.
A query
call can also pass parameters to the corresponding reply
function. The parameters are typed based on the query definition.
Queries can also be async.
First, define your types and instantiate the EventTram instance:
import type { Event, EventMap } from 'event-tram';
import { EventTram } from 'event-tram';
type LifecycleEvents = EventMap<
| Event<'tab:visible'> //
| Event<'tab:hidden'> //
>;
// Define queries
type LifecycleQueries = QueryMap<
| Query<'tab:isUnloading', () => boolean> //
| Query<'tab:isFocused', () => boolean> //
| Query<'tab:isLeader', (id: string) => Promise<boolean>> //
>;
// Pass the QueryMap as the second type argument
export type AllChannels = ChannelMap<
| Channel<'tab', LifecycleEvents, LifecycleQueries> //
>
export const eventTram = new EventTram<AllChannels>();
Register the reply
handlers in a module of the codebase that manages tabs and tab-events:
// The return of the callback is going to be the result of the query() call
eventTram.channel('tab').reply('isUnloading', () => {});
// Register a callback that accepts parameters and is async
eventTram.channel('tab').reply('isLeader', (id) => { return Promise.resolve(id === leaderId) });
And finally, query from a different part of the codebase without coupling it with the above:
// Get a sync response
const isUnloading = eventTram.channel('tab').query('isUnloading');
// Get an async response, also pass a parameter
const isLeader = await eventTram.channel('tab').query('isLeader', '123');
If your application requires cross-tab communication, you can use EventTram
and inject the BroadcastChannelNotifyStrategy
strategy to create a strongly-typed uniform API, instead of using the low-level BroadcastChannel
class.
All normal EventTram
features are available in the cross-tab communication channel, including channels, queries and events.
Applications that require both in-tab and cross-tab communication, need to instantiate one EventTram
for each use-case.
Important notes:
- In order to avoid infinite event loops and conform to the BroadcastChannel specification, the cross-tab EventTram cannot be used to communicate with the same tab that created it.
- Published events will not be received by the tab that published them.
- Queries will not be received by the tab that initiated them and will timeout if no other tab responds.
- Queries always return a promise - the handler needs to be asynchronous.
- A
query
call will timeout with an error if there is no tab that registered a handler for the query. The timeout can be customised by passing atimeout
option to theBroadcastChannelNotifyStrategy
constructor.
The following example shows how to create a cross-tab communication channel and use it to synchronize tokens between tabs:
import type { ChannelMap, Event, EventMap, Channel, Query, QueryMap } from 'event-tram';
import { BroadcastChannelNotifyStrategy, EventTram } from 'event-tram';
type TokenEvents = EventMap<
Event<'tokens:emitToken', { sessionId: string; token: string }> //
>;
type TokenQueries = QueryMap<
Query<'tokens:getToken', (sessionId: string) => Promise<string>> //
>;
export type CrossTabChannels = ChannelMap<
Channel<'tokens', TokenEvents, TokenQueries> //
>;
// Define your events, queries and channels as normal
// Instantiate and inject the BroadcastChannelNotifyStrategy
// The crossTabEventTram can now be used to communicate with other tabs
export const crossTabEventTram = new EventTram<CrossTabChannels>({
notifyStrategy: new BroadcastChannelNotifyStrategy('cl_tabs_event_bus'),
});
The following example will show how to register a new channel even after instantiating the EventTram
instance. Registering extra events and queries can be done in the same way, using the registerEvents
and registerQueries
methods.
In the following example, we pretend that the BillingPlugin
module is a plugin that is added to the application at runtime. The module will register a new channel and a new event on the main EventTram instance that will be passed in to the plugin during the initialisation phase.
import type { Event, EventMap, Channel } from 'event-tram';
const BillingPlugin = (_eventTram: ApplicationEventTram) => {
// Define the channel's events
type NewEvents = EventMap<
Event<'billing:init'> //
>;
// Define the new Billing channel
type BillingChannel = Channel<'billing', NewEvents>;
// Register the new channel on the existing EventTram instance
const eventTram = _eventTram.registerChannel<BillingChannel>();
// This will return the same instance, but with the new channel registered
// The new channel and its events are now available
eventTram.channel('billing').on('billing:init', () => {});
}
WIP
Q: Why name it EventTram
instead of EventBus
?
A: All unscoped package names were already taken. The name EventTram
is just a bad pun on EventBus
and Tram
.