Important
This project is still under development. Do NOT use in production. Currently only the new Contesting supports this which is also still in development (wavelog/wavelog#3063)
A generic WebSocket pub/sub broker written in Go. It receives events from PHP and forwards them to connected browsers. It has no knowledge of Wavelog-specific logic — topics, payloads, and their meaning are irrelevant to the Worker.
Documentation: https://docs.wavelog.org/wavelog-worker/
PHP (Wavelog)
│
│ POST /internal/publish {topic, payload}
▼
Worker :9001 (internal port, accessible by PHP only)
│
│ WebSocket push {type:"push", payload}
▼
Browser :9000/ws?topic=<topic>
PHP publishes an event → Worker immediately broadcasts it to all browsers subscribed to that topic. That's it. The Worker polls nothing, caches nothing, and knows nothing about the database schema.
Two independent authentication layers:
All requests from PHP to the internal port :9001 are secured with the X-Worker-Secret header. This is a static shared secret (min. 32 characters) configured identically on both sides.
The internal port :9001 must never be publicly accessible (firewall/Docker network).
The browser connects via WebSocket on port :9000. The connection is initially open — but the first frame must be an authentication frame:
{"type": "auth", "token": "<hmac-token>"}If the Worker responds with {"type": "auth_ok"}, the connection is authenticated. On invalid token: {"type": "error", "code": "unauthorized"} and immediate disconnect.
The HMAC token is generated by PHP (never by the browser itself) and contains:
{"user_id": 1, "session_id": 42, "expires": 1748700000}This JSON is hex-encoded and signed with HMAC-SHA256:
hex(json(claims)).hex(hmac-sha256(hex(claims), secret))
The Worker verifies the signature and expiry locally — no database query or HTTP callback to PHP required. The shared secret is sufficient.
Before a browser can connect to a topic, PHP must register it with the Worker. This prevents arbitrary topics from being subscribed to.
POST :9001/internal/register
X-Worker-Secret: <secret>
Content-Type: application/json
{"topic": "session.42", "meta": {"require_token": true}}
| Field | Description |
|---|---|
topic |
Freely chosen string, e.g. session.42, alerts.hb9hil |
require_token |
true: browser must present a valid HMAC token. false: connection without token allowed (e.g. public feeds) |
The topic registry is in-memory — all registered topics are lost on Worker restart. PHP re-registers topics as needed (e.g. on the next page load or when a publish returns 404).
Unregistration (optional, e.g. on session end):
POST :9001/internal/unregister
X-Worker-Secret: <secret>
Content-Type: application/json
{"topic": "session.42"}
POST :9001/internal/publish
X-Worker-Secret: <secret>
Content-Type: application/json
{"topic": "session.42", "payload": {"event": "qso_updated"}}
- Topic must be registered, otherwise:
404 topic not registered - On 404, PHP re-registers the topic and retries the publish once
payloadis arbitrary JSON — the Worker forwards it unchanged
GET :9001/internal/status
X-Worker-Secret: <secret>
Response (single instance):
{
"status": "ok",
"version": "0.1.0",
"uptime": "3h22m",
"registered_topics": 2,
"active_topics": 1,
"connected_clients": 3,
"topic_list": ["session.42", "session.7"],
"cluster_nodes": -1
}cluster_nodes is -1 when Redis clustering is disabled. In cluster mode it returns the number of Worker instances currently subscribed to the Redis Pub/Sub channel (including this one).
By default each Worker instance is standalone. For high-availability or horizontal scaling, multiple instances can be connected via Redis Pub/Sub so that a publish to any one node is fan-out to all browsers across all nodes.
PHP
│ POST /internal/publish
▼
Worker-1 :9001 ──── Redis (wavelog:events channel) ────► Worker-2 :9001
│ │
│ WebSocket WebSocket
▼ ▼
Browsers on node 1 Browsers on node 2
A publish to Worker-1 is forwarded via Redis Pub/Sub to Worker-2, which then broadcasts it to its own connected browsers. An origin_id in the envelope prevents double-delivery on the originating node.
Add redis_url to config.yaml:
ws_port: 9000
internal_port: 9001
worker_secret: ""
redis_url: "redis://localhost:6379/2" # DB 2 — avoid collision with other Redis usersThe Redis URL follows the standard redis://[user:pass@]host:port/db format. Leave it empty (or omit the key) for single-instance mode.
{
"cluster_nodes": 3
}cluster_nodes reflects the number of active subscribers on the Redis channel at query time. This is the authoritative live count — no separate discovery infrastructure needed.
// Enable or disable the Worker integration entirely.
$config['worker_enabled'] = true;
// Internal URLs of wavelog_worker instances (PHP -> Worker, HTTP).
// Single instance: one entry. Cluster: one entry per node.
// PHP publishes to the first entry; the debug page shows status of all nodes.
$config['worker_urls'] = [
'http://127.0.0.1:9001',
// 'http://127.0.0.1:9011', // second node in cluster mode
];
// Shared secret — must match worker_secret in config.yaml.
// Generate with: openssl rand -hex 32
$config['worker_secret'] = '<min. 32 characters>';
// Timeout for publish calls in seconds (float). Keep it short:
// a slow worker must not block QSO saves.
$config['worker_timeout'] = 1.0;
// Public WebSocket URL for the browser (Browser -> Worker).
// May differ from worker_urls when behind a reverse proxy.
// Format: ws://host:port or wss://host:port. Empty = no WebSocket in browser.
$config['worker_client_url'] = 'wss://example.org:9000';worker_enabled = false disables all Worker calls without requiring URL/secret removal. Useful for temporarily disabling the feature without losing the configuration.
PHP always publishes to the first entry in worker_urls. In cluster mode Redis handles the fan-out to other nodes — PHP does not need to know all node URLs for publishing. The Wavelog debug page queries all configured URLs individually to show a per-node status overview.
Provides three public methods:
$this->load->library('Worker_publisher');
// Register topic (on session creation / page load)
$this->worker_publisher->register_topic('session.42');
// Publish event (after any relevant change)
$this->worker_publisher->publish('session.42', ['event' => 'qso_updated']);
// Unregister topic (when session is deleted)
$this->worker_publisher->unregister_topic('session.42');All three methods are fire-and-forget — a PHP operation never fails because of the Worker. If the Worker is not configured or worker_enabled is false, all methods are no-ops.
Errors are not silently swallowed: cURL errors (timeout, connection refused) and unexpected HTTP status codes are written to the CodeIgniter log via log_message('error', ...). This makes an unreachable Worker visible in the application log without interrupting the request flow.
Exception: a 404 on publish() is not an error — it means the Worker lost the topic after a restart. PHP automatically re-registers the topic and retries the publish once.
// In Paths.php — generates a signed token for the browser
$token = $this->paths->create_worker_token($session_id);
// Expiry: 24h, contains user_id + session_idThis token is passed to the browser via the PHP view (as a JS variable) and used during the WebSocket handshake.
ws_port: 9000 # browser-facing (public, optionally behind reverse proxy)
internal_port: 9001 # PHP-facing (internal only!)
worker_secret: "" # min. 32 characters, generate with: openssl rand -hex 32
# Bind addresses (optional): restrict each listener to a specific IP.
# Empty or omitted = listen on all interfaces (0.0.0.0 + ::).
# Recommended: bind the internal port to localhost when PHP runs on the same host.
# ws_bind: "0.0.0.0"
# internal_bind: "127.0.0.1"
# Cluster mode (optional): connect multiple instances via Redis Pub/Sub.
# Leave empty or omit for single-instance mode.
# redis_url: "redis://localhost:6379/2"The Worker runs as its own container in the same Docker network as Wavelog. PHP reaches it via the internal container name; browsers connect through a reverse proxy.
wavelog-worker:
image: ghcr.io/wavelog/wavelog_worker:latest
ports:
- "9000:9000" # browser WebSocket (public)
# do NOT publish port 9001 — internal only!Run multiple Worker containers sharing the same Redis instance. All containers use the same config.yaml; only the ports differ on the host side. The internal port 9001 is not published — PHP and the debug page reach nodes via their container names.
wavelog-worker-1:
image: ghcr.io/wavelog/wavelog_worker:latest
ports:
- "9000:9000" # browser WebSocket (VIP / load balancer target)
wavelog-worker-2:
image: ghcr.io/wavelog/wavelog_worker:latest
# no host port needed — internal traffic only
wavelog-worker-3:
image: ghcr.io/wavelog/wavelog_worker:latestIn worker.php, list all node URLs so the debug page can show individual node status:
$config['worker_urls'] = [
'http://wavelog-worker-1:9001',
'http://wavelog-worker-2:9001',
'http://wavelog-worker-3:9001',
];PHP publishes to the first URL. Redis distributes the event to the other nodes.
./wavelog_worker -config /etc/wavelog_worker/config.yamlRestrict the internal port to localhost. The simplest way is to bind it directly in config.yaml:
internal_bind: "127.0.0.1"Alternatively (or additionally) enforce it via firewall:
iptables -A INPUT -p tcp --dport 9001 ! -s 127.0.0.1 -j DROPThe Worker loses all registered topics on restart (in-memory). This is not a problem because:
publish()returns404when the topic is unknown- PHP automatically re-registers the topic and retries the publish
- Browsers reconnect via WebSocket backoff — once the topic is re-registered, auth works again
No persistent storage needed. PHP is the source of truth.
1. PHP: register_topic("session.42")
→ POST :9001/internal/register
→ Worker stores topic in memory
2. PHP passes worker_client_url + HMAC token to browser (via view)
3. Browser: new WebSocket("wss://example.org:9000/ws?topic=session.42")
→ sends {"type":"auth","token":"<hmac>"}
→ Worker: signature ok, topic known, session_id matches
→ Worker: {"type":"auth_ok"}
→ browser is subscribed
4. PHP: something changes (QSO saved, etc.)
→ publish("session.42", {"event":"qso_updated"})
→ POST :9001/internal/publish
→ Worker broadcasts {"type":"push","payload":{"event":"qso_updated"}}
to all subscribed browsers
In cluster mode: Worker also publishes to Redis → other nodes broadcast
to their own connected browsers.
5. Browser receives push → triggers AJAX request to PHP → UI updates
6. PHP: unregister_topic("session.42") when session ends
→ POST :9001/internal/unregister
→ Worker removes topic
Step 5 is intentional: the Worker carries only the signal, not the data. The browser always fetches current data directly from PHP via AJAX. The Worker needs no knowledge of Wavelog data.