ryardley / ts-bus

A lightweight JavaScript/TypeScript event bus to help manage your application architecture.
MIT License
137 stars 8 forks source link

ts-bus

A lightweight TypeScript event bus to help manage your application architecture

Build Status codecov GitHub license

Example

import { EventBus, createEventDefinition } from "ts-bus";

// Define Event
export const someEvent = createEventDefinition<{ url: string }>()("SOME_EVENT");

// Create bus
const bus = new EventBus();

// Subscribe
bus.subscribe(someEvent, event => {
  alert(event.payload.url);
});

// Publish
bus.publish(someEvent({ url: "https://github.com" }));

Rationale

We want to write loosely coupled highly cohesive applications and one of the best and easiest ways to do that is to use an event bus as a management layer for our applications.

This is the kind of thing that you could use effectively in most applications.

For my purposes I wanted a system that:

Alternatives

Upgrading to v3

Version 3 includes a couple of breaking changes to the react extensions. Now both useBusState and useBusReducer return tuples the same as their React equivalents.

// Old 
const state = useBusReducer(/* ... */);

// New 
const [state, dispatch] = useBusReducer(/* ... */);
// Old
const count = useBusState(0, eventCreator);

// New
const useState = useBusState.configure(eventCreator);

const [count, setCount] = useState(0);

Also the configuration for useBusState has changed.

See useBusReducer, useBusState.

Installation

Use your favourite npm client to install ts-bus. Types are included automatically.

Npm:

npm install ts-bus

Yarn:

yarn add ts-bus

Example applications

With Redux Devtools.

Usage

Create a bus

Create your EventBus globally somewhere:

// bus.ts
import { EventBus } from "ts-bus";
export const bus = new EventBus();

Declare events

Next create some Events:

// events.ts
import { createEventDefinition } from "ts-bus";

export const taskCreated = createEventDefinition<{
  id: string;
  listId: string;
  value: string;
}>()("task.created");

export const taskLabelUpdated = createEventDefinition<{
  id: string;
  label: string;
}>()("task.label.updated");

Notice createEventDefinition() will often be called with out a runtime check argument and it returns a function that accepts the event type as an argument. Whilst possibly a tiny bit awkward, this is done because it is the only way we can allow effective discriminated unions. See switching on events.

Runtime payload checking

You can also provide a predicate to do runtime payload type checking in development. This is useful as a sanity check if you are working in JavaScript:

import p from "pdsl";

// pdsl creates predicate functions
const isLabel = p`{
  id: string,
  label: string,
}`;

export const taskLabelUpdated = createEventDefinition(isLabel)(
  "task.label.updated"
);

taskLabelUpdated({ id: "abc" }); // {"id":"abc"} does not match expected payload.

These warnings are suppressed in production.

Subscribing

import { taskLabelUpdated, taskCreated } from "./event";
import { bus } from "./bus";

// You can subscribe using the event creator function
bus.subscribe(taskLabelUpdated, event => {
  const { id, label } = event.payload; // Event is typed
  doSomethingWithLabelAndId({ id, label });
});

Unsubscribing

To unsubscribe from an event use the returned unsubscribe function.

const unsubscribe = bus.subscribe(taskLabelUpdated, event => {
  // ...
});

unsubscribe(); // removes event subscription

Subscribing with a type string

You can use the event type to subscribe.

bus.subscribe("task.created", event => {
  // ...
});

Or you can use wildcards:

bus.subscribe("task.**", event => {
  // ...
});

Subscribing with a predicate function

You can also subscribe using a predicate function to filter events.

// A predicate
function isSpecialEvent(event) {
  return event.payload && event.payload.special;
}

bus.subscribe(isSpecialEvent, event => {
  // ...
});

You may find pdsl a good fit for creating predicates.

Subscription syntax

As you can see above you can subscribe to events by using the subscribe method of the bus.

const unsubscriber = bus.subscribe(<string|eventCreator|predicate>, handler);

This subscription function can accept a few different options for the first argument:

The returned unsubscribe() method will unsubscribe the specific event from the bus.

Publishing events

Now let's publish our events somewhere

// publisher.ts
import { taskLabelUpdated, taskCreated } from "./events";
import { bus } from "./bus";

