Aral Balkan

Mastodon icon RSS feed icon

Remote: a little module for more elegant remoting with WebSockets

Remote1 is a tiny (< 50 lines of code) module that creates a very lightweight façade over a socket connection, using convention over configuration to give you an expressive interface with which to send outgoing messages and handle incoming ones.

You can use it both on the server and on the client.2

Here’s a simple Site.js example that performs some basic arithmetic on the server and keeps a count.

  1. Install Site.js.

  2. Create a folder to hold the example and the following directory structure inside it.

    ├── .dynamic
    │  └── .wss
    │     └── index.cjs
    └── index.html
    

    The index.cjs file is where you will put your server-side WebSocket route and index.html is where you will have your client.

  3. Initialise an npm project.

    npm init -y
    
  4. Install the Remote module.

    npm install @small-tech/remote
    
  5. Create the client (in index.html).

    <script type='module'>
      // Only load from Skypack for simple demonstration purposes. Don’t
      // use in production as it doesn’t support sub-resource integrity.
      // (See https://ar.al/2020/12/30/skypack-backdoor-as-a-service/)
      import Remote from 'https://cdn.skypack.dev/@small-tech/remote'
    
      const socket = new WebSocket(`wss://${window.location.hostname}/`)
      const remote = new Remote(socket)
    
      socket.addEventListener('open', () => {
        // Send remote events.
        remote.counter.increment.send()
        remote.addNumbers.request.send({firstNumber: 40, secondNumber: 2})
        remote.subtractNumbers.request.send({firstNumber: 44, secondNumber: 2})
      })
    
      // Handle remote events.
    
      remote.counter.update.handler = message => {
        log(`Counter is now ${message.count}`)
      }
    
      remote.addNumbers.response.handler = message => {
        log (`${message.firstNumber} + ${message.secondNumber} = ${message.result}`)
      }
    
      remote.subtractNumbers.response.handler = message => {
        log(`${message.firstNumber} - ${message.secondNumber} = ${message.result}`)
      }
    
      // DOM manipulation.
      const output = document.getElementById('output')
      const log = message => {
        const li = document.createElement('li')
        li.innerHTML = message
        output.appendChild(li)
      }
    </script>
    
    <h1>Simple remote arithmetic</h1>
    <ul id='output'></ul>
    
  6. Create the server (in .dynamic/.wss/index.cjs).

    const Remote = require('@small-tech/remote')
    
    let count = 0
    
    module.exports = function (client, request) {
    
      const remote = new Remote(client)
    
      remote.addNumbers.request.handler = message => {
        remote.addNumbers.response.send({
          firstNumber: message.firstNumber,
          secondNumber: message.secondNumber,
          result: message.firstNumber + message.secondNumber
        })
      }
    
      remote.subtractNumbers.request.handler = message => {
        remote.subtractNumbers.response.send({
          firstNumber: message.firstNumber,
          secondNumber: message.secondNumber,
          result: message.firstNumber - message.secondNumber
        })
      }
    
      remote.counter.increment.handler = message => {
        count++
        remote.counter.update.send({ count })
      }
    }
    
  7. Run Site.js (in the root of your example folder).

    site
    

Now hit https://localhost and you should see the following output:

Simple remote arithmetic

    • Counter is now 1
    • 40 + 2 = 42
    • 44 - 2 = 42

If you hit the page from other browser tabs, you should see the counter increase (it will reset if you restart Site.js as we are not persisting the value anywhere.3).

How it works

Messages are sent as JSON strings and received messages are parsed from JSON strings.

There are two special keywords: send and handler. You use the former when sending a message and you define the latter as a function to handle received messages.

Otherwise, there is nothing special about the path segments that make up the rest of the statements you see. Anything between the remote instance reference and either send or handler is used as the message type.

So, for example, addNumbers.request and addNumbers.response are message types, as is counter.update. Remote automatically creates the defined object hierarchies using some Proxy magic.

So far, Remote is working well for me as I build Domain. Give it a shot and see if it works for you too.


  1. In building Domain (see a demo of it from last week’s episode of Small is Beautiful) using Site.js and SvelteKit, I use WebSockets1 for calls between the client and server. This works really well and I find it much easier (less verbose and easier to maintain) than HTTP calls.

    Even though the majority of the communication in Domain is request/response, I didn’t want to implement traditional RPC over WebSocket (which is quite restrictive). Instead, I wanted to retain the feel of a message-based API but give it some structure and an elegant, expressive interface.

    Starting out, I didn’t want to use a heavyweight framework of any kind so I was simply passing messages back and forth, using plain old strings (POS; not a terrible acronym to describe the practice, if you think about it) for the message names. This works well enough as a quick and dirty way to get started but, as your app grows, you’ll likely start to feel the need to refactor.

    During the first pass of refactoring, I replaced the POS with constants. This is fine, and gives you additional type safety at compile-time but it is also more verbose. But I just wasn’t happy with how the code read. So I decided to create a tiny class to standardise and abstract out the socket communication between client and server. ↩︎

  2. It’s ‘isomorphic’, if you want to get fancy about it. ↩︎

  3. If you want to persist the counter so it survives serve restarts, it is trivial to do so using Site.js.

    Just update the server like this:

    // …
    
    if (!db.counter) {
      db.counter = { count: 0 }
    }
    
    module.exports = function (client, request) {
    
      // …
    
      remote.counter.increment.handler = message => {
        db.counter.count++
        remote.counter.update.send({ count: db.counter.count })
      }
    }
    

    (db is a global reference to the JavaScript Database (JSDB) instance that’s available for you to use in any HTTP or WebSocket route in Site.js.) ↩︎