Home Blog CV Projects Patterns Notes Book Colophon Search

Server-Side-Events with EventSource

4 Feb, 2022

Server side events are a technology for pushing messages to a browser client. They are more modern than the 'long polling' or 'forever iframe' approaches but older than WebSockets.

There are polyfills that make the approach compatible with servers back to IE8 e.g. https://www.npmjs.com/package/event-source-polyfill

They suffer from two severe limitations though:

  1. An intermediate proxy server that doesn't understand SSE might just buffer the events for a few minutes waiting for the connection to be closed before sending the response on to the client.

  2. When not used over HTTP/2:

    https://developer.mozilla.org/en-US/docs/Web/API/EventSource

    SSE suffers from a limitation to the maximum number of open connections, which can be specially painful when opening various tabs as the limit is per browser and set to a very low number (6).

    See:

A workaround for this second problem is described in https://fastmail.blog/historical/inter-tab-communication-using-local-storage/ that uses localstorage like this:

function WindowController () {
    var now = Date.now(),
        ping = 0;
    try {
        ping = +localStorage.getItem( 'ping' ) || 0;
    } catch ( error ) {}
    if ( now - ping > 45000 ) {
        this.becomeMaster();
    } else {
        this.loseMaster();
    }
    window.addEventListener( 'storage', this, false );
    window.addEventListener( 'unload', this, false );
}

WindowController.prototype.isMaster = false;
WindowController.prototype.destroy = function () {
    if ( this.isMaster ) {
        try {
            localStorage.setItem( 'ping', 0 );
        } catch ( error ) {}
    }
    window.removeEventListener( 'storage', this, false );
    window.removeEventListener( 'unload', this, false );
};

WindowController.prototype.handleEvent = function ( event ) {
    if ( event.type === 'unload' ) {
        this.destroy();
    } else {
        var type = event.key,
            ping = 0,
            data;
        if ( type === 'ping' ) {
            try {
                ping = +localStorage.getItem( 'ping' ) || 0;
            } catch ( error ) {}
            if ( ping ) {
                this.loseMaster();
            } else {
                // We add a random delay to try avoid the race condition in 
                // Chrome, which doesn't take out a mutex on local storage. It's
                // imperfect, but will eventually work out.
                clearTimeout( this._ping );
                this._ping = setTimeout(
                    this.becomeMaster.bind( this ),
                    ~~( Math.random() * 1000 )
                );
            }
        } else if ( type === 'broadcast' ) {
            try {
                data = JSON.parse(
                    localStorage.getItem( 'broadcast' )
                );
                this[ data.type ]( data.event );
            } catch ( error ) {}
        }
    }
};

WindowController.prototype.becomeMaster = function () {
    try {
        localStorage.setItem( 'ping', Date.now() );
    } catch ( error ) {}

    clearTimeout( this._ping );
    this._ping = setTimeout( this.becomeMaster.bind( this ),
        20000 + ~~( Math.random() * 10000 ) );

    var wasMaster = this.isMaster;
    this.isMaster = true;
    if ( !wasMaster ) {
        this.masterDidChange();
    }
};

WindowController.prototype.loseMaster = function () {
    clearTimeout( this._ping );
    this._ping = setTimeout( this.becomeMaster.bind( this ),
        35000 + ~~( Math.random() * 20000 ) );

    var wasMaster = this.isMaster;
    this.isMaster = false;
    if ( wasMaster ) {
        this.masterDidChange();
    }
};

WindowController.prototype.masterDidChange = function () {};

WindowController.prototype.broadcast = function ( type, event ) {
    try {
        localStorage.setItem( 'broadcast',
            JSON.stringify({
                type: type,
                event: event
            })
        );
    } catch ( error ) {}
};

According to this Gist, the above is MIT licensed. It looks like a later version of this made into the OvertureJS library too.

The post also links to an example of how to use this at https://nmjenkins.com/intertab.html. That implementation assumes you've patched the constructor like this:

function WindowController () {
    this.id = Math.random();
    ...
}

Here's Neil Jenkins' example:

<h1>I am not yet initialised</h1>
<h2>Data received from other tabs:</h2>
<ul id="eventLog"></ul>
<h2>Send data</h2>
<p><input type="text"><button>Send</button></p>

<script>
  var $ = function ( selector ) {
      return document.querySelector( selector );
  };
  var wc = new WindowController();
  wc.logMessage = function ( data ) {
      var li = document.createElement( 'li' );
      li.textContent = data.text;
      $( '#eventLog' ).appendChild( li );
  };
  wc.masterDidChange = function () {
      $( 'h1' ).textContent = wc.id + ' ' + ( this.isMaster ?
          'I am master' : 'I am a slave' );
  };
  wc.masterDidChange();
  var input = $( 'input' );
  function broadcast () {
      var value = input.value;
      input.value = '';
      if ( value ) {
        wc.broadcast( 'logMessage', { text: value });
      }
  };
  input.addEventListener( 'keypress', function ( event ) {
      if ( ( event.keyCode || event.which ) === 13 ) {
          broadcast();
      }
  }, false );
  $( 'button' ).addEventListener( 'click', broadcast, false );
</script>

Here is a Python server that puts these pieces together, but with a text box to send messages to different tabs, not a SSE implementation.

I'd like a solution that doesn't have quite such a long delay so I'm thinking about a similar algorithm, but one in which each tab sets the time it was opened, and all of them expect the earliest tab to automatically become the master next. If that hasn't happened after two seconds, then they each use the random algorithm above, but setting the max time to 1 second for each tab opened. If lots of tabs are opened this makes a race condition less likely.

Because of these two problems, I think long polling could probably be designed to be the most reliable solution.

I'm working on a new version based on these ideas and will post it here.

Update 7th Feb 2022: This is actually really complex because IE8 fires the storage event before the key that changed has actually changed, and it doesn't tell you which key has changed anyway, so you need to loop through all of them, but their order is not guaranteed. I'll continually update this link to my evolving versions.

Comments

Be the first to comment.

Add Comment





Copyright James Gardner 1996-2020 All Rights Reserved. Admin.