Skip to content

Commit

Permalink
Merge pull request #24 from MatthewWid/channels
Browse files Browse the repository at this point in the history
Broadcast Channels
  • Loading branch information
MatthewWid authored Jul 17, 2021
2 parents 321fbc9 + 0043c02 commit 223ead7
Show file tree
Hide file tree
Showing 15 changed files with 807 additions and 10 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Please consider starring the project [on GitHub ⭐](https://github.com/MatthewW
* Fully written in TypeScript (+ ships with types directly).
* [Thoroughly tested](./src/Session.test.ts) (+ 100% code coverage!).
* [Comprehensively documented](./docs) with guides and API documentation.
* [Channels](./docs/channels.md) allow you to broadcast events to many clients at once.
* Configurable reconnection time.
* Configurable message serialization and data sanitization (but with good defaults).
* Trust or ignore the client-given last event ID.
Expand Down
75 changes: 69 additions & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,21 @@
### Exports

* [Session](#session)
* [createSession]()
* [createSession](#createsession%3A-(constructorparameters<typeof-session>)-%3D>-promise<session>)
* [Channel](#channel)
* [createChannel](#createchannel%3A-(...args%3A-constructorparameters<typeof-channel>)-%3D>-channel)

## Documentation

### `Session`

*Extends from [EventEmitter](https://nodejs.org/api/events.html#events_class_eventemitter)*
*Extends from [EventEmitter](https://nodejs.org/api/events.html#events_class_eventemitter)*.

A Session represents an open connection between the server and the client.

It emits the `connected` event after it has connected and flushed all headers to the client, and the `disconnected` event after client connection has been closed.

#### `new Session(req: IncomingMessage, res: ServerResponse, [options] = {})`
#### `new Session(req: IncomingMessage, res: ServerResponse[, options = {}])`

`req` is an instance of [IncomingMessage](https://nodejs.org/api/http.html#http_class_http_incomingmessage).

Expand Down Expand Up @@ -45,6 +47,12 @@ This is initialized to the last event ID given by the user (in the `Last-Event-I

Indicates whether the session and connection is open or not.

#### `Session#state`: `{}`

Custom state for this session.

Use this object to safely store information related to the session and user.

#### `Session#dispatch`: `() => this`

Flush the buffered data to the client by writing an additional newline.
Expand Down Expand Up @@ -82,7 +90,6 @@ Create and dispatch an event with the given data all at once.
This is equivalent to calling `.event()`, `.id()`, `.data()` and `.dispatch()` in that order.

If no event name is given, the event name (type) is set to `"message"`.

Note that this sets the event ID (and thus the [`lastId` property](#session%23lastid%3A-string)) to a string of eight random characters (`a-z0-9`).

#### `Session#stream`: `(stream: Readable[, options]) => Promise<boolean>`
Expand All @@ -95,8 +102,64 @@ Each data emission by the stream emits a new event that is dispatched to the cli
|-|-|-|-|
|`event`|`string`|`"stream"`|Event name to use when dispatching a data event from the stream to the client.|

### `createSession: (ConstructorParameters<typeof Session>) => Promise<Session>`
### `createSession`: `(ConstructorParameters<typeof Session>) => Promise<Session>`

`createSession` creates and returns a promise that resolves to an instance of the [Session class](#session) once it has connected.
Creates and returns a promise that resolves to an instance of a [Session](#session) once it has connected.

It takes the [same arguments as the Session class constructor](#new-session(req%3A-incomingmessage%2C-res%3A-serverresponse%2C-%5Boptions%5D-%3D-%7B%7D)).

### `Channel`

*Extends from [EventEmitter](https://nodejs.org/api/events.html#events_class_eventemitter)*.

A Channel is used to broadcast events to many sessions at once.

#### `new Channel()`

#### `Channel#activeSessions`: `ReadonlyArray<Session>`

List of the currently active sessions subscribed to this channel.

You should not mutate the contents of this array.

#### `Channel#sessionCount`: `number`

Number of sessions subscribed to this channel.

Equivalent to `channel.activeSessions.length`.

#### `Channel#register`: `(session: Session) => this`

Register a session so that it will start receiving events from this channel.

Note that a session must be [connected](#session%23isconnected%3A-boolean) before it can be registered to a channel.

Fires the `session-registered` event with the registered session as its first argument.

#### `Channel#deregister`: `(session: Session) => this`

Deregister a session so that it no longer receives events from this channel.

Note that sessions are automatically deregistered when they are disconnected.

Fires the `session-deregistered` event with the session as its first argument.

If the session was disconnected the channel will also fire the `session-disconnected` event with the disconnected session as its first argument beforehand.

#### `Channel#broadcast`: `(eventName: string, data: any[, options = {}]) => this`

Broadcasts an event with the given name and data to every active session subscribed to the channel.

Under the hood this calls the [`push`](#session%23push%3A-(event%3A-string%2C-data%3A-any)-%3D>-this-%7C-(data%3A-any)-%3D>-this) method on every active session.

Fires the `broadcast` event with the given event name and data in their respective order.

|`options.`|Type|Default|Description|
|-|-|-|-|
|`filter`|`(session: Session) => unknown`||Filter sessions that should receive the event.<br><br>Called with each session and should return a truthy value to allow the event to be sent, otherwise return a falsy value to prevent the session from receiving the event.|

### `createChannel`: `(...args: ConstructorParameters<typeof Channel>) => Channel`

Creates and returns an instance of a [Channel](#channel).

It takes the [same arguments as the Channel class](#new-channel()).
232 changes: 232 additions & 0 deletions docs/channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# Channels

This section covers the usage of [channels](./api.md#channels) with Better SSE. You should read the [Getting Started](./getting-started.md) guide first if you haven't already.

## Guide

### Introduction

Channels are an abstraction that make it easy to broadcast events to many clients at once.

When a client connects, you first register (subscribe) the session to one or many channels, and from then-on any events broadcast on those channels will also be sent to that client.

Don't worry about deregistering the session from the channel; that is automatically handled for you when the session disconnects, [but you can also do it manually](./api.md#createchannel%3A-(...args%3A-constructorparameters<typeof-channel>)-%3D>-channel) if you no longer wish to listen for events on a channel at any time.

Channels can be used for things such as notification systems, chat rooms and synchronizing data between multiple clients.

### Create a channel

We are going to be making a channel that does two things:

1. Synchronizes a number that counts up in set intervals with all clients, and,
2. Sends a live updating count of how many users are currently online in real time.

Let's start off from where the Getting Started guide finished as a base:

```javascript
// server.ts
import express from "express";
import {createSession} from "better-sse";

const app = express();

app.use(express.static("./public"));

app.get("/sse", async (req, res) => {
const session = await createSession(req, res);

session.push("Hello world!");
);

app.listen(8080);
```
Right now we have a simple mechanism where a client connects and receives an event with the data `"Hello world!"`.
This is nice, but what if we want to say hi to everyone at once? Or in a more real-world example, send live updates about real-time events in our system to groups of our users? This is where we can use channels.
To make a channel, you simply need to call the exported `createChannel()` factory function.
Let's create a channel called *ticker* in a new file and export it:
```javascript
// channels/ticker.ts
import {createChannel} from "better-sse";

const tickerChannel = createChannel();

export default tickerChannel;
```
Then import the channel to where your route handler is located:
```javascript
// server.ts
import tickerChannel from "./channels/ticker";
```
You then need to *register* the session with your new channel so that it can start receiving events. Inside your route handler add the following just after you create your session:
```javascript
tickerChannel.register(session);
```
New sessions will now be subscribed to your channel and will start receiving its events!
Channels are powerful in that you can register your session to listen on many channels at once or none at all. This makes it dynamically configurable based on what channels your client may or may not be authorized to receive events from, and allows you to have a lot of flexibility in your implementation.
### Create a counter
Now that you have a channel and your sessions are registered with it, lets actually make it do something.
We are going to have a number that increments by one (1) every second and synchronize it across all of our connected clients in real time.
Back in your `ticker.ts` file, right after your create your channel, add the following:
```javascript
let count = 0;

setInterval(() => {
count = count + 1;

tickerChannel.broadcast("tick", count);
}, 1000);
```
Here we have a variable `count` that gets incremented by `1` every 1000ms (one second).
We then broadcast the value of `count` on the `ticker` channel every interval under the event name `tick` which will be received by all of our registered sessions.
On our client-side let's write a handler that updates some text with the received value:
```javascript
// public/client.js
const countElement = document.createElement("pre");
document.body.appendChild(countElement);

const eventSource = new EventSource("/sse");

eventSource.addEventListener("tick", ({data}) => {
countElement.innerText = `The clock has ticked! The count is now ${data}.`;
});
```
In this snippet the following is happening:
1. We create a `pre` element stored in a variable `countElement` and add it to our document body.
2. We create an [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) to listen to events from our server.
3. We listen for the `tick` event and, when received, set the text in `countElement` to display the received value, corresponding to the `count` variable on the server.
Open it up in your browser (you can [run the pre-made example project](../examples) if you haven't been following along) and you will now see your ticking counter being updated in real time!
You can open the same page in multiple tabs at once and notice that the value is kept in sync across them all. Easy!
### Track online users
Let's add some more functionality to our *ticker* channel. This time we want to keep our users in the know about how many other users are on the site at the same time as them. No one wants to feel lonely!
Channels [emit events](https://nodejs.org/api/events.html#events_class_eventemitter) when certain things happen such as sessions being registered, deregistered and being disconnected. Let's listen on these events to broadcast the total number of connected sessions at any given time.
In our `ticker.ts` file, after you create your channel, add the following:
```javascript
const broadcastSessionCount = () => {
ticker.broadcast("session-count", ticker.sessionCount);
};

ticker
.on("session-registered", broadcastSessionCount)
.on("session-deregistered", broadcastSessionCount);
```
Here we create a function `broadcastSessionCount` that broadcasts an event with the name `session-count` and a value with the current total session count exposed to us under the [Channel `sessionCount` property](./api.md#channelsessioncount-number).
We then listen on both the events `session-registered` and `session-deregistered` and set the `broadcastSessionCount` function as a callback for each. This way, every time a session joins or leaves the channel the count is re-broadcasted and updated for all of the existing sessions on the channel.
Back on our client lets add another listener that displays the session count:
```javascript
// public/client.js
const sessionsElement = document.createElement("pre");
document.body.appendChild(sessionsElement);

eventSource.addEventListener("session-count", ({data}) => {
sessionsElement.innerText = `There are ${data} person(s) here right now!`;
});
```
We do a similar thing to our previous example:
1. Create a `pre` element stored in a variable `sessionsElement` and add it to our document body.
2. Listen for the `session-count` event and, when received, set the text in `sessionsElement` to display the received value, corresponding to the number of active sessions connected.
Once again open the page in your browser ([or run the example project](../examples)) and you will now see a new text element with a real-time updating display of the active sessions. Open and close more tabs on the same page and observe how the count changes to stay in sync. Amazing!
Your finished code should look like the following:
```javascript
// server.ts
import express from "express";
import {createSession} from "better-sse";
import tickerChannel from "./channels/ticker";

const app = express();

app.use(express.static("./public"));

app.get("/sse", async (req, res) => {
const session = await createSession(req, res);

tickerChannel.register(session);
});

app.listen(8080);
```
```javascript
// channels/ticker.ts
import {createChannel} from "better-sse";

const ticker = createChannel();

let count = 0;

setInterval(() => {
ticker.broadcast("tick", count++);
}, 1000);

const broadcastSessionCount = () => {
ticker.broadcast("session-count", ticker.sessionCount);
};

ticker
.on("session-registered", broadcastSessionCount)
.on("session-deregistered", broadcastSessionCount);

export default ticker;
```
```javascript
// public/client.js
const eventSource = new EventSource("/sse");

const sessionsElement = document.createElement("pre");
document.body.appendChild(sessionsElement);

eventSource.addEventListener("tick", ({data}) => {
countElement.innerText = `The clock has ticked! The count is now ${data}.`;
});

const countElement = document.createElement("pre");
document.body.appendChild(countElement);

eventSource.addEventListener("session-count", ({data}) => {
sessionsElement.innerText = `There are ${data} person(s) here right now!`;
});
```
## Keep going...
Check the [API documentation](./api.md) for information on getting fine-tuned control over your data such as managing event IDs, data serialization, streams, dispatch controls and more.
You can also see the full example from this guide [in the examples directory](../examples).
6 changes: 4 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ You can also find a reference to the received event object interface under the [

## Keep going...

Check the [API documentation](./api.md) for information on getting more fine-tuned control over your data such as managing event IDs, data serialization, streams, dispatch controls and more.
Move on to [learning about channels](./channels.md) which allow you to broadcast events to multiple sessions at once.

You can also see the full example from this guide in [the examples directory](../examples).
Check the [API documentation](./api.md) for information on getting fine-tuned control over your data such as managing event IDs, data serialization, streams, dispatch controls, channels and more.

You can also see the full example from this guide [in the examples directory](../examples).
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ This directory showcases some simple examples of using Better SSE.
## Table of Contents

* [Getting Started](./getting-started)
* [Channels](./channels)
* [Streams](./streams)
Loading

0 comments on commit 223ead7

Please sign in to comment.