# "WebSocket Client"
_Path: en/lua/http/websocket_
> "WebSocket client for real-time bidirectional communication with servers."
## Table of Contents
- WebSocket Client
## Content
# 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
```lua
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`.
### `connect`
Open a WebSocket connection with the default options:
```lua
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:
```lua
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.
### Text Messages
Send a text message.
```lua
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`.
```lua
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.
```lua
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
```lua
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
```lua
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
```lua
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:
```lua
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.
### Message Types
```lua
-- 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
```lua
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 |
```lua
local _, close_err = client:close(websocket.CLOSE_CODES.NORMAL, "Done")
if close_err then return nil, close_err end
```
### Real-Time Chat
```lua
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
```lua
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](system/security.md) 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.
```lua
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](lua/core/errors.md) for working with errors.
## Navigation
Previous: "HTTP Client" (lua/http/client)
Next: "HTML Sanitization" (lua/http/html)