@dasnoo/arsocket-server

A dead easy websocket framework

Stats

stars 🌟issues ⚠️updated 🛠created 🐣size 🏋️‍♀️
Minified + gzip package size for @dasnoo/arsocket-server in KB

Readme

AR Socket

A dead easy websocket framework that acts as a layer on top of commonly used websocket technologies to furnish a standardized API.

Table of Contents

Prerequisites

Why use AR Socket

Which websocket library should you use ? socket.io, sockjs, uws ? What if you want to change the underlying library ? Will you have to change your codebase ? The answer to these questions is simple, AR Socket. AR Socket is a wrapper around commonly used websocket libraries. As such there is a single, simple, standardized API that controls them all.

For example if you'd like to use engine.io, you could use AR Socket instead and if in the future you'd like to change µWebSocket, your code base doesn't have to change.

Features

  • Simple reactive API
  • Receive / send messages to client / server
  • Middleware support
  • Broadcast messages
  • Room system
  • Typescript
  • Integrates well with Angular

Architecture

visual

Every message from the client to the server is called an Action. Every message from the server to the client(s) is called a Reaction. That's where the name AR Socket comes from. Action Reaction Socket. The client sends an action to the server, which reacts.

Both action and reaction contain a type and a payload. As such a typical Action or Reaction object would be this:

    const actionOrReaction = { type: 'NEW_MESSAGE', payload: 'hey' };

There is one central object, the Socket, to which you can subscribe to the different Action/Reaction.

Client example:

    const socket = new Socket();
    socket.select('NEW_MESSAGE')
                .subscribe(payload => console.log(`server says ${payload}`));

Server example:

    // only difference with client is that
    // the subscribe method on the server side receive an object { payload, connection }.

    const socket = new Socket();
    socket.select('NEW_MESSAGE')
                .subscribe( ({ payload }) => console.log(`client says ${payload}`));

That's it! If you understood the above you already understand the framework.

Learn by example:

We are gonna set up a simple project to get the hang of it quickly. For this project we will use typescript. You can install/update it by running the command npm i -g typescript@latest. We are using webpack npm i -g webpack

1 setting up example project:

Let's create a new project (we are using type script here)

Here is a command line to get started:

  mkdir arsocket-test && cd arsocket-test && mkdir public && npm init -y && tsc --init --target "ES2016" && npm i @dasnoo/arsocket-server

Or you can do it manually:

  1. Create arsocket-test directory
  2. Create public directory inside arsocket-test
  3. initialize package.json run npm init -y
  4. initialize typescript run tsc --init --target "ES2016"
  5. Install arsocket-server npm i @dasnoo/arsocket-server

2. Creating the server:

Create app.ts and put the code below in it

import { Socket } from '@dasnoo/arsocket-server';

const socket = new Socket({staticDir: './public'});

socket.addRoutes([{type: 'NEW_MESSAGE', handler: onMessage}]);

function onMessage(payload: any){
    socket.broadcast({type: 'NEW_MESSAGE', payload});
}

3. Note

The routing code is equivalent to the previously presented:

socket.select('NEW_MESSAGE').subscribe(onMessage);

However addRoute is more recommended.

4. Adding the Client

let's create index.html and put it in the public directory:

    <!DOCTYPE html>
    <html lang="en">
        <head>
            <title>ARSocket</title>
        </head>
        <body>

            <input id="inp" type="text" placeholder="Type here"> 
            <input id="send" type="submit" value="Send">
            <div id="msgs"></div>
        
            <script src="/chat.bundle.js"></script>
        </body>
    </html>

and chat.ts:

    import { Socket } from '@dasnoo/arsocket-client';

    const inp = document.getElementById('inp') as HTMLInputElement;
    const sendBtn = document.getElementById('send') as HTMLElement;
    const msgs = document.getElementById('msgs') as HTMLElement;

    // creating socket
    const socket = new Socket();
    socket.init();
    // selecting entering messages
    socket.select('NEW_MESSAGE').subscribe(onNewMessage)

    function onNewMessage(payload: any){
        msgs.innerHTML += payload + '<br/>';
    }

    // sending message on button click
    sendBtn.addEventListener('click', evt => { 
        socket.send('NEW_MESSAGE', inp.value)
    });

5. Run the app:

Let's compile and run

tsc && webpack ./public/chat.js ./public/chat.bundle.js && node app

You can now visit http://localhost:3000 to find your billion dollar app.

Documentation server:

Installation:

    npm i @dasnoo/arsocket-server

Server Api:

Routes

Most of your interaction with the socket should be through routes. As such, it makes sens to present those first.

    export interface Route{
            type: string;
            handler: (payload?: any, connection?: Connection) => any;
            middlewares?: Array<Middleware>;
    }
  • Type is the type of Action the route is interested in. Example: 'NEW_MESSAGE'
  • handler is the function that is gonna deal with the action
  • middlewares: is an array of function that are going to be executed before the handler

Usage example:

    const routes: Array<Route> = [
        { type: "NEW_MESSAGE", handler: onMessage},
        { type: "JOIN", handler: onJoin, middlewares: [authorize]}
    ];

You'd add those route like so :

    socket.addRoutes(routes)

The handler, the important bit:

What you return from your handler is going to dictate what is going to happen. There is 3 scenarios:

  • a Promise : When the promise resolves, the value it resolved to goes to the two cases below
  • undefined : Nothing happens
  • anything else : The server sends a response back to the client

Middlewares

Middlewares are executed before the handler if they return true or a Promise then the next middleware / handler is executed. If alternatively any of them returns false the flow is stopped.

The socket:

The interface of Socket should be self explanatory.

export interface ISocket {
    readonly socket: any;
    readonly userContainer: UserContainer;

