Bus Messaging

Theatersoft Bus is a zero friction approach to distributed JavaScript application development.

The Bus transports remote method calls and remote event publish/subscribe between nodes in a distributed application that work much like the ones that developers already use in normal applications.

Context and Connections

Context is a configuration object that describes how to create bus connection(s) to other node(s) in the network.

For example:

{parent: {url: 'ws://localhost:5453'}} create a parent connection to a server (at the specified URL)
{children: {port: 5453}} a new WebSocketServer that creates child connections (on the specified port)
{children: {server}} a new WebSocketServer that creates child connections (on the same port as the specified http/https.Server)

Use the context to start the bus node:

import {bus} from '@theatersoft/bus'

bus.start(context)

Calling start() without context will create a parent connection using the variable location.host (browser only) or the process environment variable BUS (Node.js only).

Nodes

A bus network begins with a single root node, i.e. a parentless server that accepts connections from child nodes. Child nodes connect to the parent and may also have nested children. The entire network topology is a rooted tree structure.

Node connections are indexed serially starting from 1 (0 being the parent connection), such that a full path from root is a unique node name. Node names are the basis for message routing between nodes.

Example: A four node network (a root with two children, one with its own child):

/ /1 /2/ /2/1/

In this example, messages from node /1/ to destination node /2/1/ will be routed through intermediate nodes / and /2/.

Objects

The bus implements remote method calls to bus objects, which are normal JavaScript objects that have been registered using bus.registerObject (name :string, obj :any). The name argument is the object name that becomes mapped to the registered object.

Names beginning with an uppercase letter must be globally and locally unique since they are also registered at a global scope.

Names beginning with a lowercase letter are only registered at the local node and must be locally unique within the node.

Example: Register a bus object name Ping with one method ping:

bus.registerObject('Ping', {ping: () => 'PING!'})

The Manager is used to resolve a bus object name:

bus.resolveName('Ping')

…returns the full path to the object, e.g. /2/1/Ping if it was registered on the last node in the previous example.

Node objects always requires the full path, e.g. /2/1/ping

Methods

With the full path, we can call methods on the object:

bus.request('/2/1/Ping.ping').then(value => console.log(value))

Since the primary goal is to make the bus API easy to use, the resolution and request mechanism can be simplified using a Proxy object:

const Ping = bus.proxy('Ping')

Bus proxy methods are callable and return values asynchronously (of course).

Ping.ping().then(value => console.log(value))

Signals

Bus signals are an event publish and subscribe mechanism.

The return value from bus.registerObject is a Promise<BusObject> which is used to pubish signals to other listeners on the bus.

export type BusObject = {
    signal :(member :string, args :mixed[]) => void
}

bus.registerListener (name :string, cb :Listener) is used to subscribe to signals, where the signal name combines the bus object name with the member argument from signal(). E.g. Device.state

Authorization

Authorization is required of child connections if the parent server connection was created using a Context with a check :(string) => Promise<boolean> function.

The authorization handshake is this message from the server immediately after connection open: {"auth":""}

The child must respond with: {"auth":<AUTH_STRING>}, which is performed by the bus using the auth member from the child’s Context.

If check(<AUTH_STRING>) resolves true then the server responds with {"hello":"<NODE_NAME>"} and the connection is ready for use.

Connection failure and reconnection

Since network connections are unreliable, reconnection and recovery from connection failures must occur without any user intervention.

The child bus node emits disconnect on a parent connection failure and begins the reconnection process. It attempts to reopen the parent connection from the child node, using an exponential backoff timeout between retries. On the parent side of the connection, the lost node is removed from the bus Manager, which also removes any associated bus objects.

Once reopen succeeds, the child re-adds its node and objects to the Manager. Then the bus emits a reconnect event. It is the reponsibility of the application developer to listen for this event and perform any further recovery actions, such as resyncing state.

Theatersoft services

Theatersoft services are global bus objects with additional conventions such as service registration and lifecycle control which are beyond the scope of the bus. See Server for more information.

class Bus

    start (context :Context)
    started ()
    get root () :boolean
    get name () :string
    get proxy () :any
    registerObject (name :string, obj :any, intf :string[], meta :any) :Promise<BusObject>
    unregisterObject (name :string) :Promise<void>
    request (name :string, ...args :mixed[])
    registerListener (name :string, cb :Listener)
    unregisterListener (name :string, cb :Listener)
    on (type :string, cb :Listener)
    off (type :string, cb :Listener)
    close ()
    introspectNode (path :string)
    resolveName (name :string)

class Manager

    names :Map<Name, Path>
    nodes :Map<Path, Name[]>

    init (path :Path) 
    addNode (path :Path) :Promise<void> 
    removeNode (path :Path) :Promise<void> 
    addName (name :Name, _sender :Path) :Promise<void> 
    resolveName (name :Name) :Promise<Name | void> 
    removeName (name :Name, _sender? :Path) :Promise<void> 
    check (auth :string)

Debugging

In a browser bus node, the WebSocket frames can be viewed using the browser’s built-in tools:

Example of WebSocket frames at client start up:

Data Length Time
{"auth":""} 11 14:48:02.799
{"auth":<AUTH_STRING>} 47 14:48:02.800
{"hello":"/42/"} 16 14:48:02.817
{"req":{"path":"/","intf":"Bus","member":"addNode","args":["/42/"],"sender":"/42/","id":0}} 91 14:48:02.818
{"req":{"path":"/","intf":"Bus","member":"resolveName","args":["Config"],"sender":"/42/","id":1}} 97 14:48:02.819
{"res":{"id":0,"path":"/42/"}} 30 14:48:02.820
{"res":{"id":1,"path":"/42/","res":"/"}} 40 14:48:02.820
{"req":{"path":"/","intf":"Config","member":"get","args":[],"sender":"/42/","id":2}} 84 14:48:02.821
{"res":{"id":2,"path":"/42/","res":<CONFIG_OBJECT>}} 4022 14:48:02.823

In both Node and browser nodes, you may rebuild the bus module using npm run build:debug to enable debug BUS logging.

Example of the same client start up from the server logs:

Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT BUS / adding child /42
Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT BUS   0-> /Bus.addNode( /42/ ) from /42/
Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT BUS manager.addNode /42/
Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT BUS <-0   undefined
Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT BUS   1-> /Bus.resolveName( Config ) from /42/
Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT BUS manager.resolveName Config /
Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT BUS <-1   /
Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT BUS   2-> /Config.get( ) from /42/
Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT bus Config.get
Mar 01 14:48:02 office theatersoft[10506]: THEATERSOFT BUS <-2   <CONFIG_OBJECT>