Skip to content

Web Browser Client

Tổng quan

TypeScript/JavaScript WebSocket client cho môi trường web browser sử dụng Web Crypto API (crypto.subtle). Hoạt động trên Chrome, Firefox, Edge, Safari, và tất cả các trình duyệt hiện đại.

  • Pure browser APIs -- Sử dụng Web Crypto API (crypto.subtle) và native WebSocket
  • Zero dependencies -- Không cần thư viện crypto bên ngoài
  • TypeScript-first -- An toàn kiểu đầy đủ với các interface được export
  • Auto-reconnect -- Exponential backoff với forward secrecy (cặp khóa ECDH mới mỗi kết nối)

Hai file:

FileMục đíchVị trí khuyến nghị
signal-crypto.tsTạo khóa ECDH P-256, mã hóa/giải mã AES-256-GCMsrc/socket/crypto/signal-crypto.ts
encrypted-ws-client.tsWebSocket client với quản lý toàn bộ vòng đờisrc/socket/encrypted-ws-client.ts

Dependencies

Không cần package bên ngoài. Các browser API được sử dụng:

APIMục đíchHỗ trợ
crypto.subtle (Web Crypto)Tạo khóa ECDH, mã hóa/giải mã AES-256-GCM, dẫn xuất khóa HKDFChrome 37+, Firefox 34+, Edge 12+, Safari 11+
WebSocketTruyền tải WebSocketTất cả trình duyệt hiện đại
TextEncoder / TextDecoderMã hóa chuỗi UTF-8Tất cả trình duyệt hiện đại
btoa / atobMã hóa/giải mã Base64Tất cả trình duyệt hiện đại

Crypto Module (signal-crypto.ts)

Các type và hàm chính:

ExportLoạiMô tả
IECDHKeyPairInterface{ keyPair: CryptoKeyPair; publicKeyB64: string }
generateECDHKeyPair()FunctionTạo cặp khóa ECDH P-256 tạm thời. Private key không thể trích xuất (non-extractable). Public key xuất dạng raw base64 (65 bytes).
deriveAESKey(opts)FunctionDẫn xuất khóa AES-256-GCM từ ECDH shared secret + HKDF-SHA-256. Yêu cầu clientPrivateKey, serverPublicKeyB64, saltB64, ecdhInfo.
IEncryptedPayloadInterface{ iv: string; ct: string } -- IV mã hóa base64 (12 bytes) và ciphertext (bao gồm auth tag 128-bit).
encrypt(opts)FunctionMã hóa chuỗi plaintext bằng AES-256-GCM. IV ngẫu nhiên 12 bytes mỗi tin nhắn.
decrypt(opts)FunctionGiải mã payload { iv, ct } về plaintext. Ném lỗi nếu auth tag không hợp lệ.

Luồng handshake:

1. generateECDHKeyPair()                → ephemeral P-256 key pair
2. Send publicKeyB64 in authenticate    → server receives client public key
3. Server sends { serverPublicKey, salt } → client receives in "connected" event
4. deriveAESKey(clientPriv, serverPub, salt, info) → HKDF-SHA-256 → AES-256-GCM key
5. encrypt(plaintext) / decrypt(payload)  → per-message encryption
Nhấn để xem source code đầy đủ
typescript
// ============================================================================
// signal-crypto.ts — ECDH P-256 + AES-256-GCM Crypto Module for Signal
// ============================================================================

// ---------- Base64 Utilities ----------

export function arrayBufferToBase64(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

export function base64ToArrayBuffer(base64: string): ArrayBuffer {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

// ---------- ECDH Key Pair ----------

export interface IECDHKeyPair {
  keyPair: CryptoKeyPair;
  publicKeyB64: string;
}

/**
 * Generate an ephemeral ECDH P-256 key pair.
 * Private key is non-extractable for security.
 * Public key is exported as raw base64 (65 bytes for uncompressed P-256).
 */
export async function generateECDHKeyPair(): Promise<IECDHKeyPair> {
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDH', namedCurve: 'P-256' },
    false,         // non-extractable private key
    ['deriveBits'],
  );

  const rawPublicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey);
  const publicKeyB64 = arrayBufferToBase64(rawPublicKey);

  return { keyPair, publicKeyB64 };
}

// ---------- AES Key Derivation ----------