  // addd route handlers  
    addRoutes: (routerConfig: Array<Route>) => Socket;
    // sends reaction to one specific connection.
    send: (connection: Connection, reaction: Reaction<any>) => Socket;
    // sends reaction to all with omition of the specified by omit. Only in room if specified
    broadcast: (reaction: Reaction<any>, omit?: Connection, roomname?: string) => Socket;
    // addroutes should be used instead, this is for convenience
    select: (type: string) => Observable<{payload: any, connection: Connection}>;
    
    addUserToRoom: (roomname: string, conn: Connection) => Socket;
    removeUserFromRoom: (roomname: string, conn: Connection) => Socket
}

You can specify SocketOpts in the constructor of the socket class:

export class Socket implements ISocket{
    constructor(opts: SocketOpts = {}) {
        //..
    }
}

The options:

Here are the default options:

export const DEFAULT_OPTS: SocketOpts = {
        path: 'arsocket',
        logLevel: 'debug',
        port: 3000,
        staticDir: '',
        customServer: false,
        engine: Engines.uws
};
  • path is the prefix to which the client is going to connect to.
  • Custom server, is whether you supplied your own http server
  • Engine is the underlying engine used. The default is uws.

Documentation client:

Installation:

    npm i @dasnoo/arsocket-client

Client Api:

The client side api is very simple let's take a look at it :

    export interface ISocket {
        readonly socket: any;

        init:(options: SocketOpts) => Socket | undefined;

        send:(type: string, payload?: any, metadata?: {}) => Observable<any>;

        select:(type: string) => Observable<any>

    }
  • socket is the actual socket given by the engine
  • send is the method used to send messages to the server
  • select is used to select messages from the server

The options:

The options should be self explanatory.

    export const DEFAULT_OPTS_CLIENT: SocketOpts = {
        host: 'localhost', 
        port: 3000, 
        path: 'arsocket',
        secure: false, 
        engine: Engines.websocket,
        reconnectionDelay: 200,
        reconnectionDelayMax: 5000
    };

This will connect to a socket on ws://localhost:3000/arsocket, if it doesn't manage to connect it will retry 200ms after that then 400ms then 800ms until it's capped at 5000ms.

Extending the socket:

One can extend the socket to give it additional functionalities. Here is an example of a socket that will send a JWT token when the connection hasn't been authenticated on the server :

    export class AuthSocket extends Socket implements ISocket{

        private isAuthenticatedOnServer: boolean;
        private token: string | undefined;

        constructor(){
            super();
            this.select('auth')
                    .subscribe(r => this.onAuth(r));
        }


        send(type: string, payload?: any, metadata: any = {}){
            if(!this.isAuthenticatedOnServer && this.token){
                metadata.auth = { token: this.token };
            }
            return super.send(type, payload, metadata);
        }

        onAuth(r){
            this.isAuthenticatedOnServer = true;
        }
    }

With that one can create a middleware on the server to check if metadata.auth is present and if so authenticate the connection with the token.

In development

  • Redis adaptater to scale accross process
  • Tests

Delving into the source code

Prerequisite

Since the library makes use of Rxjs, knowing what's an Observable is a good idea.

Visual

visual

The Bridge overview

The Bridge is used when the Socket object must interact with the underlying engine. Since there are many different websocket engine out there, we need a Bridge.

Bridge class server side:

export class Bridge{
    // engine specific implementation of the bridge
    engineBridge: any;

    setEngine(engine: string){
        switch(engine){
            case "engine.io":
                this.engineBridge = new EngineIOBridge(); break;
            case 'uws':
                this.engineBridge = new UwsBridge(); break;
            // etc..
            default:
                throw Error(`${engine} hasn't been implemented yet.`);
        }
    }

    onConnection(socket, fn: Function){
        return this.engineBridge.onConnection(socket, fn);
    }

    // etc ..
}

Thus when the socket wants to interacts with the underlying engine, it does so via the Bridge. The bridge in turn talks to the correct bridge implementation. This is the only point in the source code where there is interactions with the engines.

If an engine specific bridge is not implemented yet, it's easy to write one in 5 minutes. You just have to implement the IBridge interface (if you are using typescript) or copy the other bridges if you are using regular javascript. Just take a look at SockJSBridge. It's that simple.

visual

Event handler

The Event handler is responsible for pushing events. Take a look at the code below where the event handler adds event to the _connection Rx.Subject.

    export class SocketEventHandler{

        // subject for pushing events
        private _connection = new Subject<Connection>();
        // observable to subscribe
      public connection$ = this._connection.asObservable();

        constructor(private bridge: Bridge){}

        addEventHandlers(socket:any){
                this.bridge.onConnection(socket, (engineConnection: any) => {
                    const connectionWrapper: Connection = { engineConnection };
                    this._connection.next(connectionWrapper);
                });
        }
    }

The router

Here is a simplified version of the router, to get the basic idea.

    export class Router{
        constructor(private evtHandler: SocketEventHandler, private routeConfig: Array<Route> ){
            // 1. we add the routes to object for easy access 
            this.addRoutes(routeConfig)
            // 2. on action we route to the correct handler
            evtHandler.action$.subscribe(a => this.route(a));
        }

        private addRoutes(routeConfig){
            routeConfig.forEach(r => this.addRoute(r));
        } 

        private addRoute(route: Route){
            this.routes[route.type] = route;
        }

        private route(a: ActionEvent){
            const r = this.routes[a.action.type];
            if(r)
                r.handler(a.action.payload, a.connection);
        }

If you find any bugs or have a feature request, please open an issue on github!

The npm package download data comes from npm's download counts api and package details come from npms.io.