Client Integration Guide
This section provides production-ready guides for connecting to the Signal WebSocket server. There are two separate client implementations — they share nothing with each other:
| Client | Language | Crypto | WebSocket | Guide |
|---|---|---|---|---|
| Web Browser | TypeScript/JavaScript | Web Crypto API (crypto.subtle) | window.WebSocket | Web Browser Guide |
| Tauri Android | Rust (main process) | p256 + aes-gcm + hkdf crates | tokio-tungstenite | Tauri Android Guide |
Also see: Cross-Service Integration & Testing
Connection Lifecycle
Steps in detail:
| # | Phase | What happens | Timeout |
|---|---|---|---|
| 1 | CONNECTING | new WebSocket(url) opens TCP connection | Browser default (~30s) |
| 2 | CONNECTED | onopen fires. Client generates ECDH P-256 key pair. | -- |
| 3 | AUTHENTICATING | Client sends authenticate with { type: "Bearer", token, clientPublicKey } | Server closes after 5 seconds if no authenticate received |
| 4 | ECDH Handshake | Server verifies JWT, generates its own ECDH key pair, derives AES-256-GCM key with random 32-byte HKDF salt | -- |
| 5 | AUTHENTICATED | Server sends connected { id, userId, time, serverPublicKey, salt }. Client imports server public key, derives same AES key using server's salt. | -- |
| 6 | Encrypted | All subsequent messages wrapped as { event: "encrypted", data: { iv, ct } }. Heartbeat every 30s (plaintext). | Heartbeat timeout: 90 seconds |
| 7 | Disconnect | onclose fires. AES key is deleted. Client can auto-reconnect (new ECDH key pair each time = forward secrecy). | -- |
Reconnection Behavior
When the WebSocket connection drops unexpectedly, the client automatically reconnects with forward secrecy:
| Step | What happens |
|---|---|
| 1 | Connection drops → onclose fires |
| 2 | cleanup() — delete AES key, ECDH key pair, stop heartbeat |
| 3 | Wait interval * backoffMultiplier^(retryCount-1) ms (exponential backoff) |
| 4 | connect() — generate new ECDH key pair (forward secrecy) |
| 5 | Open new WebSocket, send authenticate with same JWT token + new clientPublicKey |
| 6 | Server generates new server ECDH key pair, derives new AES key with new random salt |
| 7 | Client derives new AES key from new server public key + new salt |
| 8 | Auto-join configured rooms — rooms are re-subscribed automatically |
| 9 | retryCount resets to 0 on successful authentication |
Exponential backoff example (defaults: interval=3000, multiplier=1.5, max=30000):
| Retry # | Delay |
|---|---|
| 1 | 3,000 ms |
| 2 | 4,500 ms |
| 3 | 6,750 ms |
| 4 | 10,125 ms |
| 5 | 15,187 ms |
| 6+ | 30,000 ms (capped) |
After maxRetries (default: 5) consecutive failures, the client emits reconnect_failed and stops. The application can listen for this:
client.on('reconnect_failed', () => {
showToast('Connection lost. Please check your network and refresh.');
});Room-Based Messaging Patterns
Rooms enable targeted message delivery. The server encrypts messages per-client automatically via the outbound transformer.
| Room | Purpose | Joined by |
|---|---|---|
ws-default | General notifications | All authenticated clients |
ws-orders | Order status updates | POS clients, admin dashboard |
ws-notifications | Push notifications | All clients |
ws-payment | Payment events (success, fail, refund) | POS clients |
ws-test-payment | Payment testing | Development only |
Pattern: Subscribe on mount, unsubscribe on unmount
// Join rooms when the component mounts
useEffect(() => {
if (client?.isConnected()) {
client.joinRooms(['ws-orders']);
}
return () => {
client?.leaveRooms(['ws-orders']);
};
}, [client?.isConnected()]);Pattern: Auto-join via configuration
// Rooms specified at construction time are auto-joined on connect and re-joined on reconnect
const client = new EncryptedWebSocketClient({
url: '...',
ecdhInfo: '...',
token: '...',
autoJoinRooms: ['ws-default', 'ws-orders', 'ws-notifications'],
});REST API Examples
The REST API is used by backend services to send messages to WebSocket clients. The server handles encryption automatically — REST callers send plaintext, and the outbound transformer encrypts for each target client.
Check Server Status (no auth required)
curl http://localhost:3500/v1/api/socket/websocket/clients/status
# Response: { "isReady": true, "clientCount": 5 }List Connected Clients
curl http://localhost:3500/v1/api/socket/websocket/clients \
-H "Authorization: Bearer <jwt-token>"
# Response: { "clients": [{ "id": "abc", "state": "authenticated", "encrypted": true, ... }] }Broadcast to All Clients
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/broadcast \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt-token>" \
-d '{ "topic": "order.updated", "data": { "orderId": "12345" } }'Send to a Room
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/rooms/ws-orders/send \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt-token>" \
-d '{ "topic": "order.new", "data": { "orderId": "67890" } }'Send to a Specific Client
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/{clientId}/send \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt-token>" \
-d '{ "topic": "notification", "data": { "message": "Your order is ready" } }'Force Disconnect a Client
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/{clientId}/disconnect \
-H "Authorization: Bearer <jwt-token>"Debugging & Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
connected event received but no encryption | Server sent connected without serverPublicKey / salt | Check requireEncryption: true in NxWebSocketComponent. Ensure APP_ENV_WEBSOCKET_ECDH_INFO is set on the server. |
| Decryption fails after successful handshake | ECDH info mismatch between client and server | Verify VITE_SIGNAL_ECDH_INFO matches APP_ENV_WEBSOCKET_ECDH_INFO exactly (case-sensitive). |
| Connection closes immediately (code 4004) | Missing clientPublicKey in authenticate payload | Ensure generateECDHKeyPair() is called before connecting and publicKeyB64 is sent. |
| Connection closes after 5 seconds (code 4001) | Client didn't send authenticate in time | Check that onopen handler sends authenticate immediately. |
| Connection closes after 90 seconds | Missing heartbeat | Verify heartbeat is running every 30 seconds. Check startHeartbeat() is called after connected. |
WebSocket error with no details | Network issue, server down, or TLS error | Check server is running. For wss://, verify TLS certificate is valid. Check firewall/proxy rules. |
| Messages received but handlers not called | Event name mismatch | The decrypted inner payload has { event, data }. Verify the event name matches what the server sends (e.g., payment.success not payment-success). |
| Reconnect loop — authenticates then immediately disconnects | Expired JWT token | Call client.updateToken(newToken) with a fresh token before/during reconnect. |
Cannot send — encryption not established | Sending before ECDH handshake completes | Wait for state === 'authenticated' before calling send(). Use the connected event handler. |
Enable debug logging:
const client = new EncryptedWebSocketClient({
url: '...',
ecdhInfo: '...',
token: '...',
logger: {
info: (...args) => console.log('[Signal]', ...args),
warn: (...args) => console.warn('[Signal]', ...args),
error: (...args) => console.error('[Signal]', ...args),
debug: (...args) => console.debug('[Signal]', ...args),
},
});Security Considerations
| Rule | Detail |
|---|---|
| Never log AES keys | CryptoKey objects are non-extractable by design. Do not attempt to serialize or log them. |
| Never reuse ECDH key pairs | Generate a new key pair for every connection (including reconnects). This provides forward secrecy. |
| Never skip salt | Always use the server-provided salt from the connected event. Without it, the client and server derive different AES keys. |
| Token storage | Store JWT tokens securely. In browser: httpOnly cookies preferred, sessionStorage acceptable. Avoid localStorage for sensitive tokens. In Tauri: use the Tauri secure storage plugin or OS keychain. |
| Validate origin | The Signal server should validate WebSocket upgrade origins in production to prevent cross-site WebSocket hijacking. |
| TLS in production | Always use wss:// (WebSocket Secure) in production. Plain ws:// is acceptable only for local development. |
| Key cleanup on disconnect | EncryptedWebSocketClient.cleanup() sets aesKey = null and ecdhKeyPair = null on every disconnect. This ensures session keys cannot be used after disconnection. |
Important Notes Summary
| Topic | Detail |
|---|---|
| ECDH Info must match | The HKDF info string on the client must match APP_ENV_WEBSOCKET_ECDH_INFO on the server. Mismatched values produce different AES keys and decryption will fail. |
| HKDF Salt must match | The client must use the salt value from the connected event. The server generates a random 32-byte salt during deriveAESKey() and includes it in the handshake response. |
| JWT token required | Obtain a valid JWT from the Identity service (@nx/identity) before connecting. Authentication without a valid token is rejected. |
| Encryption is mandatory | After handshake, all application messages must use event: "encrypted". The outbound transformer encrypts all server-to-client messages automatically. |
| Forward secrecy | Each connection generates a new ephemeral ECDH key pair. Session keys are never reused across reconnections. |
| Heartbeat | Send { event: "heartbeat" } every 30 seconds to prevent idle timeout (default heartbeat timeout: 90 seconds). |
| Auth timeout | Clients have 5 seconds to send the authenticate event after connecting, or they are disconnected. |
| Plaintext events | authenticate, connected, error, heartbeat, join, leave are always sent in plaintext. All other events are encrypted. |
| Room re-join | After reconnection, rooms must be re-joined. The auto-join rooms configuration (autoJoinRooms in TypeScript, auto_join_rooms in Rust) handles this automatically. |
| Token refresh | Call client.updateToken(newToken) if the JWT is refreshed. The new token is used on the next reconnect. |