/**
 * Derive an AES-256-GCM key from ECDH shared secret + HKDF-SHA-256.
 *
 * Server and client must use:
 * - Same ECDH shared secret (from own private key + peer's public key)
 * - Same HKDF salt (server generates, client receives in `connected` event)
 * - Same HKDF info string (must match `APP_ENV_WEBSOCKET_ECDH_INFO` on server)
 */
export async function deriveAESKey(opts: {
  clientPrivateKey: CryptoKey;
  serverPublicKeyB64: string;
  saltB64: string;
  ecdhInfo: string;
}): Promise<CryptoKey> {
  const { clientPrivateKey, serverPublicKeyB64, saltB64, ecdhInfo } = opts;

  // 1. Import server's raw public key
  const serverPublicKey = await crypto.subtle.importKey(
    'raw',
    base64ToArrayBuffer(serverPublicKeyB64),
    { name: 'ECDH', namedCurve: 'P-256' },
    false,
    [],
  );

  // 2. Derive 256 bits shared secret via ECDH
  const sharedBits = await crypto.subtle.deriveBits(
    { name: 'ECDH', public: serverPublicKey },
    clientPrivateKey,
    256,
  );

  // 3. Import shared bits as HKDF key material
  const hkdfKey = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, ['deriveKey']);

  // 4. Derive AES-256-GCM key via HKDF-SHA-256
  const salt = new Uint8Array(base64ToArrayBuffer(saltB64));
  const info = new TextEncoder().encode(ecdhInfo);

  const aesKey = await crypto.subtle.deriveKey(
    { name: 'HKDF', hash: 'SHA-256', salt, info },
    hkdfKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  );

  return aesKey;
}

// ---------- Encrypt / Decrypt ----------

export interface IEncryptedPayload {
  iv: string;   // 12-byte IV, base64-encoded
  ct: string;   // AES-GCM ciphertext, base64-encoded (includes 128-bit auth tag)
}

/** Encrypt a plaintext string with AES-256-GCM. Random 12-byte IV per message. */
export async function encrypt(opts: {
  plaintext: string;
  aesKey: CryptoKey;
}): Promise<IEncryptedPayload> {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(opts.plaintext);

  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    opts.aesKey,
    encoded,
  );

  return {
    iv: arrayBufferToBase64(iv.buffer),
    ct: arrayBufferToBase64(ciphertext),
  };
}

/** Decrypt a { iv, ct } payload back to plaintext. Throws if auth tag is invalid. */
export async function decrypt(opts: {
  payload: IEncryptedPayload;
  aesKey: CryptoKey;
}): Promise<string> {
  const iv = new Uint8Array(base64ToArrayBuffer(opts.payload.iv));
  const ciphertext = base64ToArrayBuffer(opts.payload.ct);

  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv },
    opts.aesKey,
    ciphertext,
  );

  return new TextDecoder().decode(decrypted);
}

WebSocket Client (EncryptedWebSocketClient)

WebSocket client TypeScript production-ready. Thay thế WebSocketClientHelper cũ. Quản lý toàn bộ vòng đời kết nối bao gồm ECDH handshake, mã hóa/giải mã tự động, heartbeat, phòng, và kết nối lại với forward secrecy.

Tính năng chính:

Tính năngMô tả
Forward secrecyCặp khóa ECDH mới cho mỗi kết nối (bao gồm kết nối lại)
Auto-reconnectExponential backoff với maxRetries có thể cấu hình
HeartbeatGửi { event: "heartbeat" } mỗi 30 giây qua setInterval
Auto-join roomsTự động tham gia phòng sau khi xác thực thành công
Event systemHandler on()/off() cho sự kiện đã giải mã + callback thay đổi trạng thái

Public API:

typescript
class EncryptedWebSocketClient {
  // Lifecycle
  async connect(): Promise<void>;
  disconnect(): void;
  destroy(): void;

  // Messaging
  async send(event: string, data?: unknown): Promise<void>;

  // Rooms
  joinRooms(rooms: string[]): void;
  leaveRooms(rooms: string[]): void;

  // Token
  updateToken(token: string): void;

  // Events
  on<T>(event: string, handler: TEventHandler<T>): void;
  off<T>(event: string, handler: TEventHandler<T>): void;
  offAll(event: string): void;
  onStateChange(handler: TStateChangeHandler): void;

