realtime-systems/SKILL.md
Use when building real-time features — live dashboards, notifications, chat, collaborative editing, order tracking, or any feature requiring push updates from server to client. Covers WebSockets, SSE, multi-tenant channel isolation, and...
npx skillsauth add peterbamuhigire/skills-web-dev realtime-systemsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
realtime-systems or would be better handled by a more specific companion skill.SKILL.md first, then load only the referenced deep-dive files that are necessary for the task.Real-time features require the server to push data to clients without polling. Two primary patterns:
Core rule: Use SSE for dashboards and notifications. Use WebSockets only when the client must also send real-time data.
| Feature | Use | |---|---| | Live dashboard updates | SSE | | Push notifications | SSE | | Order/job status tracking | SSE | | Chat / messaging | WebSocket | | Collaborative document editing | WebSocket | | Live bidding / multiplayer | WebSocket | | Typing indicators | WebSocket |
// GET /api/v1/stream/dashboard?franchise_id=...
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // Nginx: disable buffering
$franchiseId = getSession('franchise_id'); // NEVER from query string
if (!$franchiseId) { http_response_code(401); exit; }
// Stream loop
while (true) {
$data = getDashboardSnapshot($franchiseId);
echo "id: " . time() . "\n";
echo "event: dashboard_update\n";
echo "data: " . json_encode($data) . "\n\n";
ob_flush(); flush();
if (connection_aborted()) break;
sleep(5); // Poll interval (replace with DB trigger listener for production)
}
class RealtimeStream {
constructor(endpoint) {
this.endpoint = endpoint;
this.reconnectDelay = 1000;
this.connect();
}
connect() {
this.source = new EventSource(this.endpoint);
this.source.addEventListener('dashboard_update', (e) => {
const data = JSON.parse(e.data);
this.onUpdate(data);
});
this.source.onerror = () => {
this.source.close();
// Exponential backoff: 1s → 2s → 4s → max 30s
setTimeout(() => this.connect(),
Math.min(this.reconnectDelay *= 2, 30000));
};
this.source.onopen = () => {
this.reconnectDelay = 1000; // Reset on success
};
}
onUpdate(data) {
document.dispatchEvent(new CustomEvent('dashboardData', { detail: data }));
}
disconnect() { this.source.close(); }
}
const stream = new RealtimeStream('/api/v1/stream/dashboard');
// composer require cboden/ratchet
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
class TenantAwareSocket implements MessageComponentInterface {
protected SplObjectStorage $clients;
protected array $channelMap = []; // connectionId => franchise_id
public function __construct() {
$this->clients = new SplObjectStorage();
}
public function onOpen(ConnectionInterface $conn) {
// Authenticate via query parameter token (JWT only — no session cookies over WS)
$query = $conn->httpRequest->getUri()->getQuery();
parse_str($query, $params);
$payload = verifyJwt($params['token'] ?? '');
if (!$payload) { $conn->close(); return; }
$this->clients->attach($conn);
$this->channelMap[$conn->resourceId] = $payload['franchise_id'];
}
public function onMessage(ConnectionInterface $from, $msg) {
$data = json_decode($msg, true);
$franchiseId = $this->channelMap[$from->resourceId] ?? null;
if (!$franchiseId) { $from->close(); return; }
// Validate action
$allowed = ['CURSOR_MOVE', 'DOC_EDIT', 'PRESENCE'];
if (!in_array($data['type'] ?? '', $allowed)) return;
// Broadcast only to same franchise
$this->broadcastToFranchise($franchiseId, $msg, $from);
}
public function onClose(ConnectionInterface $conn) {
$this->clients->detach($conn);
unset($this->channelMap[$conn->resourceId]);
}
public function onError(ConnectionInterface $conn, \Exception $e) {
$conn->close();
}
private function broadcastToFranchise(int $franchiseId, string $msg, ConnectionInterface $except): void {
foreach ($this->clients as $client) {
if ($client !== $except
&& ($this->channelMap[$client->resourceId] ?? null) === $franchiseId) {
$client->send($msg);
}
}
}
}
class RealtimeSocket {
constructor(wsUrl, token) {
this.wsUrl = `${wsUrl}?token=${token}`;
this.ws = null;
this.reconnectDelay = 1000;
this.handlers = {};
this.connect();
}
connect() {
this.ws = new WebSocket(this.wsUrl);
this.ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
(this.handlers[msg.type] || []).forEach(fn => fn(msg));
};
this.ws.onclose = () => {
setTimeout(() => this.connect(),
Math.min(this.reconnectDelay *= 2, 30000));
};
this.ws.onopen = () => {
this.reconnectDelay = 1000;
};
}
on(type, handler) {
(this.handlers[type] ??= []).push(handler);
}
send(type, payload) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, ...payload }));
}
}
}
// Usage
const socket = new RealtimeSocket('wss://app.example.com/ws', userJwt);
socket.on('DOC_EDIT', ({ userId, delta }) => applyDelta(delta));
socket.send('CURSOR_MOVE', { x: 150, y: 200 });
Critical rule: Clients must NEVER receive messages from other franchises.
Channel naming convention: {event_type}:{franchise_id}:{optional_resource_id}
Examples:
dashboard:42 → franchise 42 dashboard
order_status:42:1001 → franchise 42, order 1001
doc_collab:42:99 → franchise 42, document 99
Isolation checklist:
franchise_id from JWT/session — NEVER from client message payloadfranchise_id server-side in connection mapfranchise_id before sendingFor systems without a persistent WebSocket server, use a polling approach that reduces DB load:
// Long-poll endpoint: client waits up to 30s for new data
$lastId = (int) ($_GET['last_event_id'] ?? 0);
$deadline = time() + 30;
while (time() < $deadline) {
$stmt = $db->prepare('
SELECT * FROM realtime_events
WHERE franchise_id = ? AND id > ?
ORDER BY id ASC LIMIT 50
');
$stmt->execute([$franchiseId, $lastId]);
$events = $stmt->fetchAll();
if (!empty($events)) {
echo json_encode(['events' => $events]);
exit;
}
usleep(500_000); // 500ms poll interval
}
echo json_encode(['events' => []]); // Empty on timeout — client retries
CREATE TABLE realtime_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
franchise_id BIGINT UNSIGNED NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_franchise_id (franchise_id, id)
) ENGINE=InnoDB;
CREATE TABLE active_sessions (
session_key VARCHAR(100) PRIMARY KEY,
franchise_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
resource VARCHAR(100), -- e.g. 'document:99'
last_ping DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_franchise_resource (franchise_id, resource)
);
-- Clean up: DELETE FROM active_sessions WHERE last_ping < NOW() - INTERVAL 30 SECOND
For basic collaborative editing where conflicts are rare:
// Client: include document version with every edit
socket.send('DOC_EDIT', {
docId: 99,
version: localVersion, // Last known server version
delta: { op: 'insert', pos: 45, text: 'hello' }
});
// Server: reject if version mismatch — client must re-fetch and retry
if (msg.version !== currentVersion) {
from.send(JSON.stringify({ type: 'EDIT_REJECTED', docId: msg.docId }));
return;
}
# WebSocket proxy
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
# SSE proxy
location /api/v1/stream/ {
proxy_pass http://localhost:80;
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Accel-Buffering no;
}
| Anti-Pattern | Problem | Fix |
|---|---|---|
| franchise_id from client message | Tenant spoofing | Extract from JWT/session only |
| Broadcast to all connections | Data leak across tenants | Filter by franchise_id |
| No reconnection logic | Silent disconnects | Exponential backoff on client |
| Polling every 500ms | DB overload | SSE or long-poll with sleep |
| WebSocket for notifications | Unnecessary complexity | Use SSE |
| Unauthenticated WS connections | Anyone can connect | Auth check in onOpen, close if fails |
franchise_id server-side in connection/channel mapfranchise_id onlyrealtime_events table indexed on (franchise_id, id)data-ai
Use when adding AI-powered analytics to a SaaS platform — semantic search over business data, natural language queries, trend detection, anomaly alerts, and AI-generated insights for dashboards. Covers embeddings, NL2SQL, and per-tenant analytics...
data-ai
Design AI-powered analytics dashboards — what metrics to show, how to display AI predictions and confidence, drill-down patterns, KPI cards, trend visualisation, AI Insights panels, export design, and role-based dashboard variants. Invoke when...
development
Use when designing, building, reviewing, or upgrading production software systems that must be secure, performant, maintainable, scalable, and user-centered. Apply before writing specs, code, architecture, APIs, databases, mobile apps, SaaS platforms, or ERP systems.
development
Professional web app UI using commercial templates (Tabler/Bootstrap 5) with strong frontend design direction when needed. Use for CRUD interfaces, dashboards, admin panels with SweetAlert2, DataTables, Flatpickr. Clone seeder-page.php, use...