DEV Community

Wilson Xu
Wilson Xu

Posted on

Building a WebSocket Debugging CLI with Node.js

WebSockets power the real-time web — chat apps, live dashboards, multiplayer games, collaborative editors. But when something goes wrong with a WebSocket connection, debugging it is surprisingly painful.

In this tutorial, we will build a full-featured WebSocket debugging CLI from scratch. By the end, you will have a tool that connects to any WebSocket endpoint, logs messages with timestamps, filters by pattern, measures latency, records sessions for replay, and supports multiple concurrent connections — all from your terminal.

Why Browser DevTools Fall Short

If you have ever tried to debug WebSocket traffic in Chrome DevTools, you know the frustration:

  • No filtering: You cannot filter messages by content or pattern.
  • No replay: You cannot resend a previous message without writing code.
  • No recording: Once you close the tab, your message history is gone.
  • No scripting: No way to automate sends or set up timed pings.
  • No multi-connection view: You have to switch between connections manually.

Project Setup

mkdir ws-debugger && cd ws-debugger
npm init -y
npm install ws chalk commander readline
Enter fullscreen mode Exit fullscreen mode

The Connection Manager

The heart of our tool is a connection manager that wraps the ws library and emits structured events.

const WebSocket = require('ws');
const EventEmitter = require('events');

class ConnectionManager extends EventEmitter {
  constructor() {
    super();
    this.connections = new Map();
    this.connectionCounter = 0;
  }

  connect(url, options = {}) {
    const id = ++this.connectionCounter;
    const ws = new WebSocket(url, options.protocols || [], {
      headers: options.headers || {},
    });
    const connection = {
      id, url, ws, createdAt: Date.now(),
      messageCount: { sent: 0, received: 0 },
      bytesTransferred: { sent: 0, received: 0 }
    };

    ws.on('open', () => this.emit('connected', { id, url }));
    ws.on('message', (data, isBinary) => {
      const message = isBinary ? data : data.toString();
      connection.messageCount.received++;
      this.emit('message', {
        id, direction: 'incoming', data: message,
        size: Buffer.byteLength(data), timestamp: Date.now()
      });
    });
    ws.on('close', (code) => {
      this.emit('disconnected', { id, url, code });
      this.connections.delete(id);
    });
    ws.on('error', (error) => {
      this.emit('error', { id, error: error.message });
    });
    this.connections.set(id, connection);
    return id;
  }

  send(id, data) {
    const conn = this.connections.get(id);
    if (!conn) throw new Error('Connection not found');
    const payload = typeof data === 'object' ? JSON.stringify(data) : data;
    conn.ws.send(payload);
    conn.messageCount.sent++;
  }

