🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
291 lines
11 KiB
Plaintext
Executable File
291 lines
11 KiB
Plaintext
Executable File
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
|