Skip to content

Hướng dẫn tích hợp Client

Phần này cung cấp hướng dẫn production-ready để kết nối đến Signal WebSocket server. Có hai loại client riêng biệt — chúng không chia sẻ bất kỳ code nào với nhau:

ClientNgôn ngữCryptoWebSocketHướng dẫn
Web BrowserTypeScript/JavaScriptWeb Crypto API (crypto.subtle)window.WebSocketWeb Browser Guide
Tauri AndroidRust (main process)p256 + aes-gcm + hkdf cratestokio-tungsteniteTauri Android Guide

Xem thêm: Tích hợp liên dịch vụ & Kiểm thử

Vòng đời kết nối

Các bước chi tiết:

#Giai đoạnMô tảTimeout
1CONNECTINGnew WebSocket(url) mở kết nối TCPMặc định trình duyệt (~30s)
2CONNECTEDonopen kích hoạt. Client tạo cặp khóa ECDH P-256.--
3AUTHENTICATINGClient gửi authenticate với { type: "Bearer", token, clientPublicKey }Server đóng sau 5 giây nếu không nhận authenticate
4ECDH HandshakeServer xác thực JWT, tạo cặp khóa ECDH riêng, dẫn xuất khóa AES-256-GCM với HKDF salt ngẫu nhiên 32 bytes--
5AUTHENTICATEDServer gửi connected { id, userId, time, serverPublicKey, salt }. Client import public key server, dẫn xuất cùng khóa AES sử dụng salt của server.--
6EncryptedTất cả tin nhắn tiếp theo bọc trong { event: "encrypted", data: { iv, ct } }. Heartbeat mỗi 30s (plaintext).Heartbeat timeout: 90 giây
7Disconnectonclose kích hoạt. Khóa AES bị xóa. Client có thể tự động kết nối lại (cặp khóa ECDH mới mỗi lần = forward secrecy).--

Hành vi kết nối lại

Khi kết nối WebSocket rớt bất ngờ, client tự động kết nối lại với forward secrecy:

BướcMô tả
1Kết nối rớt → onclose kích hoạt
2cleanup() — xóa khóa AES, cặp khóa ECDH, dừng heartbeat
3Chờ interval * backoffMultiplier^(retryCount-1) ms (exponential backoff)
4connect() — tạo cặp khóa ECDH mới (forward secrecy)
5Mở WebSocket mới, gửi authenticate với cùng JWT token + clientPublicKey mới
6Server tạo cặp khóa ECDH mới, dẫn xuất khóa AES mới với salt ngẫu nhiên mới
7Client dẫn xuất khóa AES mới từ public key server mới + salt mới
8Auto-join các phòng đã cấu hình — các phòng tự động được đăng ký lại
9retryCount reset về 0 khi xác thực thành công

Ví dụ exponential backoff (mặc định: interval=3000, multiplier=1.5, max=30000):

Lần thử #Độ trễ
13.000 ms
24.500 ms
36.750 ms
410.125 ms
515.187 ms
6+30.000 ms (giới hạn)

Sau maxRetries (mặc định: 5) lần thất bại liên tiếp, client emit reconnect_failed và dừng. Ứng dụng có thể lắng nghe:

typescript
client.on('reconnect_failed', () => {
  showToast('Mất kết nối. Vui lòng kiểm tra mạng và làm mới trang.');
});

Mẫu nhắn tin theo phòng

Phòng cho phép gửi tin nhắn có mục tiêu. Server tự động mã hóa tin nhắn cho từng client qua outbound transformer.

PhòngMục đíchAi tham gia
ws-defaultThông báo chungTất cả client đã xác thực
ws-ordersCập nhật trạng thái đơn hàngPOS client, admin dashboard
ws-notificationsPush notificationsTất cả client
ws-paymentSự kiện thanh toán (thành công, thất bại, hoàn tiền)POS client
ws-test-paymentKiểm thử thanh toánChỉ development

Mẫu: Đăng ký khi mount, hủy khi unmount

typescript
// Join rooms when the component mounts
useEffect(() => {
  if (client?.isConnected()) {
    client.joinRooms(['ws-orders']);
  }
  return () => {
    client?.leaveRooms(['ws-orders']);
  };
}, [client?.isConnected()]);

Mẫu: Auto-join qua cấu hình

typescript
// Các phòng chỉ định khi khởi tạo tự động join khi connect và re-join khi reconnect
const client = new EncryptedWebSocketClient({
  url: '...',
  ecdhInfo: '...',
  token: '...',
  autoJoinRooms: ['ws-default', 'ws-orders', 'ws-notifications'],
});

Ví dụ REST API

REST API được các dịch vụ backend sử dụng để gửi tin nhắn đến WebSocket client. Server xử lý mã hóa tự động — người gọi REST gửi plaintext, outbound transformer mã hóa cho từng client đích.

Kiểm tra trạng thái Server (không cần xác thực)

bash
curl http://localhost:3500/v1/api/socket/websocket/clients/status

# Phản hồi: { "isReady": true, "clientCount": 5 }

Liệt kê Client đang kết nối

bash
curl http://localhost:3500/v1/api/socket/websocket/clients \
  -H "Authorization: Bearer <jwt-token>"

# Phản hồi: { "clients": [{ "id": "abc", "state": "authenticated", "encrypted": true, ... }] }

Broadcast đến tất cả Client

bash
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" } }'

Gửi đến một phòng

bash
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" } }'

