Files
rspade_system/app/RSpade/man/realtime.txt

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