Getting Started

Overview

All communication between a browser and a server with BoardStreams happens through channels, also known as streams. A browser can join multiple channels through a single WebSocket connection, and the same channel on the server can be joined by multiple browsers.

Browsers talk to servers by sending actions and requests to them, through channels they have joined. Actions and requests have a name and a payload. Servers are programmed to do something when they receive an action or a request with a given name. Requests are followed by a response to the browser indicating success or failure, and optionally accompanied by a payload, whereas actions are not.

If a browser succeeds in joining a channel, it will start receiving all the events related to that channel from that moment on, until it leaves that channel. Events are created and emitted only by the server (often in response to browsers' actions or requests), and are broadcast to all browsers that are members of the channel, regardless of which server worker process they have connected to.

Each channel also has a shared data object (called the state object), held by all clients in that channel, and also by the server. It only ever gets updated by the server (often in response to browsers' actions or requests), and the browsers' copies are automatically kept up to date.

Mini tutorial

Here's an example with code. It assumes you've already installed the library.

First create a websocket route in your Mojolicious app, and have the BoardStreams initialize incoming connections to it, to listen to their commands and handle various other aspects of the BoardStreams websocket connection, as such:


    $r->websocket('/ws')->to('WS#ws', 'boardstreams.endpoint' => 1);

    package MyApp::Controller::WS;
    use Mojo::Base 'Mojolicious::Controller';
    sub ws {}
  

BoardStreams-related methods (such as $self->bs>set_action) have been added to Mojolicious by the BoardStreams Mojolicious plugin, and start with the ->bs prefix.

Next, create and use a Mojolicious plugin to host your BoardStreams-related code:


    $self->plugin('MyDemo');

    package MyApp::Plugin::MyDemo;
    use Mojo::Base 'Mojolicious::Plugin', -signatures;
    sub register ($self, $app, $config) {
    }
    1;
  

Next, we need a channel/stream that browsers can join. Either write a script to execute this line, or include it in your plugin:


    $app->bs->create_stream('demo1', {counter => 0}); # channel name, initial state of state object
  

The above line does nothing if the channel already exists.

Now write some JavaScript to have the browser join the stream and listen for events and updates to the state object:


    const ch = BS.newStream('demo1');
    ch.on(event => console.log('event arrived from demo1:', event);
    ch.on(state => console.log('we have a new state:', state);
    ch.join();

    // and later, in the cleanup routine:
    ch.leave();
  

Leaving a channel unsubscribes from it and prevents resources from leaking.

Now create a script that increases the state's counter property every second, and sends an event to all browsers that have joined the channel:


  #!/usr/bin/env perl

  use Mojo::Base -strict, -signatures;

  use MyApp;

  my $app = MyApp->new;

  while (1) {
      $app->bs->lock_stream('demo1', sub ($state) {
          $state->{counter}++;
          my $event = {msg => 'there was an increase'};
          return $event, $state;
      });

      sleep 1;
  }
  

$app->bs->lock_stream will lock the channel row in the database, and allow you to modify the state in the sub. When you return the state, it will get saved as the "new state" of the channel, and its diff will be sent as state update to all the browsers, together with the event you returned.

Either of the two return values may be undef, to signify that no event happened, or that state didn't change.

Lastly, allow the users to join the channel by writing the "join" handler:


    # in Demo1 plugin's "register" function:

    $app->bs->set_join('demo1', sub ($stream_name, $c, $attrs) {
        return {
            limit => $attrs->{is_reconnect} ? 100 : 0,
        };
    });
  

This callback will get called whenever the server receives a join request from a browser. If the callback dies or returns a falsy value, the client's join request will be rejected. That way the sub also acts as an authorization handler.

A truthy return value means acceptance, and may be a hashref containing one key (limit) that defaults to 0. limit shows the maximum number of most recent events the browser should receive as soon as it joins. If the browser is reconnecting to the server after a temporary network failure, the number of events sent is further limited to how many events exist in the database that are new to the client. The browser's channel object's event event handler will fire for each of these events.

In both cases (first connect or reconnection) the browser will receive the current state object as well, which will trigger the initialState and state events of the browser's channel object.

Setting limit to "all" will send all events to the browser. (The missed ones only, in case of reconnection.)

If you run this Perl and JavaScript code, the browser should connect to the WebSocket server, join the channel, and display the event and new state in its console every second.

Actions

Browsers can send actions to the server. The following creates a form that lets the user set our counter to a value of their preference, by sending an action to the server:


    <input type="text" id="newValue" />
    <button id="setCounter" />

    <script>
    const button = document.getElementById('setCounter');
    button.addEventListener('click', function() {
      const textBox = document.getElementById('newValue');
      const newValue = textBox.value;
      ch.doAction('set_counter', newValue); // action name, payload
    });
    </script>
  

Also create an action handler on the server-side:


    # in your plugin:

    $app->bs->set_action('demo1', 'set_counter', sub ($stream_name, $c, $payload) {
        $c->bs->lock_stream('demo1', sub ($state) {
            $state->{counter} = int $payload;
            return undef, $state;
        });
    });
  

Return values of action handlers are ignored.

Requests

Browsers can also send requests to the server, and expect a success or a failure response from it. doRequest accepts a request name and an optional payload, and returns a promise that resolves to the request's return value, or rejects on error.

On the Perl side, the request handler may either be sychronous (in which case to return a failure it needs to die), or may return a promise that resolves or rejects to achieve the same.


    async function whoAmI() {
      return await ch.doRequest('whoami'); // request name, payload
    }
  

    $app->bs->set_request('demo1', 'whoami', async sub ($stream_name, $c, $payload) {
      my $result = await $c->pg->db->select_p(...);
      return $result->hash->{username};
    });
  
© 2020-2021