NAME realtime - WebSocket realtime notification system SYNOPSIS // PHP: Publish a notification Realtime::publish('Contact_Updated_Topic', ['id' => $contact->id]); // JavaScript: Subscribe in a component (auto-unsubscribes on stop) this.subscribe('Contact_Updated_Topic', {id: this.args.id}, (msg) => { this.reload(); }); // PHP: Define a topic with permission check class Contact_Updated_Topic extends Realtime_Topic_Abstract { public static function can_subscribe(array $filter = []): bool { return Permission::has_permission(User_Model::PERM_VIEW_DATA); } } DESCRIPTION The RSpade realtime system provides WebSocket-based notifications so the browser can react to server-side events without polling. Messages are NOTIFICATIONS ONLY. They indicate that something happened (e.g., "contact 5 was updated by user 3") and the client reacts by fetching fresh data through normal Ajax. Messages must NEVER contain confidential data — any authenticated user on a site who subscribes to a topic will receive the notification. Architecture: PHP (authority) Node.js (dumb relay) Browser --------------- -------------------- ------- publish() --> Redis pub/sub --> Node --> WebSocket connection_token() --> signed token -----------> WS auth subscribe_token() --> signed token -----------> WS subscribe can_subscribe() <-- called before issuing token PHP is the authority — issues HMAC-signed tokens, checks permissions, publishes events. The Node.js process is a stateless relay that validates token signatures, routes messages, and stores last-message for replay. It has zero business logic and zero database access. Site ID scoping: Every WebSocket connection is tagged with the user's site_id from their connection token. Messages published with a site_id only route to connections on that site. Messages never cross site boundaries. SETUP 1. Add to .env: REALTIME_ENABLED=true REALTIME_WS_PORT=6200 REALTIME_PUBLIC_URL=ws://localhost:6200 2. Start the Node.js server: node system/bin/realtime-server.js 3. The client auto-connects when REALTIME_ENABLED=true and the user is authenticated. No client-side configuration needed. Environment Variables: REALTIME_ENABLED Enable/disable the system (default: false) REALTIME_WS_PORT WebSocket server port (default: 6200) REALTIME_PUBLIC_URL URL clients connect to (default: ws://localhost:6200) The server reads APP_KEY from .env for HMAC token validation and REDIS_HOST/REDIS_PORT/REDIS_PASSWORD for pub/sub. TOPICS Topics are PHP classes that define who can subscribe to a channel of notifications. Place them in /rsx/lib/topics/. Creating a topic: // /rsx/lib/topics/Contact_Updated_Topic.php class Contact_Updated_Topic extends Realtime_Topic_Abstract { public static function can_subscribe(array $filter = []): bool { // Any authenticated user on the site can subscribe return true; } } Permission-restricted topic: class Admin_Alert_Topic extends Realtime_Topic_Abstract { public static function can_subscribe(array $filter = []): bool { return Permission::has_role(User_Model::ROLE_ADMIN); } } The can_subscribe() method runs in the context of the current user's session. Use Session::get_user_id(), Permission::has_permission(), etc. Topic naming convention: {Model_or_Feature}_{Event}_Topic Examples: Contact_Updated_Topic, Invoice_Created_Topic, Chat_Message_Topic PUBLISHING Publish from PHP controllers or services after an event occurs: #[Ajax_Endpoint] public static function save(Request $request, array $params = []) { $contact = Contact_Model::find($params['id']); $contact->name = $params['name']; $contact->save(); // Notify subscribers Realtime::publish('Contact_Updated_Topic', [ 'id' => $contact->id, 'updated_by' => Session::get_user_id(), ]); return ['success' => true]; } Publishing from scheduled tasks: #[Task('Process daily report')] public static function generate_report(Task_Instance $task, array $params = []) { // ... generate report ... Realtime::publish('Report_Ready_Topic', ['report_id' => $report->id]); } Publish is a no-op when REALTIME_ENABLED=false. Safe to leave publish calls in code regardless of whether realtime is enabled. Payload guidelines: - Include record IDs so clients can filter: ['id' => 5] - Include actor ID for "someone else changed this": ['updated_by' => 3] - NEVER include record data, field values, or PII - Keep payloads small — they are routing hints, not data SUBSCRIBING (JavaScript) In components — auto-unsubscribes when component is destroyed: class Contacts_View_Action extends Spa_Action { async on_load() { this.data.contact = await Contact_Model.fetch(this.args.id); } on_ready() { // Subscribe with filter — only get updates for this contact this.subscribe('Contact_Updated_Topic', {id: this.args.id}, (msg) => { this.reload(); }); } } Without filter — receive all messages for a topic: on_ready() { this.subscribe('Contact_Updated_Topic', (msg) => { // msg.id tells us which contact was updated this.reload(); }); } Outside components (e.g., in layouts or global code): const sub_id = await Rsx_Realtime.subscribe('Notification_Topic', (msg) => { show_notification(msg); }); // Manual unsubscribe when no longer needed Rsx_Realtime.unsubscribe(sub_id); Filter parameters: Filters do shallow key matching. A subscription with filter {id: 5} only receives messages where data.id === 5. Multiple filter keys are AND-ed: {id: 5, type: 'invoice'} matches only when both match. SERVER-SIDE FILTERING Filtering happens on the Node.js server, not in the browser. When you subscribe with a filter, only messages matching that filter are sent over the WebSocket. This is efficient — a page watching contact #5 does not receive traffic for contacts #1-#4 and #6-#10000. LAST-MESSAGE REPLAY When subscribing to a topic, the server sends the last message that matched the subscription (if any). This means if a contact was updated 5 minutes ago and you navigate to its view page, you immediately get the last update notification and can decide whether to refresh. This is useful for: - Catching updates that happened while navigating - Initial state sync without polling - "Last known update" indicators Replay messages have replay: true in the message object. The callback receives them identically to live messages. AUTO-RECONNECT The client automatically reconnects with exponential backoff: 1s, 2s, 4s, 8s, 16s, up to 30s max. On reconnect, all active subscriptions are re-established with fresh tokens. Component subscriptions survive reconnection — no special handling needed. The framework manages the full lifecycle. DEPLOYMENT CONSIDERATIONS Starting the server: # Development node system/bin/realtime-server.js # Production with systemd [Unit] Description=RSpade Realtime Server After=redis.service [Service] ExecStart=/usr/bin/node /var/www/html/system/bin/realtime-server.js Restart=always RestartSec=5 User=www-data [Install] WantedBy=multi-user.target Server restart: Clients auto-reconnect. No data loss — messages published while the server is down are not queued (Redis pub/sub is fire-and-forget), but clients will refresh data on next interaction. Multiple app servers: All PHP servers publish to the same Redis instance. A single Node.js process handles all WebSocket connections. Nginx reverse proxy for wss:// in production: location /ws { proxy_pass http://127.0.0.1:6200; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 86400; } With this config, set REALTIME_PUBLIC_URL=wss://yourdomain.com/ws Scaling: A single Node.js process comfortably handles thousands of concurrent WebSocket connections. The server logs connection and subscription counts every 60 seconds when connections are active. Monitoring: The server logs to stdout: [realtime] WebSocket server listening on port 6200 [realtime] Connected to Redis at 127.0.0.1:6379 [realtime] Connections: 42, Subscriptions: 156, Cached messages: 23 SECURITY Connection tokens: - HMAC-SHA256 signed with APP_KEY - 60-second expiry (only for initial handshake) - Contains user_id, site_id, session_id Subscribe tokens: - Per-topic permission check via can_subscribe() - HMAC-SHA256 signed with APP_KEY - 60-second expiry - Contains topic, filter, site_id Site ID scoping: - Connection tagged with site_id from token - Subscribe token site_id must match connection site_id - Messages only route to matching site_id connections - Messages NEVER cross site boundaries No confidential data: - Messages are notification hints, not data payloads - Client fetches fresh data through normal Ajax with normal auth - Topic payloads should contain only: IDs, action types, actor IDs API REFERENCE PHP: Realtime::is_enabled() Check if realtime is on Realtime::connection_token() Generate WS auth token Realtime::subscribe_token($topic, $filter) Generate subscribe token Realtime::publish($topic, $data) Publish to subscribers JavaScript: Rsx_Realtime.subscribe(topic, [filter], callback) Subscribe (returns sub_id) Rsx_Realtime.unsubscribe(sub_id) Unsubscribe this.subscribe(topic, [filter], callback) Component shorthand Topic class: Realtime_Topic_Abstract::can_subscribe($filter) Permission check (abstract) SEE ALSO rsx:man model_fetch rsx:man spa rsx:man session