Gửi đến Client cụ thể

bash
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": "Đơn hàng của bạn đã sẵn sàng" } }'

Buộc ngắt kết nối Client

bash
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/{clientId}/disconnect \
  -H "Authorization: Bearer <jwt-token>"

Gỡ lỗi & Xử lý sự cố

Triệu chứngNguyên nhânCách sửa
Nhận connected nhưng không mã hóaServer gửi connected không có serverPublicKey / saltKiểm tra requireEncryption: true trong NxWebSocketComponent. Đảm bảo APP_ENV_WEBSOCKET_ECDH_INFO được đặt trên server.
Giải mã thất bại sau handshake thành côngECDH info không khớp giữa client và serverXác nhận VITE_SIGNAL_ECDH_INFO khớp chính xác APP_ENV_WEBSOCKET_ECDH_INFO (phân biệt hoa thường).
Kết nối đóng ngay (code 4004)Thiếu clientPublicKey trong authenticate payloadĐảm bảo generateECDHKeyPair() được gọi trước khi kết nối và publicKeyB64 được gửi đi.
Kết nối đóng sau 5 giây (code 4001)Client không gửi authenticate kịpKiểm tra handler onopen gửi authenticate ngay lập tức.
Kết nối đóng sau 90 giâyThiếu heartbeatXác nhận heartbeat chạy mỗi 30 giây. Kiểm tra startHeartbeat() được gọi sau connected.
WebSocket error không chi tiếtLỗi mạng, server down, hoặc lỗi TLSKiểm tra server đang chạy. Với wss://, xác nhận chứng chỉ TLS hợp lệ. Kiểm tra firewall/proxy.
Nhận tin nhắn nhưng handler không gọiTên sự kiện không khớpPayload giải mã có { event, data }. Xác nhận tên event khớp server gửi (ví dụ: payment.success chứ không phải payment-success).
Vòng lặp reconnect — xác thực xong ngắt ngayJWT token hết hạnGọi client.updateToken(newToken) với token mới trước/trong reconnect.
Cannot send — encryption not establishedGửi trước khi ECDH handshake hoàn thànhĐợi state === 'authenticated' trước khi gọi send(). Dùng handler sự kiện connected.

Bật debug logging:

typescript
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),
  },
});

Lưu ý bảo mật

Quy tắcChi tiết
Không bao giờ log khóa AESĐối tượng CryptoKey không thể trích xuất (non-extractable) theo thiết kế. Không cố serialize hoặc log.
Không bao giờ tái sử dụng cặp khóa ECDHTạo cặp khóa mới cho mỗi kết nối (bao gồm reconnect). Đảm bảo forward secrecy.
Không bao giờ bỏ qua saltLuôn sử dụng salt từ server trong sự kiện connected. Không có nó, client và server dẫn xuất khóa AES khác nhau.
Lưu trữ tokenLưu JWT token an toàn. Browser: httpOnly cookies ưu tiên, sessionStorage chấp nhận được. Tránh localStorage cho token nhạy cảm. Tauri: dùng Tauri secure storage plugin hoặc OS keychain.
Xác thực originSignal server nên xác thực WebSocket upgrade origins trong production để ngăn cross-site WebSocket hijacking.
TLS trong productionLuôn dùng wss:// (WebSocket Secure) trong production. Plain ws:// chỉ chấp nhận cho development local.
Dọn dẹp khóa khi ngắt kết nốiEncryptedWebSocketClient.cleanup() đặt aesKey = nullecdhKeyPair = null mỗi lần ngắt kết nối. Khóa phiên không thể dùng sau ngắt kết nối.

Tổng kết lưu ý quan trọng

Chủ đềChi tiết
ECDH Info phải khớpChuỗi HKDF info trên client phải khớp APP_ENV_WEBSOCKET_ECDH_INFO trên server. Giá trị không khớp tạo khóa AES khác nhau và giải mã thất bại.
HKDF Salt phải khớpClient phải sử dụng giá trị salt từ sự kiện connected. Server tạo salt 32 bytes ngẫu nhiên trong deriveAESKey() và bao gồm trong phản hồi handshake.
Yêu cầu JWT tokenLấy JWT hợp lệ từ dịch vụ Identity (@nx/identity) trước khi kết nối. Xác thực không có token hợp lệ bị từ chối.
Mã hóa bắt buộcSau handshake, tất cả tin nhắn ứng dụng phải dùng event: "encrypted". Outbound transformer mã hóa tin nhắn server-to-client tự động.
Forward secrecyMỗi kết nối tạo cặp khóa ECDH tạm thời mới. Khóa phiên không bao giờ tái sử dụng qua các lần kết nối lại.
HeartbeatGửi { event: "heartbeat" } mỗi 30 giây để tránh timeout rảnh (mặc định heartbeat timeout: 90 giây).
Auth timeoutClient có 5 giây để gửi sự kiện authenticate sau khi kết nối, nếu không bị ngắt.
Sự kiện plaintextauthenticate, connected, error, heartbeat, join, leave luôn gửi plaintext. Tất cả sự kiện khác mã hóa.
Re-join phòngSau khi kết nối lại, phòng phải được join lại. Cấu hình auto-join rooms (autoJoinRooms trong TypeScript, auto_join_rooms trong Rust) xử lý tự động.
Refresh tokenGọi client.updateToken(newToken) nếu JWT được refresh. Token mới dùng cho lần reconnect tiếp theo.

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.