  // Getters
  getState(): TConnectionState;
  getClientId(): string | null;
  getUserId(): string | null;
  isEncrypted(): boolean;
  isConnected(): boolean;
}

Cấu hình (IEncryptedWSClientOptions):

typescript
interface IEncryptedWSClientOptions {
  url: string;              // WebSocket server URL
  ecdhInfo: string;         // HKDF info string (must match server)
  token: string;            // JWT Bearer token from @nx/identity
  reconnect?: {
    enabled?: boolean;       // Default: true
    maxRetries?: number;     // Default: 5
    interval?: number;       // Default: 3000 (ms)
    backoffMultiplier?: number; // Default: 1.5
    maxInterval?: number;    // Default: 30000 (ms)
  };
  heartbeatInterval?: number; // Default: 30000 (ms)
  autoJoinRooms?: string[];   // Auto-join after auth, re-join on reconnect
  logger?: IWSLogger;         // Default: console
}
Nhấn để xem source code đầy đủ
typescript
// ============================================================================
// encrypted-ws-client.ts — Production WebSocket Client with ECDH Encryption
// ============================================================================

import {
  type IECDHKeyPair,
  type IEncryptedPayload,
  generateECDHKeyPair,
  deriveAESKey,
  encrypt,
  decrypt,
} from './crypto/signal-crypto';

// ---------- Types ----------

export type TConnectionState =
  | 'disconnected'
  | 'connecting'
  | 'connected'
  | 'authenticating'
  | 'authenticated'
  | 'reconnecting';

export interface IEncryptedWSClientOptions {
  /** WebSocket server URL, e.g. 'wss://signal.example.com/stream' */
  url: string;
  /** HKDF info string — must match APP_ENV_WEBSOCKET_ECDH_INFO on the server exactly */
  ecdhInfo: string;
  /** JWT Bearer token from @nx/identity */
  token: string;
  /** Reconnection configuration */
  reconnect?: {
    enabled?: boolean;       // Auto-reconnect on unexpected close. Default: true
    maxRetries?: number;     // Maximum retry attempts. Default: 5
    interval?: number;       // Base interval between retries (ms). Default: 3000
    backoffMultiplier?: number; // Exponential backoff multiplier. Default: 1.5
    maxInterval?: number;    // Maximum interval cap (ms). Default: 30000
  };
  /** Heartbeat interval (ms). Default: 30000 (30s). Server timeout is 90s. */
  heartbeatInterval?: number;
  /** Rooms to auto-join after authentication. Auto re-joined on reconnect. */
  autoJoinRooms?: string[];
  /** Logger interface. Default: console */
  logger?: IWSLogger;
}

export interface IWSLogger {
  info(message: string, ...args: unknown[]): void;
  warn(message: string, ...args: unknown[]): void;
  error(message: string, ...args: unknown[]): void;
  debug(message: string, ...args: unknown[]): void;
}

export type TEventHandler<T = unknown> = (data: T) => void;
export type TStateChangeHandler = (state: TConnectionState) => void;

/** Control-plane events — always sent in plaintext (never encrypted) */
const PLAINTEXT_EVENTS = new Set([
  'authenticate', 'connected', 'error', 'heartbeat', 'join', 'leave',
]);

// ---------- Class ----------

export class EncryptedWebSocketClient {
  private ws: WebSocket | null = null;

  // ECDH state — new key pair per connection (forward secrecy)
  private ecdhKeyPair: IECDHKeyPair | null = null;
  private aesKey: CryptoKey | null = null;

  // Connection state
  private state: TConnectionState = 'disconnected';
  private clientId: string | null = null;
  private userId: string | null = null;

  // Reconnection state
  private retryCount: number = 0;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private forcedClose: boolean = false;

  // Heartbeat
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;

  // Event system
  private eventHandlers: Map<string, Set<TEventHandler>> = new Map();
  private stateChangeHandlers: Set<TStateChangeHandler> = new Set();

  // Options (see IEncryptedWSClientOptions)
  private readonly url: string;
  private readonly ecdhInfo: string;
  private token: string;
  private readonly reconnectEnabled: boolean;
  private readonly maxRetries: number;
  private readonly baseInterval: number;
  private readonly backoffMultiplier: number;
  private readonly maxInterval: number;
  private readonly heartbeatInterval: number;
  private readonly autoJoinRooms: string[];
  private readonly logger: IWSLogger;

