Real-Time Hub (WebSockets & SSE)
bext ships a built-in real-time pub/sub hub that supports both Server-Sent Events (SSE) and WebSocket transports. No external message broker is required for single-instance deployments — the hub runs in-process with zero additional infrastructure. For horizontal scaling, an optional Redis relay synchronizes events across instances. This is a Pro feature.
Configuration
Enable and configure the hub in bext.config.toml:
[realtime]
enabled = true
max_connections = 10000 # 0 = unlimited
heartbeat_interval_ms = 30000 # ping/keepalive interval
replay_buffer_size = 1000 # events kept for Last-Event-ID catchup
# Optional: Redis relay for multi-instance sync
[realtime.redis]
url = "rediss://redis.internal:6379"
prefix = "bext:" # channel becomes "bext:hub:events"
Topic Wildcards
Topics use MQTT-style path separators (/) with two wildcard types:
| Pattern | Matches | Does NOT match |
|---|---|---|
app/deploy |
app/deploy (exact) |
app/restart |
app/* |
app/deploy, app/restart |
app/deploy/us-east |
app/# |
app/deploy, app/deploy/us-east, app/x/y/z |
system/deploy |
* matches exactly one path segment. # matches zero or more trailing segments and must be the last segment in the pattern.
WebSocket Protocol
Connect to wss://your-domain/_bext/ws and exchange JSON-framed messages.
Client-to-server messages:
// Subscribe to topics (supports wildcards)
{"type": "subscribe", "topics": ["chat/room-42", "notifications/#"]}
// Unsubscribe from specific topics
{"type": "unsubscribe", "topics": ["chat/room-42"]}
// Publish to a topic
{"type": "publish", "topic": "chat/room-42", "data": {"text": "hello"}}
// Respond to server heartbeat
{"type": "pong"}
Server-to-client messages:
// Subscription confirmed
{"type": "subscribed", "topics": ["chat/room-42", "notifications/#"]}
// Event delivery
{"type": "event", "topic": "chat/room-42", "data": {"text": "hello"}, "id": 571}
// Heartbeat (client must reply with pong)
{"type": "ping"}
// Error (e.g. max connections reached, auth failure)
{"type": "error", "message": "max connections reached"}
Client Example (JavaScript)
const ws = new WebSocket("wss://app.example.com/_bext/ws");
ws.onopen = () => {
ws.send(JSON.stringify({
type: "subscribe",
topics: ["orders/#", "notifications/*"]
}));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "event") {
console.log(`[${msg.topic}]`, msg.data);
} else if (msg.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
}
};
Server-Sent Events (SSE)
For read-only subscribers (dashboards, live feeds), SSE is simpler than WebSockets:
GET /_bext/sse?topics=orders/#,notifications/*
Accept: text/event-stream
The response is a standard SSE stream:
event: orders/new
data: {"id":"ord_839","total":59.99}
id: 571
event: notifications/system
data: {"message":"deploy complete"}
id: 572
Use the Last-Event-ID header on reconnect to replay missed events from the ring buffer:
const source = new EventSource("/_bext/sse?topics=orders/#");
source.onmessage = (e) => console.log(e.data);
// Browser automatically sends Last-Event-ID on reconnect
Authentication Hooks
The hub supports topic-level authorization with four policy types:
| Policy | Description |
|---|---|
public |
Anyone, including anonymous users |
authenticated |
Any user with a valid session/JWT |
role |
Only users with a specific role (e.g. admin) |
user_id |
Only a specific user by ID |
Policies are configured per topic pattern, with separate rules for subscribe and publish:
[[realtime.auth_rules]]
pattern = "system/#"
subscribe_policy = { type = "authenticated" }
publish_policy = { type = "role", value = "__system_internal__" }
[[realtime.auth_rules]]
pattern = "user/*/inbox"
subscribe_policy = { type = "user_id", value = "$user_id" }
publish_policy = { type = "authenticated" }
[[realtime.auth_rules]]
pattern = "broadcast/#"
subscribe_policy = { type = "public" }
publish_policy = { type = "role", value = "broadcaster" }
The first matching rule wins. Unmatched topics fall through to defaults: public subscribe, authenticated publish.
Connection Lifecycle
1. Connect -- client opens a WebSocket or SSE connection.
2. Subscribe -- client sends topic patterns; the hub registers a subscriber with a bounded 256-event channel.
3. Heartbeat -- the server sends ping frames at the configured interval. Clients that fail to respond within the pong timeout (10s by default) are disconnected.
4. Slow client eviction -- if a subscriber's channel fills (256 pending events), the hub drops the connection to prevent unbounded memory growth.
5. Disconnect -- on WebSocket close or SSE connection drop, the subscriber is automatically unregistered and all topic associations are cleaned up.
Redis Relay for Multi-Instance Sync
When running multiple bext instances behind a load balancer, events published on one instance need to reach subscribers on all instances. The Redis relay bridges local hub events to a shared Redis Pub/Sub channel.
Each instance generates a unique ID at startup (e.g. bext-12345-789012) and tags outbound messages. Inbound messages from the same instance ID are silently dropped to prevent echo loops. The relay reconnects automatically with exponential backoff (100ms to 30s) on Redis connection failures.
[realtime.redis]
url = "rediss://redis.internal:6379" # Use rediss:// for TLS
prefix = "myapp:"
Monitoring
The hub exposes live statistics at /_bext/admin/realtime/stats:
{
"active_connections": 342,
"total_published": 18492,
"total_delivered": 847201,
"topic_count": 27,
"subscriber_count": 342,
"uptime_secs": 86412.5
}
These metrics are also available in Prometheus format at /_bext/metrics.