WebSocket Relay

The websocket_relay middleware upgrades an HTTP connection and relays WebSocket messages to a target process.

Classification: protocol reference with partial integration recipes. The blocks assume an HTTP server, router, process host, target process, and security context. Application message handlers and client-state cleanup remain application-owned.

How It Works

  1. HTTP handler sets X-WS-Relay header with target process PID
  2. Middleware upgrades connection to WebSocket
  3. Relay attaches to the target process and monitors it
  4. Messages flow bidirectionally between client and process

Process Semantics

WebSocket connections are full processes with their own PID. They integrate with the process system:

  • Addressable → Any process can send messages to a WebSocket PID
  • Monitorable → Processes can monitor WebSocket connections for exit events
  • Linkable → WebSocket connections can be linked to other processes
  • EXIT events → When connection closes, monitors receive exit notifications
-- Monitor a WebSocket connection from another process
local _, monitor_err = process.monitor(websocket_pid)
if monitor_err then return nil, monitor_err end

-- Send a message to the WebSocket client from any process.
-- The relay wraps it as {topic, data} JSON; the topic name is arbitrary.
local _, send_err = process.send(websocket_pid, "update", "hello")
if send_err then return nil, send_err end
The relay monitors the target process. If the target exits, the WebSocket connection closes automatically and the client receives a close frame.

Connection Transfer

Connections can be transferred to a different process by sending a control message:

local _, transfer_err = process.send(websocket_pid, "ws.control", {
    target_pid = new_process_pid,
    message_topic = "ws.message"
})
if transfer_err then return nil, transfer_err end

Configuration

Add as post-match middleware on a router:

- name: ws_router
  kind: http.router
  meta:
    server: gateway
  prefix: /ws
  post_middleware:
    - websocket_relay
  post_options:
    wsrelay.allowed.origins: "https://app.example.com"
Option Description
wsrelay.allowed.origins Comma-separated allowed origins
If no origins configured, only same-origin requests are allowed.

Handler Setup

The HTTP handler spawns a process and configures the relay:

local http = require("http")
local json = require("json")

local function handler()
    local req, req_err = http.request()
    if req_err then return nil, req_err end
    local res, res_err = http.response()
    if res_err then return nil, res_err end

    local user_id, query_err = req:query("user_id")
    if query_err then return nil, query_err end

    -- Spawn handler process
    local pid, spawn_err = process.spawn("app.ws:handler", "app:processes")
    if spawn_err then return nil, spawn_err end

    -- Configure relay
    local relay_config, encode_err = json.encode({
        target_pid = tostring(pid),
        message_topic = "ws.message",
        heartbeat_interval = "30s",
        metadata = {
            user_id = user_id
        }
    })
    if encode_err then
        local _, terminate_err = process.terminate(pid)
        return nil, terminate_err or encode_err
    end

    local header_err = res:set_header("X-WS-Relay", relay_config)
    if header_err then
        local _, terminate_err = process.terminate(pid)
        return nil, terminate_err or header_err
    end
end

Relay Config Fields

Field Type Default Description
target_pid string required Process PID to receive messages
message_topic string ws.message Topic for client messages
heartbeat_interval duration 30s Heartbeat frequency (e.g. 30s)
metadata object - Attached to join/leave/heartbeat messages

Message Topics

The relay sends these messages to the target process:

Topic When Payload
ws.join Client connects JSON {client_pid, metadata}
ws.message (or your message_topic) Client sends message Raw client payload (text frame → String format, binary frame → Bytes format); payload:data() returns a Lua string for either format, and the source PID is the client PID
ws.heartbeat Periodic (every 30s by default; interval overridable via heartbeat_interval) JSON {client_pid, uptime, message_count, metadata}
ws.leave Client disconnects JSON {client_pid, metadata}

Receiving Messages

local function handler()
    local inbox = process.inbox()

    while true do
        local msg, ok = inbox:receive()
        if not ok then break end

        local topic = msg:topic()
        local from = msg:from()                -- client connection PID

        if topic == "ws.join" then
            -- Client connected — payload is {client_pid, metadata}
            local data, payload_err = msg:payload():data()
            if payload_err then return nil, payload_err end
            local client_pid = data.client_pid

        elseif topic == "ws.message" then
            -- Raw client message; from() is the client PID
            local incoming = msg:payload()
            local frame_format = incoming:get_format()     -- "text/plain" or "application/octet-stream"
            local body, payload_err = incoming:data()      -- Lua string in either case
            if payload_err then return nil, payload_err end
            -- Decode or dispatch `body` according to `frame_format` and the
            -- application's protocol.

        elseif topic == "ws.leave" then
            -- Client disconnected — payload is {client_pid, metadata}
            -- Release application state associated with `from`.
        end
    end
end

Sending to Client

Send messages back using the client PID. Any topic you choose is wrapped as {topic, data} JSON and forwarded to the WebSocket. Every server-to-client message is sent as a single WebSocket text frame containing the wrapper. Tables remain JSON objects in data; strings remain strings. Payloads that reach the relay in Bytes format are base64-encoded into data; they are not sent as separate binary frames. Lua process.send exports its arguments as Lua-format payloads, so a Lua string does not take the Bytes-format branch.

-- Send a structured message (any topic name)
process.send(client_pid, "update", json.encode({event = "update", value = 42}))

-- Close connection (payload is the close reason string)
local _, close_err = process.send(client_pid, "ws.close", "Session ended")
if close_err then return nil, close_err end

The reserved topics from server → client are ws.control (relay reconfiguration) and ws.close (close the connection).

Broadcasting

Track client PIDs to broadcast to multiple clients:

local clients = {}

-- On join
clients[client_pid] = true

-- On leave
clients[client_pid] = nil

-- Broadcast
local function broadcast(message)
    for pid, _ in pairs(clients) do
        local _, send_err = process.send(pid, "broadcast", message)
        if send_err then return nil, send_err end
    end
    return true
end
For complex multi-room scenarios, spawn a separate handler process per room or use a central manager process that tracks room memberships.

See Also