function handleUpdateButtonClicked() {
  bus.publish(taskLabelUpdated({ id: "638", label: "This is an event" }));
}

function handleDishesButtonClicked() {
  bus.publish(
    taskCreated({ id: "123", listId: "345", value: "Do the dishes" })
  );
}

Using a plain event object

If you want to avoid the direct dependency with your event creator you can use the plain event object:

bus.publish({
  type: "kickoff.some.process",
  payload: props.data
});

Republishing events

Lets say you have received a remote event from a websocket and you need to prevent it from being automatically redispatched you can provide custom metadata with each publication of an event to prevent re-emmission of events over the socket.

import p from "pdsl";

// get an event from a socket
socket.on("event-sync", (event: BusEvent<any>) => {
  bus.publish(event, { remote: true });
});

// This is a shorthand utility that creates predicate functions to match based on a given object shape.
// For more details see https://github.com/ryardley/pdsl
const isSharedAndNotRemoteFn = p`{
  type: ${/^shared\./},
  meta: {
    remote: !true
  }
}`;

// Prevent sending a event-sync if the event was remote
bus.subscribe(isSharedAndNotRemoteFn, event => {
  socket.emit("event-sync", event);
});

Switching on Events and Discriminated Unions

// This function creates foo events
const fooCreator = createEventDefinition<{
  foo: string;
}>()("shared.foo");

// This function creates bar events
const barCreator = createEventDefinition<{
  bar: string;
}>()("shared.bar");

// Create a union type to represent your app events
type AppEvent = ReturnType<typeof fooCreator> | ReturnType<typeof barCreator>;

bus.subscribe("shared.**", (event: AppEvent) => {
  switch (event.type) {
    case String(fooCreator):
      // compiler is happy about payload having a foo property
      alert(event.payload.foo.toLowerCase());
      break;
    case String(barCreator):
      // compiler is happy about payload having a bar property
      alert(event.payload.bar.toLowerCase());
      break;
    default:
  }
});

Wildcard syntax

You can namespace your events using period delimeters. For example:

"foo.*" matches "foo.bar"
"foo.*.thing" matches "foo.fing.thing"
"**" matches everything eg "foo" or "foo.bar.baz"
"*" matches everything within a single namespace eg. "foo" but not "foo.bar"

This is inherited directly from EventEmitter2 which ts-bus currently uses under the hood.

React extensions

Included with ts-bus are some React hooks and helpers that provide a bus context as well as facilitate state management within React.

BusProvider

Wrap your app using the BusProvider

import React from "react";
import App from "./App";

import { EventBus } from "ts-bus";
import { BusProvider } from "ts-bus/react";

// global bus
const bus = new EventBus();

// This wraps React Context and passes the bus to the `useBus` hook.
export default () => (
  <BusProvider value={bus}>
    <App />
  </BusProvider>
);

useBus

Access the bus instance with useBus

// Dispatch from deep in your application somewhere...
import { useBus } from "ts-bus/react";
import { kickoffSomeProcess } from "./my-events";

function ProcessButton(props) {
  // Get the bus passed in from the top of the tree
  const bus = useBus();

  const handleClick = React.useCallback(() => {
    // Fire the event
    bus.publish(kickoffSomeProcess(props.data));
  }, [bus]);

  return <Button onClick={handleClick}>Go</Button>;
}

useBusReducer

This connects state changes to bus events via a state reducer function.

Its signature is similar to useReducer except that it returns the state object instead of an array:

Example:

function init(initCount: number) {
  return { count: initCount };
}

// dispatch is an alias to bus.publish() you can use either
const [state, dispatch] = useBusReducer(reducer, initCount, init);
import { useBus, useBusReducer } from "ts-bus/react";

const initialState = { count: 0 };

function reducer(state, event) {
  switch (event.type) {
    case "counter.increment":
      return { count: state.count + 1 };
    case "counter.decrement":
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}

function Counter() {
  const bus = useBus();
  const [state, dispatch] = useBusReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => bus.publish({ type: "counter.increment" })}>
        +
      </button>
      <button onClick={() => dispatch({ type: "counter.decrement" })}>-</button>
    </>
  );
}

