WebSocket Client

The websocket module creates bidirectional client connections to WebSocket servers.

This is an API reference with partial connection and subscription recipes. Endpoint URLs, tokens, message handlers, and application data come from the surrounding application. The lifecycle examples close the client on every terminal or checked error path; smaller method snippets assume an enclosing owner performs that cleanup.

Loading

local websocket = require("websocket")

Add websocket to the executable entry's modules: list before requiring it. The channel global is always available; JSON and timeout recipes also require json and time.

Connecting

connect

Open a WebSocket connection with the default options:

local client, err = websocket.connect("wss://api.example.com/ws")
if err then
    return nil, err
end

Pass an options table to configure the connection:

local client, err = websocket.connect("wss://api.example.com/ws", {
    headers = {
        ["Authorization"] = "Bearer " .. token
    },
    protocols = {"graphql-ws"},
    dial_timeout = "10s",
    read_timeout = "30s",
    compression = websocket.COMPRESSION.CONTEXT_TAKEOVER
})
if err then
    return nil, err
end
Parameter Type Description
url string WebSocket URL (ws:// or wss://)
options table Connection options (optional)

Returns: Client, error

Connection Options

Option Type Description
headers table HTTP headers for handshake
protocols table WebSocket subprotocols
dial_timeout number/string Connection timeout (ms or "5s")
read_timeout number/string Read timeout
write_timeout number/string Write timeout
compression number/string Compression mode (see Constants), or "disabled", "context_takeover", "no_context_takeover"
compression_threshold number Min size to compress (0-100MB)
read_limit number Max message size (0-128MB)
channel_capacity number Receive channel buffer (1-10000)

Timeout format: Numbers are milliseconds. Strings use Go duration syntax such as "5s" or "1m".

Invalid timeout strings and out-of-range or unsupported option values are ignored, leaving the corresponding default in effect.

Sending Messages

Text Messages

Send a text message.

local json = require("json")

client:send("Hello, Server!")

-- Send JSON
local payload, encode_err = json.encode({
    type = "subscribe",
    channel = "orders"
})
if encode_err then return nil, encode_err end
client:send(payload)

Binary Messages

Send a binary message by specifying websocket.BINARY.

client:send(binary_data, websocket.BINARY)
Parameter Type Description
data string Message content
type number websocket.TEXT (1) or websocket.BINARY (2)

Yields until the message is sent. Returns no values.

Ping

Send a ping frame.

client:ping()

Yields until the ping is sent. Returns no values.

Receiving Messages

channel() returns the receive channel, and receive() is an alias. The first call yields while the runtime creates the subscription; later calls return the same channel immediately. A subscription failure returns nil, error. The channel can be used with channel.select.

Basic Receive

local ch, err = client:channel()
if err then
    client:close()
    return nil, err
end

local msg, ok = ch:receive()
if ok then
    print("Type:", msg.type)  -- "text" or "binary"
    print("Data:", msg.data)
end

local _, close_err = client:close()
if close_err then return nil, close_err end

Message Loop

local json = require("json")

local ch, err = client:channel()
if err then
    client:close()
    return nil, err
end

while true do
    local msg, ok = ch:receive()
    if not ok then
        break  -- Connection closed
    end

    if msg.type == "text" then
        local data, decode_err = json.decode(msg.data)
        if decode_err then
            client:close()
            return nil, decode_err
        end
        handle_message(data)
    end
end

local _, close_err = client:close()
if close_err then return nil, close_err end

With Select

local json = require("json")
local time = require("time")

local ch, ch_err = client:channel()
if ch_err then
    client:close()
    return nil, ch_err
end

local timeout, timeout_err = time.after("30s")
if timeout_err then
    client:close()
    return nil, timeout_err
end

while true do
    local r = channel.select {
        ch:case_receive(),
        timeout:case_receive()
    }

    if r.channel == timeout then
        client:ping()  -- Keep-alive
        timeout, timeout_err = time.after("30s")
        if timeout_err then
            client:close()
            return nil, timeout_err
        end
    elseif not r.ok then
        break
    else
        local data, decode_err = json.decode(r.value.data)
        if decode_err then
            client:close()
            return nil, decode_err
        end
        process(data)
    end
end

local _, close_err = client:close()
if close_err then return nil, close_err end

Message Object

Field Type Description
type string "text" or "binary"
data string? Message content (nil for unknown payload types)

Closing Connection

Close the connection with an optional status code and reason:

local _, close_err = client:close(websocket.CLOSE_CODES.NORMAL, "Session ended")
if close_err then return nil, close_err end

-- Omitting both arguments also uses normal close code 1000.
-- Use INTERNAL_ERROR with an application-owned reason for a failed session.
Parameter Type Description
code number Close code (1000-4999), default 1000
reason string Close reason (optional)

The call yields until the close command completes. Success returns no values; a close failure returns nil, error. Capture two results when checking it, because the error is the second result. Values outside the accepted numeric range are ignored and the default code 1000 is used.

The receive channel is owned by the client; do not close it directly. A remote terminal event closes the channel. Calling client:close() unsubscribes the receive channel and stops the client-side producer, so use it promptly rather than relying on process shutdown cleanup.

Constants

Message Types

-- Numeric (for send)
websocket.TEXT    -- 1
websocket.BINARY  -- 2

-- Compatibility string constants
websocket.TYPE_TEXT    -- "text"
websocket.TYPE_BINARY  -- "binary"
websocket.TYPE_PING    -- "ping"
websocket.TYPE_PONG    -- "pong"
websocket.TYPE_CLOSE   -- "close"

Receive-channel message objects use only "text" and "binary". Ping and pong frames are handled by the transport, and a terminal event closes the channel instead of producing a "close" message object.

Compression Modes

websocket.COMPRESSION.DISABLED         -- 0 (no compression)
websocket.COMPRESSION.CONTEXT_TAKEOVER -- 1 (sliding window)
websocket.COMPRESSION.NO_CONTEXT       -- 2 (per-message)

Close Codes

Constant Code Description
NORMAL 1000 Normal closure
GOING_AWAY 1001 Server shutting down
PROTOCOL_ERROR 1002 Protocol error
UNSUPPORTED_DATA 1003 Unsupported data type
RESERVED 1004 Reserved
NO_STATUS 1005 No status received
ABNORMAL_CLOSURE 1006 Connection lost
INVALID_PAYLOAD 1007 Invalid frame payload
POLICY_VIOLATION 1008 Policy violation
MESSAGE_TOO_BIG 1009 Message too large
MANDATORY_EXTENSION 1010 Required extension not negotiated
INTERNAL_ERROR 1011 Server error
SERVICE_RESTART 1012 Server restarting
TRY_AGAIN_LATER 1013 Server overloaded
BAD_GATEWAY 1014 Gateway error
TLS_HANDSHAKE 1015 TLS handshake failure
local _, close_err = client:close(websocket.CLOSE_CODES.NORMAL, "Done")
if close_err then return nil, close_err end

Examples

Real-Time Chat

local json = require("json")

local function connect_chat(room_id, token, on_message)
    local client, err = websocket.connect("wss://chat.example.com/ws", {
        headers = {["Authorization"] = "Bearer " .. token}
    })
    if err then
        return nil, err
    end

    -- Join room. Runtime v0.3.32a does not expose transport send failures.
    local join_payload, encode_err = json.encode({
        type = "join",
        room = room_id
    })
    if encode_err then
        client:close()
        return nil, encode_err
    end
    client:send(join_payload)

    -- Message loop
    local ch, channel_err = client:channel()
    if channel_err then
        client:close()
        return nil, channel_err
    end
    while true do
        local msg, ok = ch:receive()
        if not ok then break end

        local data, decode_err = json.decode(msg.data)
        if decode_err then
            client:close()
            return nil, decode_err
        end
        on_message(data)
    end

    local _, close_err = client:close()
    if close_err then return nil, close_err end
    return true
end

Price Stream with Keep-Alive

local json = require("json")
local time = require("time")

local client, err = websocket.connect("wss://stream.example.com/prices")
if err then
    return nil, err
end

local subscribe_payload, encode_err = json.encode({
    action = "subscribe",
    symbols = {"BTC-USD", "ETH-USD"}
})
if encode_err then
    client:close()
    return nil, encode_err
end
client:send(subscribe_payload)

local ch, channel_err = client:channel()
if channel_err then
    client:close()
    return nil, channel_err
end

local heartbeat, heartbeat_err = time.after("30s")
if heartbeat_err then
    client:close()
    return nil, heartbeat_err
end

while true do
    local r = channel.select {
        ch:case_receive(),
        heartbeat:case_receive()
    }

    if r.channel == heartbeat then
        client:ping()
        heartbeat, heartbeat_err = time.after("30s")
        if heartbeat_err then
            client:close()
            return nil, heartbeat_err
        end
    elseif not r.ok then
        break  -- Connection closed
    else
        local price, decode_err = json.decode(r.value.data)
        if decode_err then
            client:close()
            return nil, decode_err
        end
        update_price(price.symbol, price.value)
    end
end

local _, close_err = client:close()
if close_err then return nil, close_err end

Permissions

WebSocket connections are evaluated against the active security policy.

Security Actions

Action Resource Description
websocket.connect - Allow/deny WebSocket connections
websocket.connect.url URL Allow/deny connections to specific URLs

See Security Model for policy configuration.

Errors

Condition Kind Retryable
Connections disabled errors.PERMISSION_DENIED no
URL not allowed errors.PERMISSION_DENIED no
No context errors.INTERNAL no
Connection failed errors.INTERNAL yes
Invalid connection ID returned by the dispatcher errors.INTERNAL no
Subscription failed errors.INTERNAL yes
Missing process context during subscription errors.INTERNAL no
Close failed errors.INTERNAL no

An empty URL, a non-table options value, invalid argument types, and a missing execution context or process PID when requesting the receive channel raise Lua errors. They are not returned as structured errors. Runtime v0.3.32a does not expose send or ping transport failures to Lua callers.

local client, err = websocket.connect(url)
if err then
    if errors.is(err, errors.PERMISSION_DENIED) then
        print("Access denied:", err:message())
    elseif err:retryable() then
        print("Temporary error:", err:message())
    end
    return nil, err
end

See Error Handling for working with errors.