15  WebSocket

Everything we’ve covered so far follows one pattern: the client sends a request, the server sends back a response, and the connection closes. The client always initiates.

But what if the server needs to push data to the client without being asked? A live chat message. A notification. A real-time dashboard update.

That’s where the WebSocket protocol comes in. Unlike HTTP, a WebSocket connection stays open. Both sides can send messages at any time. Communication is bi-directional.

Ambiorix has built-in WebSocket support.

15.1 The Message Protocol

Before we look at any code, you need to understand how Ambiorix structures WebSocket messages.

Every message – whether sent from the server or the client – must be a JSON object with three fields:

{
  "name": "greeting",
  "message": "Hello!",
  "isAmbiorix": true
}
  • name: A string identifier. This is how Ambiorix routes messages to the right handler, much like a URL path routes HTTP requests.
  • message: The payload. Any JSON-serializable value.
  • isAmbiorix: Must be TRUE. Messages without this flag are silently ignored.

This structure applies in both directions. The R server and the JavaScript client both speak the same format.

15.2 Receiving Messages

Use app$receive() to listen for incoming WebSocket messages. It takes:

  1. name: The message name to listen for.
  2. handler: A function to run when that message arrives.

The handler function accepts up to two arguments:

  • msg: The message content (the message field from the JSON).
  • ws: The WebSocket connection object, which you can use to send a response back.
app$receive("greeting", function(msg, ws) {
  cat("Received:", msg, "\n")

  ws$send("greeting", "Hello from the server!")
})

If you don’t need to send a response, you can drop the ws argument:

app$receive("log", function(msg) {
  cat("Client says:", msg, "\n")
})

Ambiorix checks the number of arguments in your handler and adapts accordingly.

15.3 Sending Messages

The ws$send() method takes two arguments:

  • name: The message identifier.
  • message: The content to send. Anything that can be serialized to JSON.
ws$send(name = "update", message = list(count = 42L))

Ambiorix wraps this in the required {name, message, isAmbiorix} structure and serializes it to JSON automatically.

15.4 The JavaScript Client

On the client side, Ambiorix provides a small JavaScript library – ambiorix.js – that handles the WebSocket connection and message formatting for you.

If you set up your project with {ambiorix.generator}, this file is placed in your static directory automatically. Otherwise, you can copy it manually:

ambiorix::copy_websocket_client(path = "static/ambiorix.js")

Include it in your HTML:

<script src="/static/ambiorix.js"></script>

15.4.1 Sending from the Client

Ambiorix.send() is a static method. You don’t need to instantiate anything:

Ambiorix.send("greeting", "Hello from the browser!");

This sends a properly formatted JSON message to the server.

15.4.2 Receiving on the Client

To handle messages from the server, instantiate the class, register your handlers with receive(), and call start():

var wss = new Ambiorix();

wss.receive("greeting", function(msg) {
  alert(msg);
});

wss.start();

start() is what actually attaches the event listeners. Without it, your handlers won’t fire.

15.5 Example: Button Click

Let’s put it together. This example sends a message from the browser to the server when a button is clicked, and the server responds with an alert.

First, the HTML template (templates/home.html):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="/static/ambiorix.js"></script>
  <script>
    var wss = new Ambiorix();
    wss.receive("hello", function(msg) {
      alert(msg);
    });
    wss.start();
  </script>
  <title>WebSocket Example</title>
</head>
<body>
  <h1>WebSocket Example</h1>
  <button onclick="Ambiorix.send('hello', 'Hi from the browser!')">
    Send a message
  </button>
</body>
</html>

And the server (index.R):

library(ambiorix)

app <- Ambiorix$new()

app$static("static", "static")

app$get("/", function(req, res) {
  res$send_file("home.html")
})

app$receive("hello", function(msg, ws) {
  cat("Received:", msg, "\n")
  ws$send("hello", "Hello back! (sent from R)")
})

app$start(port = 3000L)

Run the app, open localhost:3000, and click the button. You’ll see "Received: Hi from the browser!" on the R console, and an alert in the browser with the server’s response.

15.6 Broadcasting

Sending a message via ws$send() replies only to the client that sent the original message. In many real-time applications – chat, live feeds, collaborative editing – you need to send a message to all connected clients.

Ambiorix does not have a built-in broadcast method. Instead, it tracks every connected WebSocket client in a list. Use get_websocket_clients() to access it:

app$receive("chat", function(msg, ws) {
  clients <- get_websocket_clients()

  lapply(clients, function(client) {
    client$send("chat", msg)
  })
})

When any client sends a "chat" message, the handler iterates over all connected clients and forwards the message to each one.

15.6.1 Example: Chat

Here’s a minimal chat application. Every message sent by any client is broadcast to all connected clients.

The HTML (templates/chat.html):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="/static/ambiorix.js"></script>
  <script>
    var wss = new Ambiorix();

    wss.receive("chat", function(msg) {
      var li = document.createElement("li");
      li.textContent = msg;
      document.getElementById("messages").appendChild(li);
    });

    wss.start();

    function sendMessage() {
      var input = document.getElementById("msg");
      Ambiorix.send("chat", input.value);
      input.value = "";
    }
  </script>
  <title>Chat</title>
</head>
<body>
  <h1>Chat</h1>
  <ul id="messages"></ul>
  <input type="text" id="msg" placeholder="Type a message...">
  <button onclick="sendMessage()">Send</button>
</body>
</html>

The server:

library(ambiorix)

app <- Ambiorix$new()

app$static("static", "static")

app$get("/", function(req, res) {
  res$send_file("chat.html")
})

app$receive("chat", function(msg, ws) {
  clients <- get_websocket_clients()

  lapply(clients, function(client) {
    client$send("chat", msg)
  })
})

app$start(port = 3000L)

Open the app in two browser tabs. Type a message in one and it appears in both.

15.7 R as a WebSocket Client

So far the client has been a browser running JavaScript. But you can also connect to an Ambiorix WebSocket server from another R session using the {websocket} package.

The catch: you have to construct the message JSON yourself. The isAmbiorix field is required, and the message must be serialized to a JSON string.

client <- websocket::WebSocket$new(
  "ws://127.0.0.1:3000",
  autoConnect = FALSE
)

client$onOpen(function(event) {
  cat("Connection opened\n")

  msg <- list(
    isAmbiorix = TRUE,
    name = "greeting",
    message = "Hello from R client!"
  )

  client$send(yyjsonr::write_json_str(msg, auto_unbox = TRUE))
})

client$onMessage(function(event) {
  cat("Server says:", event$data, "\n")
})

client$connect()

Notes:

  • autoConnect = FALSE lets you set up handlers before connecting.
  • isAmbiorix = TRUE is mandatory. Without it, Ambiorix ignores the message.
  • {yyjsonr} is used for serialization because that’s what Ambiorix uses internally. You could also use {jsonlite}.

15.8 Bypassing the Protocol

Everything above uses Ambiorix’s convenience layer: named messages, automatic JSON serialization, and the receive() routing mechanism.

If you need full control over the raw WebSocket connection – for example, to handle binary data or use a different message format – you can override the default handler entirely:

app$websocket <- function(ws) {
  ws$onMessage(function(binary, message) {
    cat("Raw message:", message, "\n")
  })
}

This replaces Ambiorix’s internal WebSocket handling. The receive() handlers you registered will no longer fire. You’re working directly with the {httpuv} WebSocket object.

Use this only when the built-in protocol doesn’t fit your use case.