Custom subscriber function

You can configure useBusReducer with a custom subscriber passing in an options object.

// get a new useReducer function
const useReducer = useBusReducer.configure({
  subscriber: (dispatch, bus) => {
    bus.subscribe("count.**", dispatch);
  }
});

const [state, dispatch] = useReducer(/*...*/);

NOTE: Boilerplate can be reduced by using the reducerSubscriber function.

useBusReducer.configure({
  subscriber: reducerSubscriber("count.**")
});

Usage with Redux dev tools

You can use ts-bus with Redux Devtools by using Reinspect.

Here is an example:

import React from "react";
import { StateInspector, useReducer as useReinspectReducer } from "reinspect";
import { EventBus, createEventDefinition } from "ts-bus";
import { BusProvider, useBus, useBusReducer } from "ts-bus/react";

const bus = new EventBus();

export default function AppWrapper() {
  return (
    <BusProvider value={bus}>
      <StateInspector name="App">
        <App />
      </StateInspector>
    </BusProvider>
  );
}

const useReducer =
  process.env.NODE_ENV === "development" && window.__REDUX_DEVTOOLS_EXTENSION__
    ? useBusReducer.configure({
        useReducer: (reducer, initState, initializer) =>
          useReinspectReducer(reducer, initState, initializer, "MyApp") // passing in the reinspect id
      })
    : useBusReducer;

const increment = createEventDefinition()("increment");
const decrement = createEventDefinition()("decrement");

function App() {
  const b = useBus();
  const [state] = useReducer(
    (state, action) => {
      switch (action.type) {
        case `${increment}`: {
          return {
            ...state,
            count: state.count + 1
          };
        }
        case `${decrement}`: {
          return {
            ...state,
            count: state.count - 1
          };
        }
      }
      return state;
    },
    { count: 0 }
  );

  return (
    <div>
      <button onClick={() => b.publish(decrement())}>-</button>
      {state.count} <button onClick={() => b.publish(increment())}>+</button>
    </div>
  );
}

useBusReducer configuration

Available options:

Option Description
subscriber Reducer subscriber definition
useReducer Alternate React.useReducer implementation

useBusState

This connects state changes to bus events via a useState equivalent function.

import { useBus, useBusState } from "ts-bus/react";

const setCountEvent = createEventDefinition<number>()("SET_COUNT");

function Counter() {
  const bus = useBus();
  const [count] = useBusState(0, setCountEvent);

  return (
    <>
      Count: {count}
      <button onClick={() => bus.publish(setCountEvent(count + 1))}>+</button>
      <button onClick={() => bus.publish(setCountEvent(count - 1))}>-</button>
    </>
  );
}

Preconfigured useBusState

You can preconfigure useState to use a specific eventCreator and you get a drop in replacement for setState that is hooked up to the event bus.

Here is a more complete example:

// events.ts
export const bus = new EventBus();

export const setCountEvent = createEventDefinition<number>()("SET_COUNT");

bus.subscribe(setCountEvent, event => {
  console.log(`Setting count to ${event.payload}`);
});
// App.ts
import { bus } from "./events";

export default function App() {
  return (
    <BusProvider value={bus}>
      <Counter />
    </BusProvider>
  );
}
// ...
// Counter.ts
import { useBus, useBusState } from "ts-bus/react";
import { setCountEvent } from "./events";

const useState = useBusState(setCountEvent);

function Counter() {
  const bus = useBus();
  const [count, setCount] = useState(0);

  return (
    <>
      Count: {count}
      <button onClick={() => bus.publish(setCount(count + 1))}>+</button>
      <button onClick={() => bus.publish(setCount(count - 1))}>-</button>
    </>
  );
}

useBusState configuration

You can configure useBusState with a subscriber passing in an options object.

// get a new useState function
const useState = useBusState.configure(someEvent, {
  subscriber: (dispatch, bus) => bus.subscribe("**", ev => dispatch(ev.payload))
});

const state = useState(/*...*/);

NOTE: The boilerplate code can be reduced by using the stateSubscriber function.

const useState = useBusState.configure(someEvent, {
  subscriber: stateSubscriber("**")
});

Available options:

Option Description
subscriber State subscriber definition