Skip to content

Web Browser Client

Overview

TypeScript/JavaScript WebSocket client for web browser environments using the Web Crypto API (crypto.subtle). Works on Chrome, Firefox, Edge, Safari, and all modern browsers.

  • Pure browser APIs -- Uses Web Crypto API (crypto.subtle) and native WebSocket
  • Zero dependencies -- No external crypto libraries needed
  • TypeScript-first -- Full type safety with exported interfaces
  • Auto-reconnect -- Exponential backoff with forward secrecy (new ECDH keys per connection)

Two files:

FilePurposeRecommended location
signal-crypto.tsECDH P-256 key generation, AES-256-GCM encrypt/decryptsrc/socket/crypto/signal-crypto.ts
encrypted-ws-client.tsWebSocket client with full lifecycle managementsrc/socket/encrypted-ws-client.ts

Dependencies

No external packages required. The following browser APIs are used:

APIPurposeSupport
crypto.subtle (Web Crypto)ECDH key generation, AES-256-GCM encrypt/decrypt, HKDF key derivationChrome 37+, Firefox 34+, Edge 12+, Safari 11+
WebSocketWebSocket transportAll modern browsers
TextEncoder / TextDecoderUTF-8 string encodingAll modern browsers
btoa / atobBase64 encoding/decodingAll modern browsers

Crypto Module (signal-crypto.ts)

Key types and functions:

ExportTypeDescription
IECDHKeyPairInterface{ keyPair: CryptoKeyPair; publicKeyB64: string }
generateECDHKeyPair()FunctionGenerate ephemeral ECDH P-256 key pair. Private key is non-extractable. Public key exported as raw base64 (65 bytes).
deriveAESKey(opts)FunctionDerive AES-256-GCM key from ECDH shared secret + HKDF-SHA-256. Requires clientPrivateKey, serverPublicKeyB64, saltB64, ecdhInfo.
IEncryptedPayloadInterface{ iv: string; ct: string } -- base64-encoded IV (12 bytes) and ciphertext (includes 128-bit auth tag).
encrypt(opts)FunctionEncrypt plaintext string with AES-256-GCM. Random 12-byte IV per message.
decrypt(opts)FunctionDecrypt { iv, ct } payload back to plaintext. Throws if auth tag is invalid.

Handshake flow:

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
Click to expand full source code
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)

Production-ready TypeScript WebSocket client. Replaces the old WebSocketClientHelper. Manages the entire connection lifecycle including ECDH handshake, automatic encrypt/decrypt, heartbeat, rooms, and reconnection with forward secrecy.

Key features:

FeatureDescription
Forward secrecyNew ECDH key pair for every connection (including reconnects)
Auto-reconnectExponential backoff with configurable maxRetries
HeartbeatSends { event: "heartbeat" } every 30s via setInterval
Auto-join roomsAutomatically joins rooms after successful authentication
Event systemon()/off() handlers for decrypted events + state change callbacks

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;
}

Configuration (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
}
Click to expand full source code
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();
  }
}

Usage Example

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();

Browser-Specific Notes

TopicDetails
HTTPS requiredWeb Crypto API (crypto.subtle) is only available in secure contexts (HTTPS or localhost).
Non-extractable keysCryptoKey objects created with extractable: false cannot be serialized, logged, or transferred. This is by design for security.
WebSocket limitsBrowsers limit concurrent WebSocket connections (typically 6-30 per domain). One Signal connection per tab is recommended.
Tab visibilityWhen a tab is backgrounded, setInterval may be throttled to 1s minimum. Heartbeat (30s interval) is unaffected, but real-time message delivery may be slightly delayed.
Page unloadCall client.disconnect() or client.destroy() on beforeunload or component unmount to cleanly close the connection.
Token storageStore JWT tokens securely. httpOnly cookies preferred, sessionStorage acceptable. Avoid localStorage for sensitive tokens.

Connection Flow Sequence

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