Live updates for signed-in members over one WebSocket: new topics, new replies, new notifications and new private messages arrive without a page reload. Plain PHP, no Node, no build step, no extra PHP extension.
How it works
browser ──wss──▶ nginx ──ws──▶ server.php (CLI daemon) ──reads──▶ plugin_tabre_socket_event
▲ ▲
└──────────────── event frames ────────────────────────────── web request appends one row- A web request never talks to a socket.
topic.after_save,post.after_save,notification.after_createandnotification.after_create_manyappend one small row toplugin_tabre_socket_event; a failed insert is logged and never breaks the post. server.phppolls the queue by id, fans public rows out to every open socket and private rows to the one member they belong to.- The browser reads
#tabre-socket-config, connects with['tabre_socket', token]and reacts tohello/eventframes with a toast, an exact badge refresh and a View or Reload action.
Only categories every visitor may read (is_hidden = 0, empty view_groups) are broadcast. A topic or reply in a restricted category never leaves the queue as a public event, so its title cannot leak to other members. Notifications and private messages stay private.
Requirements
- FlatBB 0.2.4+, PHP 8.1 CLI with
stream_socket_server,stream_selectand preferablypcntl(graceful stop). - A process supervisor (systemd) and a reverse proxy (nginx) if the site is on HTTPS. Browsers refuse
ws://from an HTTPS page. - The daemon runs as the same user as PHP-FPM and can read
data/config.phpand open the database. It writes onlydata/tabre_socket.lockanddata/tabre_socket_state.json(both 0600).
Setup
- Enable TabRe Socket in Admin → Plugins.
- Open its settings and set Public WebSocket URL. Either form works:
- a path,
/tabre-socket— the browser dials the domain it is already on, so the socket stays first-party. Use this when the forum answers on several domains; a browser that blocks cross-site WebSockets then still connects. - a full address,
wss://forum.example.com/tabre-socket— always dials that host.
Empty turns the live client off; the forum then works exactly as before.
- a path,
- Keep Listen host
127.0.0.1and choose a port (default8091). The daemon only listens there; nginx exposes it. - List every domain members use in Extra allowed origins (
https://forum.example.com, https://forum2.example.com). A full public URL allows its own host, so the list is only for the other domains; a public URL written as a path carries no host at all, so list them all. Restart the daemon after editing — the allowlist is read once at start. - Start the daemon as the web user and check Admin → Realtime for the heartbeat.
sudo -u www-data php /var/www/flatbb/plugins/tabre_socket/server.php--host and --port override the settings for one run. --tls-cert and --tls-key terminate TLS in the daemon itself (then point the URL at wss://host:port and open that port in the firewall); with nginx in front, leave TLS to nginx.
systemd
[Unit]
Description=FlatBB TabRe Socket
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/flatbb
ExecStart=/usr/bin/php /var/www/flatbb/plugins/tabre_socket/server.php
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now tabre-socket
sudo journalctl -u tabre-socket -fOne daemon per site: a lock file in data/ refuses a second one. The daemon stops itself within a minute after the plugin is disabled (a clean exit, so systemd does not restart it), and a restart picks up new code and the listen address/limits from settings. A lost database connection exits non-zero, so systemd starts a fresh process.
nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location = /tabre-socket {
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Sec-WebSocket-Protocol $http_sec_websocket_protocol;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}Sec-WebSocket-Protocol carries the short-lived token, so nginx must pass it through (nginx forwards request headers by default; the line above makes it explicit). Never put the token in a query string. If another proxy sits in front, make sure it does not strip Upgrade, Connection or Sec-WebSocket-Protocol.
The plugin adds the socket origin to the page's connect-src CSP by itself — both ws:// and wss:// of the current host when the public URL is written as a path. If a separate CSP is added by nginx, it must allow that origin too.
Settings
| Setting | Default | Meaning |
|---|---|---|
| Public WebSocket URL | empty | ws:///wss:// address, or a path such as /tabre-socket that dials the domain of the page. Empty = live client off |
| Listen host (server) | 127.0.0.1 | Keep loopback when nginx proxies; read at daemon start |
| Listen port (server) | 8091 | Read at daemon start |
| Extra allowed origins | empty | Comma-separated https://… origins; a full public URL adds its own host, a path-only URL adds none, so list all the domains |
| Token lifetime (seconds) | 3600 | The open tab refreshes its token well before it expires |
| Maximum connections | 500 | Global limit |
| Maximum connections per member | 6 | One per tab is typical |
| Maximum connections per IP | 20 | Shared addresses still get a fair share |
| Keep queued events (hours) | 24 | The daemon deletes older rows hourly (the cron job does the same) |
Changing the listen address, limits, token TTL or allowed origins takes effect on the next daemon start (systemctl restart tabre-socket). The public URL and CSP are read on every web request.
Security
- Authentication: an HMAC-SHA256 token
uid.exp.signatureover the site secret, valid for the configured lifetime. It travels in theSec-WebSocket-Protocolheader, never in a URL or access log. The daemon verifies signature and expiry, then reads the account'sstatusdirectly (a banned member cannot stay connected with a cached row). - Origin allowlist: the extra list plus, for a full public URL, its own host (http and https); a path-only URL contributes no host, so every domain of the forum has to be in the list. A missing
Originis accepted only from a loopback peer, which keeps local tests working. Forwarded IPs are trusted only when the proxy itself connects from loopback. - Limits: per-IP, per-member and global connection caps; a 5-second handshake deadline; 16 KB request head; masked client frames only; control frames ≤125 bytes; 30 s ping / 90 s timeout; 1 MB outbound buffer; 30 client data frames per connection before a policy close. Rejections are plain HTTP 400/401/403/426/503 so a failed handshake never opens a socket.
- Privacy: public events are built only for fully public categories; private rows are delivered to one member and the public payload has no recipient id. The client shows text with
textContentand navigates only to same-origin URLs. - Queue: events are best-effort. The daemon starts at the newest id, so a restart never replays old toasts; stale rows are pruned hourly.
For plugin authors
The client dispatches DOM events, so another plugin can follow the same stream without its own socket:
| Event | Detail |
|---|---|
tabre-socket | one event: type: "event", kind, detail, actor_id, actor, topic_id, post_id, thread_id, title, body, url, at (no recipient id) |
tabre-socket-open | the socket opened or reopened |
tabre-socket-hello | the server's greeting, with the account's exact unread counts |
tabre-socket-close | the socket closed; a retry is already scheduled |
document.addEventListener('tabre-socket', function (e) {
if (e.detail.kind === 'chat') { /* … */ }
});
window.tabreSocket.connected(); // true while the socket is opentabre-socket is cancelable: call e.preventDefault() when your plugin already shows that event (for example, the private-message window for the open conversation) and the built-in toast is skipped. Badges are still refreshed from the server, so the two never disagree.
The body field carries up to 600 characters (markdown already reduced to plain text by the sender's plugin). Events for a member only reach that member's sockets; the chat kind is public to every signed-in connection.
Troubleshooting
| Symptom | Check |
|---|---|
| Admin → Realtime says Offline | systemctl status tabre-socket, journalctl -u tabre-socket -n 50; the state file heartbeat is under 45 s old while running |
| Browser console: CSP error | Public URL must be set; the plugin adds its origin to connect-src. A CSP added by nginx must allow it too |
| Close 401 / no connection | Session expired or token expired: reload the page. Account suspended also rejects |
| Close 403 | The page's Origin is not in the allowlist: add it in settings and restart the daemon (the list is read at start). With a full public URL, its own host is already allowed |
Console says net::ERR_BLOCKED_BY_CLIENT, and the handshake never reaches nginx or the daemon | The browser blocked a cross-site WebSocket (tracking protection, an extension, or a strict cookie policy) because the socket host differs from the page's domain. Set the public URL to the path form /tabre-socket so every page dials its own domain |
| Close 503 | A connection limit was reached; raise the limits or restart with more capacity |
| No events, socket open | The daemon is not running, or the plugin was just disabled; check Realtime. The daemon itself prunes old queue rows, so nothing piles up |
Direct wss://host:8091 fails | The daemon needs --tls-cert/--tls-key for TLS. With nginx, use wss://host/tabre-socket and a ws:// upstream |
Data
One queue table, dropped on uninstall: plugin_tabre_socket_event. State and lock files live in data/ and are removed on uninstall. The admin page can clear the queue; events queued at that moment are lost by design.