flitter-socket

A transactional web-socket framework for Flitter.

Usage no npm install needed!

<script type="module">
  import flitterSocket from 'https://cdn.skypack.dev/flitter-socket';
</script>

README

flitter-socket

flitter-socket is a transactional-websocket implementation for Flitter. For general-purpose applications that want real-time data, a websocket is awesome. But, that data may not always have the same structure. flitter-socket adds a transactional layer to express-ws to allow clients to make live requests to the server, and (the true attraction of this system) the server to make live requests to the client. All of this, while still fitting reasonably well within Flitter's existing controller framework.

Getting Started

Installation

flitter-socket doesn't ship with Flitter by default, but it's pretty easy to add. First, install the library:

yarn add flitter-socket

Then, add the following line to the "Custom Units" section of your application's Units.flitter.js file:

'Socket' : new (require('flitter-socket/SocketUnit'))(),

Now, you should be able to launch Flitter with the sockets unit:

$ ./flitter shell
(flitter)> _flitter.has('sockets')
true

Defining Socket Routes - Key Concepts

Sockets in Flitter don't work the same as normal requests. With normal requests, you define one route per possible endpoint, and when an HTTP request comes in to that endpoint, Flitter calls the controller method specified in the routes definition file.

However, because a websocket connection begins with an HTTP connection, but then stays open and may make many more requests/responses, it's handled differently by flitter-socket. The general flow is as follows:

  • Define a websocket controller class with various methods.
  • In a routing file's socket definition, define the route used as the connection endpoint and point it to the socket controller's special, built-in _connect method. This sets up a connection manager that can handle 2-way transactions between the client and the server.
  • Requests from the client specify endpoints that correspond to method names on the socket controller. Those methods are called when a valid client request is received.
  • Requests from the server to the client can be made using the controller's built-in _request method.

So, we're going to first create a template controller that we'll come back to later, and now define the routes. Create the template controller like so:

./flitter new socket:controller SocketTest

