Understanding WebSocket Handshake with TypeScript

Introduction

WebSocket provides a powerful way to establish real-time, bi-directional communication between clients and servers. In this post, we’ll dive deep into the WebSocket handshake process using TypeScript, making it easier to understand and implement.


What Is the WebSocket Handshake?

The WebSocket handshake is a process where a client and a server upgrade their communication protocol from HTTP to WebSocket. It’s like starting a conversation in one language (HTTP) and then agreeing to switch to another (WebSocket) for better communication.


How Does It Work?

Here’s a simplified flow of the WebSocket handshake:

Codecalls.com - websocket handshake explained

After the handshake, both the client and server switch to the WebSocket protocol, enabling bi-directional communication.

Client Sends a Request

The client begins with a standard HTTP request.

The request includes headers indicating the desire to upgrade to WebSocket.

Server Responds

The server evaluates the request.

If all criteria are met, the server sends back a response approving the upgrade.

Protocol Upgrade

After the handshake, both the client and server switch to the WebSocket protocol, enabling bi-directional communication.

WebSocket Client Implementation

Let’s start by creating a TypeScript class that handles WebSocket connections:

interface WebSocketConfig {
  url: string;
  protocols?: string | string[];
  onOpen?: (event: Event) => void;
  onMessage?: (data: any) => void;
  onClose?: (event: CloseEvent) => void;
  onError?: (event: Event) => void;
}

class WebSocketClient {
  private ws: WebSocket;
  private reconnectAttempts: number = 0;
  private readonly maxReconnectAttempts: number = 5;

  constructor(private config: WebSocketConfig) {
    this.initializeWebSocket();
  }

  private initializeWebSocket(): void {
    try {
      this.ws = new WebSocket(this.config.url, this.config.protocols);
      this.setupEventListeners();
    } catch (error) {
      console.error('WebSocket initialization failed:', error);
    }
  }

  private setupEventListeners(): void {
    <em>// Connection opened</em>
    this.ws.onopen = (event: Event) => {
      console.log('WebSocket connection established');
      this.reconnectAttempts = 0;
      this.config.onOpen?.(event);
    };

    <em>// Listen for messages</em>
    this.ws.onmessage = (event: MessageEvent) => {
      const data = this.parseMessage(event.data);
      this.config.onMessage?.(data);
    };

    <em>// Connection closed</em>
    this.ws.onclose = (event: CloseEvent) => {
      console.log('WebSocket connection closed:', event.code, event.reason);
      this.handleReconnection(event);
      this.config.onClose?.(event);
    };

    <em>// Error handling</em>
    this.ws.onerror = (event: Event) => {
      console.error('WebSocket error:', event);
      this.config.onError?.(event);
    };
  }

  private parseMessage(data: any): any {
    try {
      return JSON.parse(data);
    } catch {
      return data;
    }
  }

  private handleReconnection(event: CloseEvent): void {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      console.log(`Attempting to reconnect... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
      setTimeout(() => this.initializeWebSocket(), 1000 * this.reconnectAttempts);
    }
  }

  <em>// Send message to server</em>
  public send(data: any): void {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(typeof data === 'string' ? data : JSON.stringify(data));
    } else {
      console.error('WebSocket is not connected');
    }
  }

  <em>// Close connection</em>
  public close(code?: number, reason?: string): void {
    this.ws.close(code, reason);
  }
}</code>

Server Implementation (Node.js with TypeScript)

Here’s a simple WebSocket server implementation using ws library:

import { Server, WebSocket } from 'ws';
import { createServer } from 'http';
import crypto from 'crypto';

interface WebSocketServerConfig {
  port: number;
  path?: string;
}

class WebSocketServer {
  private wss: Server;
  private readonly GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

  constructor(config: WebSocketServerConfig) {
    const server = createServer();
    
    this.wss = new Server({ 
      server,
      path: config.path
    });

    this.setupServerEvents();
    server.listen(config.port);
  }

  private setupServerEvents(): void {
    this.wss.on('connection', (ws: WebSocket, request) => {
      console.log('New client connected');

      <em>// Verify the WebSocket key</em>
      const key = request.headers['sec-websocket-key'];
      if (key) {
        const acceptKey = this.generateAcceptKey(key);
        console.log('Handshake completed with accept key:', acceptKey);
      }

      this.setupClientEvents(ws);
    });
  }

  private setupClientEvents(ws: WebSocket): void {
    ws.on('message', (message: string) => {
      try {
        const data = JSON.parse(message.toString());
        this.handleMessage(ws, data);
      } catch (error) {
        console.error('Error processing message:', error);
      }
    });

    ws.on('close', () => {
      console.log('Client disconnected');
    });
  }

  private generateAcceptKey(key: string): string {
    return crypto
      .createHash('sha1')
      .update(key + this.GUID)
      .digest('base64');
  }

  private handleMessage(ws: WebSocket, data: any): void {
    <em>// Echo the message back to the client</em>
    ws.send(JSON.stringify({
      type: 'echo',
      data: data
    }));
  }
}

Using the WebSocket Client

Here’s how to use our WebSocket client:

const wsClient = new WebSocketClient({
  url: 'ws://localhost:8080/chat',
  protocols: ['chat'],
  onOpen: (event) => {
    console.log('Connected to server');
    wsClient.send({
      type: 'greeting',
      message: 'Hello Server!'
    });
  },
  onMessage: (data) => {
    console.log('Received:', data);
  },
  onClose: (event) => {
    console.log('Connection closed:', event.code);
  },
  onError: (error) => {
    console.error('WebSocket error:', error);
  }
});

WebSocket Connection States

In TypeScript, WebSocket connection states are represented by constants:

enum WebSocketState {
  CONNECTING = 0,  <em>// Socket has been created. Connection is not yet open.</em>
  OPEN = 1,        <em>// Connection is open and ready to communicate.</em>
  CLOSING = 2,     <em>// Connection is in the process of closing.</em>
  CLOSED = 3       <em>// Connection is closed or couldn't be opened.</em>
}

Security Best Practices

  1. Type-Safe Headers Verification:
interface WebSocketHeaders {
  'sec-websocket-key': string;
  'sec-websocket-version': string;
  'sec-websocket-protocol'?: string;
  upgrade: string;
  connection: string;
}

function verifyHeaders(headers: Partial<WebSocketHeaders>): boolean {
  return (
    headers['sec-websocket-key'] !== undefined &&
    headers.upgrade?.toLowerCase() === 'websocket' &&
    headers.connection?.toLowerCase().includes('upgrade')
  );
}
  1. Origin Validation:
function validateOrigin(origin: string, allowedOrigins: string[]): boolean {
  try {
    const url = new URL(origin);
    return allowedOrigins.includes(url.origin);
  } catch {
    return false;
  }
}

Conclusion

The WebSocket handshake process, while complex, becomes much more manageable when implemented with TypeScript. The type safety and interface definitions help catch potential issues early in development and make the code more maintainable.

The diagram above illustrates the three main steps of the WebSocket handshake:

  1. Client sends an HTTP upgrade request
  2. Server responds with the protocol switch confirmation
  3. Bi-directional WebSocket communication begins

Remember to handle edge cases, implement proper error handling, and follow security best practices when implementing WebSockets in your applications.

Leave a Reply