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_create and notification.after_create_many append one small row to plugin_tabre_socket_event; a failed insert is logged and never breaks the post.
  • server.php polls 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 to hello / event frames 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_select and preferably pcntl (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.php and open the database. It writes only data/tabre_socket.lock and data/tabre_socket_state.json (both 0600).

Setup

  1. Enable TabRe Socket in Admin → Plugins.
  2. 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.
  3. Keep Listen host 127.0.0.1 and choose a port (default 8091). The daemon only listens there; nginx exposes it.
  4. 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.
  5. 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.target
sudo systemctl daemon-reload
sudo systemctl enable --now tabre-socket
sudo journalctl -u tabre-socket -f

One 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

SettingDefaultMeaning
Public WebSocket URLemptyws:///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.1Keep loopback when nginx proxies; read at daemon start
Listen port (server)8091Read at daemon start
Extra allowed originsemptyComma-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)3600The open tab refreshes its token well before it expires
Maximum connections500Global limit
Maximum connections per member6One per tab is typical
Maximum connections per IP20Shared addresses still get a fair share
Keep queued events (hours)24The 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.signature over the site secret, valid for the configured lifetime. It travels in the Sec-WebSocket-Protocol header, never in a URL or access log. The daemon verifies signature and expiry, then reads the account's status directly (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 Origin is 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 textContent and 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:

EventDetail
tabre-socketone event: type: "event", kind, detail, actor_id, actor, topic_id, post_id, thread_id, title, body, url, at (no recipient id)
tabre-socket-openthe socket opened or reopened
tabre-socket-hellothe server's greeting, with the account's exact unread counts
tabre-socket-closethe 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 open

tabre-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

SymptomCheck
Admin → Realtime says Offlinesystemctl status tabre-socket, journalctl -u tabre-socket -n 50; the state file heartbeat is under 45 s old while running
Browser console: CSP errorPublic URL must be set; the plugin adds its origin to connect-src. A CSP added by nginx must allow it too
Close 401 / no connectionSession expired or token expired: reload the page. Account suspended also rejects
Close 403The 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 daemonThe 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 503A connection limit was reached; raise the limits or restart with more capacity
No events, socket openThe 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 failsThe 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.