BLOG

Decentralized Event System

12/27/2021Time to read: 6 min

Events are used a lot in web development, especially in making user interfaces. An event could be a user click, a keystroke, a network response, a timer, etc. To implement complex interaction and state management, we often want our custom events besides the browser events. So we have libraries like Redux that helps with creating custom events.

Using events is a way to break dependencies between two pieces of code, subscriber (the code that performs some action based on the event) and dispatcher (the code that decides to send out an event). It's simple to implement a bare-bone event system in JavaScript.

const eventSystem = {
    _subscribers: new Map(),
    subscribe: (event, callback) => {
        const callbacks = eventSystem._subscribers.get(event) || new Set();
        callbacks.add(callback);
        eventSystem._subscribers.set(event, callbacks);
        const unsubscribe = () => {
            callbacks.delete(callback);
        }
    },
    dispatch: (event, data) => {
        const callbacks = eventSystem._subscribers.get(event);
        if (callbacks) {
            for (const callback of callbacks) {
                callback(event, data);
            }
        }
    }
};

// usage

// subscriber
eventSystem.subscribe('echo', (event, data)=> {console.log(`echo ${data}`)};

// dispatcher
eventSystem.dispach('echo', 'hello world');

Centralized and Decentralized Event Handling

Imagine a very generic event type, say, a network request. If we want to respond to a subset of events, for example, only when the request is from a particular IP address, we have generally two approaches:

  1. We can do the filtering in subscribers. Simply subscribe to the generic event, but check if the received event has the wanted IP address. If so, handle it, otherwise, do nothing.
  2. We can change the dispatcher and/or event system to make it understand an additional attribute of an event, IP address. Subscribers can then subscribe using the event type and IP address. The event system should make sure that only a network request from the specific IP address should trigger the subscriber.

The first approach requires only a change in the subscriber. This is sometimes the only solution when the event is dispatched by the runtime or framework. When an event is dispatched, the processing of this event will be an O(n) operation, where n is the number of subscribers subscribing to this event, whether or not they decide to handle it.

The second approach requires changes on dispatchers, the event system, and subscribers. The dispatcher needs to include the additional attribute of an event, so the event system can use it to select the right subscriber. The event system needs to read the attribute and filter subscribers based on it. The subscriber needs to denote that it is listening to events with a particular attribute value. If most attribute values have only one subscriber, this event will be an O(1) operation. The event system will be able to figure out the right subscriber. However, this approach requires changes in multiple places and introduces business logic in the event system.

Many specialized event systems adopt the second approach with a well-designed attribute format. For example, a routing framework might have special syntax for routes. For example, Express.js supports special syntax for route parameters:

app.get("/users/:userId/books/:bookId", function (req, res) {
  res.send(req.params);
});

A Decentralized Event System

We can have the best of both worlds. We can have a generic event system that doesn't know the business logic events being dispatched but is still performant enough to find the subscribers.

Let's try implementing a routing system similar to Express.

When a request comes, the dispatcher will send out an event with data similar to

{
    url: 'http://mywebsite.com/path1/path2?query=param',
    type: 'GET'
}

Instead of subscribing using a string, we can subscribe using a filter function.

// a function that selects GET request.
function isGet(req) {
  return req.type === "GET";
}

eventSystem.subscribe([isGet], function handleGetRequest(req) {
  console.log("received a GET request");
});

// a function that selects POST request
function isPost(req) {
  return req.type === "POST";
}

eventSystem.subscribe([isPost], function handlePostRequest(req) {
  console.log("received a POST request");
});

We can further constrain the subscriber to subscribe to request to mywebsite.com.

function isMyWebsiteDotCom(req) {
  return new URL(req).hostname === "mywebsite.com";
}

eventSystem.subscribe(
  [isGet, isMyWebsiteDotCom],
  function handleGetMyWebsiteDotCom(req) {
    console.log("received GET request to mywebsite.com");
  }
);

The expected behavior is that:

  1. Request {url: 'http://mywebsite.com', type: 'POST'} should trigger no subscribers because both subscribers require GET request.
  2. Request {url: 'http://otherwebsite.com/', type: 'GET'} should trigger handleGetRequest but not handleGetMyWebsiteDotCom because the hostname is not mywebsite.com.
  3. Request {url: 'http://mywebsite.com/', type: 'GET'} should trigger both subscribers.

eventSystem internally should maintain a tree of filter functions.

  () => true // Root is a filter function that always returns true
    /  \
isPost isGet
          \
         isMyWebsiteDotCom

We set isMyWebsiteDotCom as a child of isGet because for handleGetMyWebsiteDotCom isGet is a precondition for isMyWebsiteDotCom.

Given a list of filter functions in subscribe, the event system can treat it as a path from the root and add all functions to the tree, creating new nodes if needed. A function might be contained in multiple nodes of the tree if they exist in different paths. eventSystem.subscribe([isPost, isMyWebsiteDotCom], (req) => {}) will build on top of the above tree and result in

                () => true
                  /  \
              isPost isGet
                /       \
isMyWebsiteDotCom       isMyWebsiteDotCom

If we associate the subscribers with the node in the tree, the dispatching logic can be implemented like follow:

  1. Start from root, run the filter function at the current node
  2. If the filter function returns true a. Dispatch the event to subscribers associate with the current node. b. Recurse into children of the current node and do steps 1,2 for the child nodes.

Our current approach of using an array of filters can be improved to emphasize the parent-child relationship of filters.

class Event {
  _filter;
  _parent;
  static BaseEvent = new Event(() => true);
  constructor(filter, parent = Event.BaseEvent) {
    this._filter = filter;
    this._parent = parent;
  }

  filter(childFilter) {
    return new Event(childFilter, this);
  }
}

const GetEvent = new Event(isGet);
eventSystem.subscribe(GetEvent, handleGetRequest);

const GetMyWebsiteEvent = GetEvent.filter(isMyWebsiteDotCom);
eventSystem.subscribe(GetMyWebsiteEvent, handleGetMyWebsiteDotCom);

This system is very extensible, frameworks like Express can build up their routing syntax using a few composable filters. Users of the frameworks can also create their subsystem to facilitate niche requirements, such as "subscribing to requests that have query param userId that is greater than 100".

This system is not perfect either. Subscriptions to new Event(isGet).filter(isMyWebsiteDotCom) and new Event(isMyWebsiteDotCom).filter(isGet) are treated as two different path in the internal filter tree. Ideally, users of this system should follow some logical ordering of the filters. If this system is used to create a framework, the framework can also provide some builders on top of the Event API.

class RouteBuilder {
  setType(type) {
    this.type = type;
  }
  setHostname(hostname) {
    this.hostname = hostname;
  }
  build() {
    let event = Event.BaseEvent;
    switch (this.type) {
      case "GET":
        event = filter(isGet);
        break;
      case "POST":
        event = filter(isPost);
        break;
    }
    event.filter((req) => new URL(req).hostname === "mywebsite.com");
    return event;
  }
}

Another issue is that currently, we rely on the identity of the function to decide if we want to create a new node in the tree or reuse the same node. For dynamically created functions, the function identity will be different each time. For those, we can add an optional key to help the system decide if two functions can be considered the same.

class Event {
    _key = null

    constructor(filter, key = null, parent = Event.BaseEvent) {
        // ...
    }

    equals(otherEvent) {
        if (this._key != null || otherEvent._key != null) {
            return this._key === otherEvent._key;
        }
        // both Event has no key, compare filter function identity
        return this._filter === otherEvent._filter;
    }
Google's go link culture