  constructor(opts: IEncryptedWSClientOptions) {
    this.url = opts.url;
    this.ecdhInfo = opts.ecdhInfo;
    this.token = opts.token;

    const rc = opts.reconnect ?? {};
    this.reconnectEnabled = rc.enabled ?? true;
    this.maxRetries = rc.maxRetries ?? 5;
    this.baseInterval = rc.interval ?? 3000;
    this.backoffMultiplier = rc.backoffMultiplier ?? 1.5;
    this.maxInterval = rc.maxInterval ?? 30_000;

    this.heartbeatInterval = opts.heartbeatInterval ?? 30_000;
    this.autoJoinRooms = opts.autoJoinRooms ?? [];
    this.logger = opts.logger ?? console;
  }

  // ======================== PUBLIC API ====================================

  /** Connect to the Signal WebSocket server. */
  async connect(): Promise<void> {
    if (this.state !== 'disconnected' && this.state !== 'reconnecting') {
      this.logger.warn('Already connected or connecting');
      return;
    }

    this.forcedClose = false;
    this.setState('connecting');

    try {
      // 1. Generate new ECDH key pair (forward secrecy — new keys every connection)
      this.ecdhKeyPair = await generateECDHKeyPair();
      this.logger.debug('Generated ECDH key pair');

      // 2. Open WebSocket connection
      this.ws = new WebSocket(this.url);

      this.ws.onopen = () => this.handleOpen();
      this.ws.onmessage = (evt: MessageEvent) => this.handleMessage(evt);
      this.ws.onclose = (evt: CloseEvent) => this.handleClose(evt);
      this.ws.onerror = (evt: Event) => {
        this.logger.error('WebSocket error', evt);
      };
    } catch (err) {
      this.logger.error('Failed to connect', err);
      this.setState('disconnected');
      this.attemptReconnect();
    }
  }

  /** Disconnect manually. Prevents auto-reconnect. */
  disconnect(): void {
    this.forcedClose = true;
    this.cleanup();
    if (this.ws) {
      this.ws.close(1000, 'Client disconnect');
      this.ws = null;
    }
    this.setState('disconnected');
  }

  /** Send an encrypted message. Automatically encrypts with AES-256-GCM. */
  async send(event: string, data?: unknown): Promise<void> {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
      throw new Error('WebSocket is not connected');
    }

    // Control-plane events are sent in plaintext
    if (PLAINTEXT_EVENTS.has(event)) {
      this.ws.send(JSON.stringify({ event, data }));
      return;
    }

    // Application events must be encrypted
    if (!this.aesKey) {
      throw new Error('Cannot send — encryption not established. Wait for connected event.');
    }

    const plaintext = JSON.stringify({ event, data });
    const encrypted = await encrypt({ plaintext, aesKey: this.aesKey });

    this.ws.send(JSON.stringify({
      event: 'encrypted',
      data: encrypted,
    }));

