---
title: "JavaScript · RUAL Documentation"
description: "Connect to RUAL from plain JavaScript."
canonical: https://docs.rual.nl/other/javascript
language: en
---

[Cluster](https://docs.rual.nl/cluster)

[Blocks](https://docs.rual.nl/block-types)

[Interfaces](https://docs.rual.nl/interfaces)

[Blueprints](https://docs.rual.nl/blueprints)

[Tutorials](https://docs.rual.nl/tutorials)

[Home automation](https://docs.rual.nl/home-automation)

[Examples](https://docs.rual.nl/examples)

[Reference](https://docs.rual.nl/reference)

[Architecture](https://docs.rual.nl/architecture)

[Troubleshooting](https://docs.rual.nl/troubleshooting)

Other

# JavaScript

Connect to the RUAL backend from plain JavaScript, in a browser or in Node, with no framework and no dependencies.

### Authentication

Requests over the socket carry an `access_token`. See the [authentication APIs](https://docs.rual.nl/cluster/api/authentication) for how to obtain one. The same token works over HTTP and over the WebSocket.

## WebSocket Connection

The socket carries a request and response protocol on top of plain WebSocket frames. You send a message containing a `requestid`, and the reply comes back carrying the same `requestid`, so several requests can be in flight at once. Alongside those replies the server pushes broadcasts that you never asked for.

The example below is dependency free. It runs unchanged in a browser and in Node 22 or later, which provides a global `WebSocket`.

### What the example handles for you

A WebSocket that only opens a connection is easy to write and unpleasant to operate. These are the parts that are worth copying:

- **Reconnecting.** Broadcasts are pushed rather than polled, so a client whose socket has dropped does not fail: it goes silent. That is considerably harder to notice than an error. The example reconnects on both `close` and `error`.

- **Backing off, and not resetting too eagerly.** The delay doubles from 500ms to a 30 second ceiling. It is only forgiven once a connection has stayed up for ten seconds, not the moment one opens. A backend that is draining will accept you, assign an id, and then announce that it is going down: reset on open and the backoff never grows, so you reconnect in a tight loop against a server that is trying to stop.

- **One reconnect at a time.** `close` and `error` often both fire for the same failure. Without a guard each schedules its own timer, each timer opens its own socket, and you finish with several live connections all reconnecting.

- **Detaching handlers before closing.** Otherwise `close()` triggers your own `onclose`, which schedules a reconnect, and a socket you deliberately tore down comes back.

- **Timing out requests.** A reply that never arrives would otherwise leave a promise pending forever, along with its entry in the pending map.

- **Acting on `server: down`.** The backend announces its own shutdown before dropping you, which lets you move to whichever node comes up next instead of waiting for a TCP close.

### Re-subscribe on every reconnect

Subscriptions belong to a socket, not to your session. When the socket is replaced, the server has no record of what the previous one was listening to. Put your subscribe calls in `onOpen`, which runs after every reconnect and not only after the first connect.

```
/*
 * A WebSocket client for the RUAL cluster, in plain JavaScript.
 *
 * Works unmodified in a browser and in Node 22+ (which has a global
 * WebSocket). No dependencies.
 *
 * The socket carries a request/response protocol on top of plain WebSocket
 * frames. You send {method, uri, requestid} and the reply comes back carrying
 * the same requestid, so several requests can be in flight at once. The server
 * also pushes broadcasts that were never requested.
 */

const DEFAULT_OPTIONS = {
  // The socket lives in rual-core itself, on the cluster domain.
  //
  // Note the path is /ws. An older generation of this example used a separate
  // rual-ws host with a /primus path; that service is gone. If you still have
  // that URL somewhere, it will fail in a way that is easy to misread: the
  // hostname usually has no record of its own, resolves through the wildcard
  // to a load balancer, and gets a 301 redirect. A WebSocket handshake cannot
  // follow a redirect, so the socket never opens and nothing reports an error.
  'url': 'wss://{{cluster-domain}}/ws',

  // Reconnect backoff. Doubles from min to max.
  'reconnectMinMs': 500,
  'reconnectMaxMs': 30000,

  // How long a connection must stay up before its backoff is forgiven. See
  // scheduleReconnect for why this is not simply reset when the socket opens.
  'stableAfterMs': 10000,

  // How long to wait for a reply before rejecting the request.
  'requestTimeoutMs': 15000
};

export default class RualWebSocket {
  constructor (options = {}) {
    this.options = Object.assign({}, DEFAULT_OPTIONS, options);
    this.accessToken = options.accessToken || null;

    this.connection = null;
    this.socketId = null;
    this.requestId = 0;
    this.openRequests = new Map();

    this.reconnecting = false;
    this.reconnectAttempts = 0;
    this.stableTimer = null;
    this.closedByUs = false;

    // Broadcast handlers, keyed by the broadcast payload type.
    this.listeners = new Map();
  }

  /* ---------------------------------------------------------------- connect */

  connect () {
    if (this.connection) {
      const state = this.connection.readyState;
      if (state === WebSocket.CONNECTING || state === WebSocket.OPEN) {
        return;
      }
    }

    this.closedByUs = false;
    this.requestId = 0;
    this.connection = new WebSocket(this.options.url);

    this.connection.onopen = () => {
      console.log('[ws] connected');

      /*
       * Forgive the backoff only once this connection has STAYED up.
       *
       * A backend that is draining will accept you, assign an id, and then
       * immediately announce that it is going down. If the backoff resets
       * whenever a socket opens, it never grows, and you reconnect in a tight
       * loop against a server that is trying to shut down.
       */
      clearTimeout(this.stableTimer);
      this.stableTimer = setTimeout(() => {
        this.reconnectAttempts = 0;
      }, this.options.stableAfterMs);

      this.onOpen();
    };

    this.connection.onmessage = (event) => {
      this.handleMessage(event);
    };

    this.connection.onerror = () => {
      // Do not merely drop the socket. Without a scheduled reconnect nothing
      // reopens it, and because broadcasts are pushed rather than polled, the
      // client goes quiet instead of going wrong. That is much harder to spot.
      this.scheduleReconnect();
    };

    this.connection.onclose = () => {
      this.onClose();
      this.scheduleReconnect();
    };
  }

  /*
   * Tear the socket down for good. Call this when you are finished with the
   * client, for example when unmounting the view that owns it.
   */
  close () {
    this.closedByUs = true;
    this.teardown();
  }

  teardown () {
    clearTimeout(this.stableTimer);
    this.stableTimer = null;

    if (this.connection) {
      /*
       * Detach the handlers BEFORE closing.
       *
       * Otherwise close() fires our own onclose, which schedules a reconnect,
       * and a socket we deliberately tore down comes back from the dead. Worse,
       * the old instance keeps its own reconnect loop running alongside the new
       * one and the duplicates multiply.
       */
      const socket = this.connection;
      socket.onopen = null;
      socket.onmessage = null;
      socket.onerror = null;
      socket.onclose = null;
      try {
        socket.close();
      } catch (err) {
        // Already closing or closed. Nothing to do.
      }
    }

    this.connection = null;
    this.socketId = null;
  }

  /*
   * One reconnect cycle at a time, backed off exponentially and capped.
   *
   * The guard matters: close and error can both fire for the same failure, and
   * without it each would schedule its own timer, each timer would open its own
   * socket, and you would end up with several live connections all reconnecting.
   */
  scheduleReconnect () {
    if (this.closedByUs || this.reconnecting) {
      return;
    }
    this.reconnecting = true;

    this.teardown();

    this.reconnectAttempts += 1;
    const delay = Math.min(
      this.options.reconnectMinMs * Math.pow(2, (this.reconnectAttempts - 1)),
      this.options.reconnectMaxMs
    );
    console.log('[ws] reconnecting in ' + delay + 'ms (attempt ' + this.reconnectAttempts + ')');

    setTimeout(() => {
      this.reconnecting = false;
      if (!this.closedByUs) {
        this.connect();
      }
    }, delay);
  }

  async waitForConnected () {
    if (this.connection && this.connection.readyState === WebSocket.OPEN) {
      return;
    }

    if (!this.connection && !this.reconnecting) {
      this.connect();
    }

    // Poll rather than recurse: an unbounded recursive wait keeps a stack
    // frame per attempt and never gives up.
    const deadline = Date.now() + this.options.requestTimeoutMs;
    while (Date.now() < deadline) {
      if (this.connection && this.connection.readyState === WebSocket.OPEN) {
        return;
      }
      await new Promise((resolve) => {
        setTimeout(resolve, 100);
      });
    }

    throw new Error('WS_NOT_CONNECTED');
  }

  /* ---------------------------------------------------------------- request */

  /*
   * Send a request and resolve with its result.
   *
   *   const me = await ws.request('GET', 'v1/users/me');
   *   await ws.request('POST', 'v1/some/endpoint', { body: { name: 'x' } });
   */
  async request (method, uri, options = {}) {
    await this.waitForConnected();

    this.requestId += 1;
    const requestid = 'RN' + String(this.requestId).padStart(10, '0');

    const payload = {
      'method': String(method).toUpperCase(),
      'requestid': requestid,
      'uri': '/api/' + uri
    };

    const token = options.accessToken || this.accessToken;
    if (token) {
      payload.access_token = token;
    }
    if (options.body) {
      payload.body = options.body;
    }
    if (options.query) {
      payload.query = options.query;
    }

    this.connection.send(JSON.stringify(payload));

    return new Promise((resolve, reject) => {
      // Always time out. Without this, a reply that never arrives leaks a
      // pending promise and its entry in openRequests, forever.
      const timer = setTimeout(() => {
        this.openRequests.delete(requestid);
        reject(new Error('WS_REQUEST_TIMEOUT'));
      }, this.options.requestTimeoutMs);

      this.openRequests.set(requestid, {
        'resolve': resolve,
        'reject': reject,
        'method': method,
        'uri': uri,
        'timer': timer
      });
    });
  }

  /* -------------------------------------------------------------- broadcast */

  /*
   * Register a handler for a broadcast type. Returns a function that removes
   * it again.
   */
  on (type, handler) {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type).add(handler);

    return () => {
      const handlers = this.listeners.get(type);
      if (handlers) {
        handlers.delete(handler);
      }
    };
  }

  emit (type, payload) {
    const handlers = this.listeners.get(type);
    if (!handlers) {
      return;
    }
    handlers.forEach((handler) => {
      try {
        handler(payload);
      } catch (err) {
        console.error('[ws] broadcast handler for ' + type + ' threw', err);
      }
    });
  }

  /* ---------------------------------------------------------------- inbound */

  handleMessage (message) {
    if (!message.data) {
      return;
    }

    let data = null;
    try {
      data = JSON.parse(message.data);
    } catch (err) {
      console.error('[ws] received a frame that is not JSON');
      return;
    }

    // Heartbeat. The server sends a bare string and expects it echoed back
    // with "ping" swapped for "pong".
    if (typeof data === 'string') {
      this.replyPing(message.data);
      return;
    }

    // The server greets every new socket with the id it assigned. Keep it:
    // when broadcasts go missing, being able to say which socket you are on is
    // the difference between a five minute diagnosis and an hour of guessing.
    if (data.type === '_id') {
      this.socketId = data.id;
      console.log('[ws] socket id ' + data.id);
      return;
    }

    // The backend announces its own shutdown before it drops you. Acting on it
    // means reconnecting to whichever node comes up next, rather than waiting
    // for a TCP close that can take considerably longer to notice.
    if (data.server === 'down') {
      console.log('[ws] server announced it is going down');
      this.scheduleReconnect();
      return;
    }

    if (data.requestid) {
      this.resolveRequest(data);
      return;
    }

    if (data.broadcast) {
      const items = Array.isArray(data.broadcast) ? data.broadcast : [data.broadcast];
      items.forEach((item) => {
        const payload = item.message || item || {};
        this.emit(payload.type, payload);
      });
    }
  }

  resolveRequest (data) {
    const pending = this.openRequests.get(data.requestid);
    if (!pending) {
      // Usually a reply to a request that already timed out.
      console.log('[ws] reply for unknown request ' + data.requestid);
      return;
    }

    clearTimeout(pending.timer);
    this.openRequests.delete(data.requestid);

    if (data.code === 200 || data.code === 201) {
      pending.resolve(data.result);
      return;
    }

    // Reject with a real Error so a stack trace survives, and hang the server
    // detail off it rather than throwing a bare object.
    const result = data.result || {};
    const err = new Error(result.error || ('HTTP_' + data.code));
    err.code = data.code;
    err.result = result;
    err.uri = pending.uri;
    err.method = pending.method;
    pending.reject(err);
  }

  replyPing (raw) {
    if (!this.connection || this.connection.readyState !== WebSocket.OPEN) {
      return;
    }
    this.connection.send(raw.replace('ping', 'pong'));
  }

  /* ------------------------------------------------------------- overridable */

  // Called once the socket is open. Subscribe to what you need here: it runs
  // again after every reconnect, which is exactly when subscriptions have to
  // be re-established.
  onOpen () {}

  // Called whenever the socket closes, for any reason.
  onClose () {
    console.log('[ws] disconnected');
  }
}

/*
 * Usage
 *
 *   const ws = new RualWebSocket({ accessToken: myToken });
 *
 *   ws.onOpen = async () => {
 *     // Re-run on every reconnect, not only the first connect.
 *     await ws.request('GET', 'v1/ws/subscribe');
 *   };
 *
 *   ws.on('some-broadcast-type', (payload) => {
 *     console.log('received', payload);
 *   });
 *
 *   ws.connect();
 *
 *   const me = await ws.request('GET', 'v1/users/me');
 *
 *   // When you are finished:
 *   ws.close();
 */
```

### Using it

Register broadcast handlers before connecting, so nothing that arrives immediately after the socket opens is missed.

```
const ws = new RualWebSocket({ accessToken: myToken });

ws.onOpen = async () => {
  // Runs on every reconnect, not only the first connect.
  await ws.request('GET', 'v1/ws/subscribe');
};

ws.on('some-broadcast-type', (payload) => {
  console.log('received', payload);
});

ws.connect();

const me = await ws.request('GET', 'v1/users/me');

// When you are finished with the client:
ws.close();
```

### React Native

The protocol is identical there. See the [React Native](https://docs.rual.nl/other/react-native) page for a version written against that runtime.

Was this page helpful? [Tell us what to improve](https://docs.rual.nl/support) · RUAL Docs is an integral component of the [RUAL ecosystem](https://rual.nl)
