Real-time synchronization layer built on Ratchet 0.4.4 (PHP) + ReactPHP event loop. The WebSocket server runs as a separate daemon alongside the web app.
php artisan boost:add-skill nilsteampassnet/teampass
Save this content to: .cursor/rules/architecture-websocket.md
# WebSocket Architecture (feature/websockets)
Real-time synchronization layer built on **Ratchet 0.4.4** (PHP) + **ReactPHP** event loop. The WebSocket server runs as a **separate daemon** alongside the web app.
## Server Structure
```
/websocket/
bin/server.php — CLI entry point (daemon, handles SIGTERM/SIGINT)
src/WebSocketServer.php — Ratchet MessageComponent (onOpen/onMessage/onClose/onError)
src/ConnectionManager.php — Tracks connections + folder subscriptions (SplObjectStorage)
src/MessageHandler.php — Routes client actions to handlers
src/AuthValidator.php — Validates tokens/sessions on handshake
src/EventBroadcaster.php — Polls websocket_events table every 200ms, dispatches
src/RateLimiter.php — Sliding window: max 10 msgs/sec per connection
src/Logger.php — File-based logger (debug/info/warning/error)
config/websocket.php — Server settings
config/teampass-websocket.service — Systemd unit (user: www-data, restart: always)
```
**Default config** (`/websocket/config/websocket.php`):
- Host: `127.0.0.1` (behind reverse proxy) — Port: `8080`
- Poll interval: 200ms — Batch: 100 events/poll
- Ping: 30s — Pong timeout: 60s — Max connections/user: 5 — Max message: 64KB
**teampass_misc settings:**
| Setting | Default | Purpose |
|---|---|---|
| `websocket_enabled` | `0` | Enable/disable feature |
| `websocket_host` | `127.0.0.1` | Server host |
| `websocket_port` | `8080` | Server port |
## Client Structure
```
/includes/js/teampass-websocket.js — Core client library (auto-reconnect, pub/sub)
/includes/js/teampass-websocket-init.js — Event handlers + page integration
```
- **Connection URL**: `ws(s)://hostname/ws` (protocol auto-detected from page)
- **Auto-reconnect**: exponential backoff, max 30s, capped at 10 attempts
- **Heartbeat**: client-side ping every 25s
## Authentication (3 methods, in priority order)
1. **WebSocket token** — 64-char hex, generated by `generateWebSocketToken()` in `main.functions.php:~6638`, stored in `teampass_websocket_tokens`
2. **JWT token** — for API clients
3. **Session cookie** (`PHPSESSID`) — legacy fallback; `key_tempo` validated against DB
## Message Protocol
**Client → Server:**
```json
{ "action": "subscribe", "request_id": "req_xxxxx", "channel": "folder", "data": {"folder_id": 123} }
```
**Server → Client:**
```json
{ "type": "event", "event": "item_updated", "data": {...}, "request_id": "req_xxxxx", "timestamp": 1234567890 }
```
**Client actions:**
| Action | Data | Rate limited |
|---|---|---|
| `subscribe` | `channel`, `folder_id` | Yes |
| `unsubscribe` | `channel`, `folder_id` | Yes |
| `ping` / `pong` | — | No |
| `get_status` | — | Yes |
| `get_stats` | — | Yes (admin only) |
| `renew_item_lock` | `item_id` | Yes |
## Synchronization Events
**Item events** (target: `folder`):
| Event | Triggered by | Key payload fields |
|---|---|---|
| `item_created` | New item saved | `item_id`, `folder_id`, `label`, `created_by` |
| `item_updated` | Item modified | `item_id`, `folder_id`, `label`, `updated_by` |
| `item_deleted` | Item deleted | `item_id`, `folder_id`, `label`, `deleted_by` |
| `item_copied` | Copy operation | `item_id`, `new_item_id`, `folder_id`, `copied_by` |
**Edition lock events** (target: `folder`):
| Event | Triggered by | Key payload fields |
|---|---|---|
| `item_edition_started` | User opens item for edit | `item_id`, `folder_id`, `user_login`, `user_id` |
| `item_edition_stopped` | Lock released or disconnects | `item_id`, `folder_id`, `user_login`, `reason` |
Locks stored in `teampass_items_edition` (`user_id`, `item_id`, `timestamp`). Released automatically on WebSocket disconnect.
**Folder events** (target: `broadcast`): `folder_created`, `folder_updated`, `folder_deleted`, `folder_permission_changed`
**User/task events:**
| Event | Target | Triggered by |
|---|---|---|
| `user_keys_ready` | `user` | Encryption key generation complete |
| `task_progress` | `user` | Long-running task heartbeat |
| `task_completed` | `user` | Background task finished |
| `session_expired` | `user` | Session timeout |
| `system_maintenance` | `broadcast` | Admin trigger |
## PHP Emission Helpers (`sources/main.functions.php`)
```php
// Low-level
emitWebSocketEvent(string $eventType, string $targetType, ?int $targetId, array $payload, ?int $excludeUserId = null): bool
// ~line 6570
// High-level wrappers (use these in sources/*.queries.php)
emitItemEvent(string $action, int $itemId, int $folderId, string $label, string $userLogin, ?int $excludeUserId = null): bool
// action: 'created' | 'updated' | 'deleted' ~line 6696
emitEditionLockEvent(string $action, int $itemId, int $folderId, string $userLogin, int $userId): bool
// action: 'started' | 'stopped' ~line 6728
emitFolderEvent(string $action, int $folderId, string $title, string $userLogin, ?int $parentId = null, ?int $excludeUserId = null): bool
// action: 'created' | 'updated' | 'deleted' ~line 6779
generateWebSocketToken(int $userId): string
// ~line 6638
```
**Rule: always call the high-level helpers** after any write on items/folders. Never insert into `teampass_websocket_events` directly.
## Database Tables
```sql
-- Event queue (polled every 200ms, cleaned after 24h)
teampass_websocket_events:
id, created_at, event_type VARCHAR(50), target_type ENUM('user','folder','broadcast'),
target_id INT, payload JSON, processed TINYINT, processed_at
-- Auth tokens (single-use, injected into page)
teampass_websocket_tokens:
id, user_id, token VARCHAR(64) UNIQUE, created_at, expires_at, used TINYINT
-- Connection log
teampass_websocket_connections:
id, user_id, resource_id VARCHAR(50), connected_at, disconnected_at, ip_address, user_agent
```
Tables created by `/install/upgrade_run_3.1.7.php`.
## End-to-End Event Flow
```
1. items.queries.php (case 'update_item'):
DB::update(...) → emitItemEvent('updated', $itemId, $folderId, $label, $userLogin, $excludeId)
2. emitItemEvent() → emitWebSocketEvent('item_updated', 'folder', $folderId, {...})
→ DB::insert(teampass_websocket_events, {processed: 0})
3. EventBroadcaster (daemon, every 200ms):
→ SELECT unprocessed events → broadcastToFolder($folderId, message)
→ UPDATE teampass_websocket_events SET processed=1
4. JS client:
→ tpWs.on('item_updated', handler) fires → UI refreshes
```
## Deployment / Upgrade
**After any change to `app/websocket/src/*`**, the WebSocket daemon must be restarted:
```bash
sudo systemctl restart teampass-websocket.service
```
Without a restart, the running process uses the old code. Clients may send new actions
(e.g., `start_item_view`) that will be silently ignored by the outdated server.
**Hard browser refresh recommended** when the application version number is unchanged between
deployments, because `teampass-websocket-init.js` is loaded with a version query string
(`?v=X.Y.Z`). If the version did not change, browsers may serve a cached copy of the old JS.
Real-time synchronization layer built on Ratchet 0.4.4 (PHP) + ReactPHP event loop. The WebSocket server runs as a separate daemon alongside the web app.
/websocket/
bin/server.php — CLI entry point (daemon, handles SIGTERM/SIGINT)
src/WebSocketServer.php — Ratchet MessageComponent (onOpen/onMessage/onClose/onError)
src/ConnectionManager.php — Tracks connections + folder subscriptions (SplObjectStorage)
src/MessageHandler.php — Routes client actions to handlers
src/AuthValidator.php — Validates tokens/sessions on handshake
src/EventBroadcaster.php — Polls websocket_events table every 200ms, dispatches
src/RateLimiter.php — Sliding window: max 10 msgs/sec per connection
src/Logger.php — File-based logger (debug/info/warning/error)
config/websocket.php — Server settings
config/teampass-websocket.service — Systemd unit (user: www-data, restart: always)
Default config (/websocket/config/websocket.php):
127.0.0.1 (behind reverse proxy) — Port: 8080teampass_misc settings:
| Setting | Default | Purpose |
|---|---|---|
websocket_enabled |
0 |
Enable/disable feature |
websocket_host |
127.0.0.1 |
Server host |
websocket_port |
8080 |
Server port |
/includes/js/teampass-websocket.js — Core client library (auto-reconnect, pub/sub)
/includes/js/teampass-websocket-init.js — Event handlers + page integration
ws(s)://hostname/ws (protocol auto-detected from page)generateWebSocketToken() in main.functions.php:~6638, stored in teampass_websocket_tokensPHPSESSID) — legacy fallback; key_tempo validated against DBClient → Server:
{ "action": "subscribe", "request_id": "req_xxxxx", "channel": "folder", "data": {"folder_id": 123} }
Server → Client:
{ "type": "event", "event": "item_updated", "data": {...}, "request_id": "req_xxxxx", "timestamp": 1234567890 }
Client actions:
| Action | Data | Rate limited |
|---|---|---|
subscribe |
channel, folder_id |
Yes |
unsubscribe |
channel, folder_id |
Yes |
ping / pong |
— | No |
get_status |
— | Yes |
get_stats |
— | Yes (admin only) |
renew_item_lock |
item_id |
Yes |
Item events (target: folder):
| Event | Triggered by | Key payload fields |
|---|---|---|
item_created |
New item saved | item_id, folder_id, label, created_by |
item_updated |
Item modified | item_id, folder_id, label, updated_by |
item_deleted |
Item deleted | item_id, folder_id, label, deleted_by |
item_copied |
Copy operation | item_id, new_item_id, folder_id, copied_by |
Edition lock events (target: folder):
| Event | Triggered by | Key payload fields |
|---|---|---|
item_edition_started |
User opens item for edit | item_id, folder_id, user_login, user_id |
item_edition_stopped |
Lock released or disconnects | item_id, folder_id, user_login, reason |
Locks stored in teampass_items_edition (user_id, item_id, timestamp). Released automatically on WebSocket disconnect.
Folder events (target: broadcast): folder_created, folder_updated, folder_deleted, folder_permission_changed
User/task events:
| Event | Target | Triggered by |
|---|---|---|
user_keys_ready |
user |
Encryption key generation complete |
task_progress |
user |
Long-running task heartbeat |
task_completed |
user |
Background task finished |
session_expired |
user |
Session timeout |
system_maintenance |
broadcast |
Admin trigger |
sources/main.functions.php)// Low-level
emitWebSocketEvent(string $eventType, string $targetType, ?int $targetId, array $payload, ?int $excludeUserId = null): bool
// ~line 6570
// High-level wrappers (use these in sources/*.queries.php)
emitItemEvent(string $action, int $itemId, int $folderId, string $label, string $userLogin, ?int $excludeUserId = null): bool
// action: 'created' | 'updated' | 'deleted' ~line 6696
emitEditionLockEvent(string $action, int $itemId, int $folderId, string $userLogin, int $userId): bool
// action: 'started' | 'stopped' ~line 6728
emitFolderEvent(string $action, int $folderId, string $title, string $userLogin, ?int $parentId = null, ?int $excludeUserId = null): bool
// action: 'created' | 'updated' | 'deleted' ~line 6779
generateWebSocketToken(int $userId): string
// ~line 6638
Rule: always call the high-level helpers after any write on items/folders. Never insert into teampass_websocket_events directly.
-- Event queue (polled every 200ms, cleaned after 24h)
teampass_websocket_events:
id, created_at, event_type VARCHAR(50), target_type ENUM('user','folder','broadcast'),
target_id INT, payload JSON, processed TINYINT, processed_at
-- Auth tokens (single-use, injected into page)
teampass_websocket_tokens:
id, user_id, token VARCHAR(64) UNIQUE, created_at, expires_at, used TINYINT
-- Connection log
teampass_websocket_connections:
id, user_id, resource_id VARCHAR(50), connected_at, disconnected_at, ip_address, user_agent
Tables created by /install/upgrade_run_3.1.7.php.
1. items.queries.php (case 'update_item'):
DB::update(...) → emitItemEvent('updated', $itemId, $folderId, $label, $userLogin, $excludeId)
2. emitItemEvent() → emitWebSocketEvent('item_updated', 'folder', $folderId, {...})
→ DB::insert(teampass_websocket_events, {processed: 0})
3. EventBroadcaster (daemon, every 200ms):
→ SELECT unprocessed events → broadcastToFolder($folderId, message)
→ UPDATE teampass_websocket_events SET processed=1
4. JS client:
→ tpWs.on('item_updated', handler) fires → UI refreshes
After any change to app/websocket/src/*, the WebSocket daemon must be restarted:
sudo systemctl restart teampass-websocket.service
Without a restart, the running process uses the old code. Clients may send new actions
(e.g., start_item_view) that will be silently ignored by the outdated server.
Hard browser refresh recommended when the application version number is unchanged between
deployments, because teampass-websocket-init.js is loaded with a version query string
(?v=X.Y.Z). If the version did not change, browsers may serve a cached copy of the old JS.
How can I help you explore Laravel packages today?