Now, open any routes file (in our case, just we're just using index.routes.js) and add the following:

socket: {
    '/socket-test': [ _flitter.controller('SocketTest')._connect ],
},

As previously mentioned, _connect is a method of the SocketController class that bootstraps the incoming websocket to support flitter-socket's transactional protocol.

Socket Controllers - Key Concepts

Socket controllers define the methods and logic available to open websocket connections. They are responsible for sending and processing transactions with connected clients. Let's look at the template controller we generated, SocketController.controller.js:

const SocketController = require('flitter-socket/Controller')  
  
class SocketTest extends SocketController {  
      
    ping(transaction, socket){  
        console.log('Sending Ping!')  
        transaction.status(200).message('Pinging!').send(transaction.incoming)  
          
        // Make a request to the client  
        this._request('testendp', {hello: 'world'}, (t, ws, data) => {  
            console.log('Got client response!')  
            console.log(data)  
            t.resolved = true  
        }, transaction.connection_id)  
    }  
      
}  
  
module.exports = exports = SocketTest

The flitter-socket/SocketController superclass provides some helper methods for bootstrapping and managing websocket connections. It also validates requests to conform to the flitter-socket spec, and creates flitter-socket/Transaction instances.

In this controller, there's one endpoint specified, ping. This is an endpoint that can be called by requests that come in from the client. It is passed 2 arguments: an instance of flitter-socket/ClientServerTransaction, and the open websocket. As much as possible, you should interact with websocket clients through the transaction instance, not the socket directly.

This simple endpoint does two things. First, it sends a response back to the client with any incoming data the request contained and the message "Pinging!". Then, it makes a request to the client. (Note that this request is a separate transaction from the one we just processed.)

The request is made to the testendp endpoint on the client and the client is sent {hello: 'world'} as data. The third argument is the callback method which is called when the client sends a valid response to the server's request. The last argument is the specific connection ID to whom the request should be sent. This is necessary because a controller may be managing many open websocket connections at once.

The callback function is passed 3 arguments, similar to before: an instance of flitter-socket/ServerClientTransaction, the open websocket, and the response data. In the callback, we mark the transaction as resolved once we have finished processing the response.

SocketController Helper Methods

See the full docs fore more info.

flitter-socket Data Specification

So, we now know how to define endpoints and logic for incoming client connections, but how do we actually connect a client to begin with? flitter-socket imposes a strict structure on websocket connections to enable this 2-way transactional processing. This means that clients should send and keep track of transactions in a particular way.

Flitter provides a simple client-side implementation of this spec to make interacting with flitter-socket servers simpler. Check it out here.

Client-to-Server Requests

Let's look at an example of a request for data made from the client to the server:

{
    "transaction_id": "e0b193dc-2e33-49df-9fcd-7dde479a645b",
    "type": "request",
    "endpoint": "ping",
    "data": { "hi": "there" }
}

There are several important parts to this transaction. Let's break down all possible fields:

  • First, every message sent to or from a flitter-socket compliant connection must be a valid JSON object.
  • Every message should be a JSON object which contains some or all of the following:
    • transaction_id (required) - every transaction MUST have a universally-unique tranaction ID. This ID must be unique not only to the client-side, but also to the server. Therefore, it is recommended that you use a UUID library like uuid to generate these. The flitter-socket server implementation uses uuid/v4. This field is how the connection managers on either side of the connection match up requests with responses to call the appropriate handlers.
    • type (required) - either "request" or "response" - this specifies the type of data that is being sent. A request is a message that the sender is awaiting data from the sender. A response is a message that the sender is fulfilling that data.
    • endpoint (required for request type) - If the message is a request, this specifies the endpoint that should be used to handle the request. In this example, it would call the ping() method on our SocketTest controller.
    • status (required for response type) - If the message is a response, specifies the HTTP status code-equivalent of the result of the transaction. For most successful cases, this should be 200.
    • data (optional) - The data payload included in the message. This may be request parameters or response data.

Sending this request to the server we set up above would produce the following response:

{
    "status":200,
    "transaction_id":"e0b193dc-2e33-49df-9fcd-7dde479a645b",
    "type":"response",
    "message":"Pinging!",
    "data":{"hi":"there"}
}

Server-to-Client Transactions

Server-to-Client transactions represent requests from the server to the client. These are identical in every respect to Client-to-Server transactions, but the client should be configured to handle these appropriately.

Message Validation

Any compliant server that can receive flitter-socket transaction messages should validate and reject invalid messages in a particular format. For the server-side implementation, this is done via an instance of the flitter-socket/ClientErrorTransaction class.

If possible, the transaction ID should be sent back to the client with these responses. Here's an example of a response to a message that failed validation. Say we sent the following request:

{
    "transaction_id": "e0b193dc-2e33-49df-9fcd-7dde479a645b",
    "type": "request",
    "data": { "hi": "there" }
}

This contains everything flitter-socket needs, except an endpoint to process the request with. This should generate the following response from the recipient:

{
    "status":400,
    "transaction_id":"e0b193dc-2e33-49df-9fcd-7dde479a645b",
    "type":"response",
    "message":"Incoming request message must include a valid endpoint.",
    "data":{}
}

Note that the equivalent HTTP response code was set properly. message can be arbitrary, but should be clear enough that it is obvious to the client why the request was rejected. In all possible instances, the transaction_id should be send back with these rejections. In fact, there are only two acceptable instances when it may be omitted:

  1. If no transaction ID was provided by the client.
  2. If the message could not be parsed as valid JSON.

In either of these cases, the response's transaction_id field should be set to "unknown":

{
    "status":400,
    "transaction_id":"unknown",
    "type":"response",
    "message":"Incoming message must be valid FSP JSON object.",
    "data":{}
}

The socket-client-js Library

Flitter provides a basic client-side implementation of this spec to make interacting with flitter-socket servers easier. More info here.

License (MIT)

flitter-socket Copyright © 2019 Garrett Mills

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.