    this.logger.debug(`Sent encrypted [${event}]`);
  }

  /** Join one or more rooms. */
  joinRooms(rooms: string[]): void {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
      this.logger.warn('Cannot join rooms — not connected');
      return;
    }
    this.ws.send(JSON.stringify({ event: 'join', data: { rooms } }));
    this.logger.debug('Join rooms:', rooms);
  }

  /** Leave one or more rooms. */
  leaveRooms(rooms: string[]): void {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
      this.logger.warn('Cannot leave rooms — not connected');
      return;
    }
    this.ws.send(JSON.stringify({ event: 'leave', data: { rooms } }));
    this.logger.debug('Leave rooms:', rooms);
  }

  /** Update the JWT token (e.g. after refresh). Used on the next reconnect. */
  updateToken(token: string): void {
    this.token = token;
  }

  // ---------- Event Registration ----------

  /** Register a handler for decrypted events. Handler receives the decrypted `data` payload. */
  on<T = unknown>(event: string, handler: TEventHandler<T>): void {
    if (!this.eventHandlers.has(event)) {
      this.eventHandlers.set(event, new Set());
    }
    this.eventHandlers.get(event)!.add(handler as TEventHandler);
  }

  /** Unregister a specific handler. */
  off<T = unknown>(event: string, handler: TEventHandler<T>): void {
    this.eventHandlers.get(event)?.delete(handler as TEventHandler);
  }

  /** Unregister all handlers for a specific event. */
  offAll(event: string): void {
    this.eventHandlers.delete(event);
  }

  /** Register a connection state change handler. */
  onStateChange(handler: TStateChangeHandler): void {
    this.stateChangeHandlers.add(handler);
  }

  // ---------- Getters ----------

  getState(): TConnectionState { return this.state; }
  getClientId(): string | null { return this.clientId; }
  getUserId(): string | null { return this.userId; }
  isEncrypted(): boolean { return this.aesKey !== null; }
  isConnected(): boolean { return this.state === 'authenticated'; }

  // ======================== PRIVATE ======================================

  /** Set state and notify all state change handlers. */
  private setState(newState: TConnectionState): void {
    const prev = this.state;
    this.state = newState;
    if (prev !== newState) {
      this.logger.debug(`State: ${prev} → ${newState}`);
      for (const handler of this.stateChangeHandlers) {
        try { handler(newState); } catch (e) { this.logger.error('State handler error', e); }
      }
    }
  }

  /** Emit a decrypted event to registered handlers. */
  private emit(event: string, data: unknown): void {
    const handlers = this.eventHandlers.get(event);
    if (handlers) {
      for (const handler of handlers) {
        try { handler(data); } catch (e) { this.logger.error(`Handler error [${event}]`, e); }
      }
    }
  }

  /** Handle WebSocket open — send authenticate with JWT + ECDH public key. */
  private handleOpen(): void {
    this.setState('authenticating');
    this.logger.debug('WebSocket open — sending authenticate');

    this.ws!.send(JSON.stringify({
      event: 'authenticate',
      data: {
        type: 'Bearer',
        token: this.token,
        clientPublicKey: this.ecdhKeyPair!.publicKeyB64,
      },
    }));
  }

  /** Handle incoming WebSocket messages. */
  private async handleMessage(evt: MessageEvent): Promise<void> {
    try {
      const { event, data } = JSON.parse(evt.data as string);

      switch (event) {
        case 'connected':
          await this.handleConnected(data);
          break;
        case 'encrypted':
          await this.handleEncryptedMessage(data);
          break;
        case 'error':
          this.logger.error('Server error:', data);
          this.emit('error', data);
          break;
        default:
          // Other plaintext events
          this.logger.debug(`Received plaintext [${event}]`);
          this.emit(event, data);
          break;
      }
    } catch (err) {
      this.logger.error('Failed to process message', err);
    }
  }

  /**
   * Handle the `connected` event — ECDH handshake.
   * Server sends: { id, userId, time, serverPublicKey, salt }
   */
  private async handleConnected(data: {
    id: string;
    userId: string;
    time: string;
    serverPublicKey?: string;
    salt?: string;
  }): Promise<void> {
    this.clientId = data.id;
    this.userId = data.userId;
    this.logger.info(`Authenticated: clientId=${data.id}, userId=${data.userId}`);

    // Derive AES key from server's public key + salt
    if (data.serverPublicKey && data.salt) {
      this.aesKey = await deriveAESKey({
        clientPrivateKey: this.ecdhKeyPair!.keyPair.privateKey,
        serverPublicKeyB64: data.serverPublicKey,
        saltB64: data.salt,
        ecdhInfo: this.ecdhInfo,
      });
      this.logger.info('Encryption established — all messages will be encrypted');
    } else {
      this.logger.warn('Server did not provide serverPublicKey/salt — encryption NOT available');
    }

    // Start heartbeat
    this.startHeartbeat();

    // Auto-join rooms
    if (this.autoJoinRooms.length > 0) {
      this.joinRooms(this.autoJoinRooms);
    }

    // Reset retry count on successful authentication
    this.retryCount = 0;

    this.setState('authenticated');
    this.emit('connected', data);
  }

  /** Decrypt an incoming encrypted message and emit the inner event. */
  private async handleEncryptedMessage(data: IEncryptedPayload): Promise<void> {
    if (!this.aesKey) {
      this.logger.error('Received encrypted message but no AES key available');
      return;
    }

    try {
      const plaintext = await decrypt({ payload: data, aesKey: this.aesKey });
      const inner = JSON.parse(plaintext) as { event: string; data: unknown };
      this.logger.debug(`Decrypted [${inner.event}]`);
      this.emit(inner.event, inner.data);
    } catch (err) {
      this.logger.error('Failed to decrypt message', err);
    }
  }

  /** Handle WebSocket close. Attempt reconnect if not a forced close. */
  private handleClose(evt: CloseEvent): void {
    this.logger.info(`WebSocket closed: code=${evt.code}, reason=${evt.reason}`);
    this.cleanup();

    if (!this.forcedClose) {
      this.attemptReconnect();
    } else {
      this.setState('disconnected');
    }
  }

  /** Start heartbeat — send every 30s to prevent idle timeout (server timeout: 90s). */
  private startHeartbeat(): void {
    this.stopHeartbeat();
    this.heartbeatTimer = setInterval(() => {
      if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ event: 'heartbeat' }));
      }
    }, this.heartbeatInterval);
  }

  /** Stop heartbeat timer. */
  private stopHeartbeat(): void {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }
  }

  /**
   * Attempt reconnection with exponential backoff.
   * Each reconnect generates a NEW ECDH key pair (forward secrecy).
   */
  private attemptReconnect(): void {
    if (!this.reconnectEnabled) {
      this.setState('disconnected');
      return;
    }

    if (this.retryCount >= this.maxRetries) {
      this.logger.error(`Max retries (${this.maxRetries}) exceeded — giving up`);
      this.setState('disconnected');
      this.emit('reconnect_failed', { retries: this.retryCount });
      return;
    }

    this.retryCount++;
    const delay = Math.min(
      this.baseInterval * Math.pow(this.backoffMultiplier, this.retryCount - 1),
      this.maxInterval,
    );

    this.logger.info(`Reconnecting in ${delay}ms (attempt ${this.retryCount}/${this.maxRetries})`);
    this.setState('reconnecting');

    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;
      this.connect();
    }, delay);
  }

  /** Clean up connection state. Delete AES key + ECDH key pair (forward secrecy). */
  private cleanup(): void {
    this.stopHeartbeat();
    this.aesKey = null;
    this.ecdhKeyPair = null;
    this.clientId = null;
    this.userId = null;

    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }
  }

  /** Full cleanup — destroy all state and clear all event handlers. */
  destroy(): void {
    this.disconnect();
    this.eventHandlers.clear();
    this.stateChangeHandlers.clear();
  }
}