  getStats(id) {
    const c = this.connections.get(id);
    if (!c) return null;
    return {
      id: c.id, url: c.url,
      uptime: Date.now() - c.createdAt,
      messages: c.messageCount
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Message Logging with Filtering

class MessageLogger {
  constructor(options = {}) {
    this.filters = [];
    this.excludePatterns = [];
    this.jsonPrettyPrint = options.pretty !== false;
  }

  addFilter(pattern) {
    this.filters.push(new RegExp(pattern, 'i'));
  }

  addExclusion(pattern) {
    this.excludePatterns.push(new RegExp(pattern, 'i'));
  }

  shouldDisplay(message) {
    const text = typeof message === 'string' ? message : JSON.stringify(message);
    for (const p of this.excludePatterns) {
      if (p.test(text)) return false;
    }
    if (this.filters.length === 0) return true;
    return this.filters.some(p => p.test(text));
  }
}
Enter fullscreen mode Exit fullscreen mode

Latency Measurement

class LatencyTracker {
  constructor() {
    this.measurements = [];
    this.pendingPings = new Map();
  }

  startPing(connectionId, ws) {
    const id = connectionId + '-' + Date.now();
    this.pendingPings.set(id, process.hrtime.bigint());
    ws.ping(id);
    return id;
  }

  recordPong(pingId) {
    const start = this.pendingPings.get(pingId);
    if (!start) return null;
    const latencyMs = Number(process.hrtime.bigint() - start) / 1_000_000;
    this.pendingPings.delete(pingId);
    this.measurements.push({ timestamp: Date.now(), latency: latencyMs });
    return latencyMs;
  }

  getStats() {
    if (!this.measurements.length) return { count: 0 };
    const sorted = this.measurements.map(m => m.latency).sort((a, b) => a - b);
    const n = sorted.length;
    return {
      count: n,
      min: sorted[0].toFixed(2) + 'ms',
      max: sorted[n - 1].toFixed(2) + 'ms',
      avg: (sorted.reduce((a, b) => a + b, 0) / n).toFixed(2) + 'ms',
      p95: sorted[Math.floor(n * 0.95)].toFixed(2) + 'ms',
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Session Recording and Replay

class SessionRecorder {
  constructor() {
    this.recording = false;
    this.events = [];
    this.startTime = null;
  }

  start() {
    this.recording = true;
    this.events = [];
    this.startTime = Date.now();
  }

  stop() {
    this.recording = false;
    return {
      duration: Date.now() - this.startTime,
      eventCount: this.events.length
    };
  }

  recordEvent(event) {
    if (!this.recording) return;
    this.events.push({
      relativeTime: Date.now() - this.startTime,
      direction: event.direction,
      data: event.data,
      connectionId: event.id
    });
  }

  save(filepath) {
    const fs = require('fs');
    fs.writeFileSync(filepath, JSON.stringify({
      version: 1,
      recordedAt: new Date(this.startTime).toISOString(),
      events: this.events
    }, null, 2));
  }
}
Enter fullscreen mode Exit fullscreen mode

Interactive REPL Mode

class InteractiveMode {
  constructor(cm, logger, recorder, latency) {
    this.cm = cm;
    this.logger = logger;
    this.recorder = recorder;
    this.latency = latency;
  }

  start(connectionId) {
    this.activeConnection = connectionId;
    const rl = require('readline').createInterface({
      input: process.stdin, output: process.stdout, prompt: 'ws> '
    });
    rl.prompt();
    rl.on('line', (line) => {
      const input = line.trim();
      if (input.startsWith('/')) this.handleCommand(input);
      else {
        try { this.cm.send(this.activeConnection, JSON.parse(input)); }
        catch { this.cm.send(this.activeConnection, input); }
      }
      rl.prompt();
    });
  }

  handleCommand(input) {
    const [cmd, ...args] = input.split(' ');
    switch (cmd) {
      case '/connect': this.cm.connect(args[0]); break;
      case '/switch': this.activeConnection = parseInt(args[0]); break;
      case '/list': console.log(this.cm.listConnections()); break;
      case '/stats': console.log(this.cm.getStats(this.activeConnection)); break;
      case '/ping': console.log(this.latency.getStats()); break;
      case '/filter': this.logger.addFilter(args.join(' ')); break;
      case '/exclude': this.logger.addExclusion(args.join(' ')); break;
      case '/record':
        if (args[0] === 'start') this.recorder.start();
        else if (args[0] === 'stop') console.log(this.recorder.stop());
        else if (args[0] === 'save') this.recorder.save(args[1] || 'session.json');
        break;
      case '/quit': process.exit(0);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage Examples

# Basic connection with interactive mode
wsd connect wss://echo.websocket.org

# Connect with custom headers
wsd connect wss://api.example.com/ws -H "Authorization:Bearer token"

# Filter for specific message types
wsd connect wss://stream.example.com -f "trade" -x "heartbeat"

# Record a session
wsd connect wss://api.example.com/ws -r session.json

# Replay at 5x speed
wsd replay session.json -u wss://staging.example.com/ws -s 5

# Monitor latency
wsd connect wss://api.example.com/ws --ping-interval 3000
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

We built a WebSocket debugging CLI that goes far beyond what browser DevTools offer:

  1. Event-driven connection manager -- Clean separation between WebSocket handling and UI logic.
  2. Pluggable message logger -- Filtering and formatting decoupled from connection management.
  3. Session recording with relative timestamps -- Enables faithful replay at any speed.
  4. Multi-connection support -- The connection map pattern scales naturally.

The full source is about 500 lines of JavaScript -- small enough to understand completely, yet powerful enough for daily use.

Top comments (0)