bidi-sse

Bidirectional Server-sent Events

Usage no npm install needed!

<script type="module">
  import bidiSse from 'https://cdn.skypack.dev/bidi-sse';
</script>

README

bidi-sse

two ways

Social Media Photo by Ian Taylor on Unsplash

Bidirectional Server-sent Events.

About

Heavily inspired by the awesome ws module, bidi-sse provides a Web Sockets friendly API, although based on both fetch and EventSource primitives.

Thanks to its different approach, both client side and server side code are minimal (~0.7K on the client, few lines on the server), and there is no need to roundtrip ping / pong to know whenever a client is gone, as that happens pretty much instantly, or better, as soon as the alive connection gets closed.

Example

Following a very simple setup to explain the basics behind this module, one for the client side, and one for the server side.

Client
import BidiSSE from 'bidi-sse/client';
// or import BidiSSE from 'https://unpkg.com/bidi-sse';

const bidi = new BidiSSE('/bidi-path');

// use it like a socket
bidi.on('open', () => {
  console.log('open');
  // it can send data once connected
  bidi.send({some: 'data'});
});

bidi.on('message', console.log);
bidi.on('error', console.error);
bidi.on('close', () => console.log('closed'));
Server
const express = require('express');
const BidiSSE = require('bidi-sse/server');
// or import BidiSSE from 'https://unpkg.com/bidi-sse/esm/server.js';

const bidi = new BidiSSE('/bidi-path');

// use it as handler or check bidi.handler(req, res) directly
const app = express();
app.use(bidi.handler);
app.use(express.static(__dirname));
app.listen(8080);

// and set it up like a socket
bidi.on('connection', client => {

  // all clients via .clients for broadcast
  console.log('clients', bidi.clients.size);

  // setup clients also like sockets
  client.on('message', data => {
    console.log('client', data);
    client.send(data);
  });

  client.on('close', () => {
    console.log('client is gone');
  });
});

API

Both client and server constructors accept a path to enable as bidi-sse, and an optional options object.

Client - a simplified emitter
const bidi = new BidiSSE('/some-path', {
  // optional fetch options to merge per each send
  // using credentials 'omit' set withCredentials
  // for EventSource as `false`: it's `true` by default.
  fetch: {credentials: 'omit'},

  // default JSON serializer to send/receive data
  JSON
});

// readyState is one of the static BidiSSE values:
bidi.readyState;
// BidiSSE.CONNECTING ➡ open event not fired yet: cannot send
// BidiSSE.OPEN       ➡ open fired: can now send
// BidiSSE.CLOSING    ➡ connection error occurred
// BidiSSE.CLOSED     ➡ bidi.close(); or after connection error

// events + chainable .on(type, fn) method
bidi.once('open', () => console.info('open'));
bidi.on('message', console.log);
bidi.on('error', console.error);
bidi.once('close', () => console.info('close'));

// methods: send throws if readyState is not OPEN
bidi.send({any: 'data'});
bidi.close();

// extra
bidi.emit('type', ...[{any: 'data'}]);
Server - an event emitter with clients notified via connection
const bidi = new BidiSSE('/some-path', {
  // if its value is `"cors"` it enables CORS via headers
  mode: '',

  // optional headers to include per each SSE initialization
  // or further posted data via send(...)
  headers: {},

  // default JSON serializer to send/receive data
  JSON
});

// a read only *Set* of clients, where each client has
// the same properties and methods of the client side one
bidi.clients;

// an auto-bound method usable as express handler or within
// basic nodejs createServer logic. Returns true if the request
// was handled as Server-sent Event
bidi.handler;

// events + chainable .on(type, fn) method
bidi.on('connection', client => {
  // client is unique per visitor and it has all features
  // a client-side bidi instance has
});
bidi.on('close', () => { console.log('all gone'); });

// methods: close throw away all connected clients, then resolves
bidi.close();

Use cases

It is very important to understand where this module can easily fail, as opposite of being a solution ...

  • this module assumes every request passes through the same stack, meaning that cluster, serverless, load balance, or any stack that might diverge the request somewhere else, will easily fail if the browser client EventSource points at a different end of the spectrum, and further UUIDs related request are sent elsewhere

  • this module was mostly born to satisfy proxied-node constrains and architecrture, among IoT caveats, so don't use this in production unless you really understand how this module works 👍

How it works

  • an EventSource client request is intercepted and handled on the server:
    • the response object is trapped until the client disconnects
    • a server side client is created and the long living response object is associated with it
    • the very first server-sent event is a unique identifier
    • the server side client is associated to this unique identifier and a connection event emitted, passing such client as ready to communicate
  • the client stores internally such unique identifier and emit it's open listener, enabling its communication ability
  • each time the client instance .send(data) is invoked, the same EventSource's href plus the unique identifier is used to POST the data as serialized format
  • the server intercepts POST requests and handle these internally if:
    • the url is the same as the initial one defined to trap responses
    • there is a known UUID associated with the url as bidi-sse query string
  • the sent data is built as string via all its chunks, and then deserialized through the same stringify and parse mechanism used on the client. This is JSON by default, but it could be any different library
    • if the parse(postedData) operation fails, an error is triggered on the client side, but only if it's still connected
    • if the operation is successful, a message event with the parsed data is invoked on the server "client's counterpart"
  • when the server side client .send(data) is invoked, a message event is emitted in the browser's client side, and through the same stringify and parse procedure

Using different serialization

Please note that the JSON reference library to stringify and parse must be the same for both client and server.

flatted and @ungap/structured-clone/json are just two of the many possible parser alternatives, able to deal with recursion and, in the structured clone case, with also more data types and primitives.