Ví dụ sử dụng

typescript
const client = new EncryptedWebSocketClient({
  url: 'wss://signal.example.com/stream',
  ecdhInfo: 'nx-signal-e2e',
  token: jwtToken,
  autoJoinRooms: ['ws-default', 'ws-orders', 'ws-notifications'],
});

// Listen for events
client.on('payment.success', (data) => {
  console.log('Payment succeeded:', data);
});

client.on('reconnect_failed', () => {
  showToast('Connection lost. Please check your network and refresh.');
});

// Connect
await client.connect();

// Send encrypted message
await client.send('order.create', { items: [...] });

// Update token after refresh
client.updateToken(newJwtToken);

// Cleanup on unmount
client.destroy();

Lưu ý dành riêng cho Browser

Chủ đềChi tiết
HTTPS bắt buộcWeb Crypto API (crypto.subtle) chỉ khả dụng trong secure contexts (HTTPS hoặc localhost).
Khóa không thể trích xuấtCác đối tượng CryptoKey tạo với extractable: false không thể serialize, log, hoặc transfer. Đây là thiết kế có chủ đích để bảo mật.
Giới hạn WebSocketTrình duyệt giới hạn số kết nối WebSocket đồng thời (thường 6-30 mỗi domain). Khuyến nghị một kết nối Signal mỗi tab.
Tab visibilityKhi tab chạy nền, setInterval có thể bị throttle xuống tối thiểu 1 giây. Heartbeat (interval 30 giây) không bị ảnh hưởng, nhưng việc nhận tin nhắn thời gian thực có thể bị trễ nhẹ.
Page unloadGọi client.disconnect() hoặc client.destroy() trong sự kiện beforeunload hoặc khi unmount component để đóng kết nối sạch sẽ.
Lưu trữ tokenLưu JWT token an toàn. Ưu tiên httpOnly cookies, sessionStorage chấp nhận được. Tránh dùng localStorage cho token nhạy cảm.

Biểu đồ chuỗi luồng kết nối

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