Back to list

Blog

One Dependency: Building a Production Server with Just the ws Package

Our Node.js server has one runtime dependency. One. Here's why we made that choice and what it means for reliability, security, and maintenance.

Published Tags: engineering / architecture / node.js / security
Blog

Follow product and engineering updates from this channel.

Browse category

Open the package.json for Tactic Remote's server. Under dependencies, you'll find exactly one entry:

{
  "dependencies": {
    "ws": "^8.18.0"
  }
}

That's it. One package. The ws WebSocket library. Everything else — the HTTP server, authentication, rate limiting, terminal management, file browsing, input validation, process execution — is built on Node.js built-ins.

This isn't accidental minimalism. It's a deliberate engineering decision with concrete consequences for reliability, security, and the people who use our software.

Why This Matters

The server runs on your Mac, managing your Claude Code sessions. It has access to your terminal, your filesystem, and your running processes. Every dependency you add to a system like this is:

  1. An attack surface. Each npm package is code running with your permissions, written by someone you don't know, with transitive dependencies you haven't audited.
  2. A maintenance burden. Dependencies need updates. Updates can introduce breaking changes or vulnerabilities. The more you have, the more often you're doing npm audit fix instead of building features.
  3. A reliability risk. When a dependency breaks (leftpad, colors, node-ipc), your server breaks. Your users' development workflows stop.

For a server that sits between a human and an AI writing code on their machine, the cost of a dependency compromise is severe. We decided the right number of external dependencies was as close to zero as we could get.

What We Built Without Dependencies

HTTP Server

Node.js ships http.createServer(). It's not Express, but we serve exactly two HTTP endpoints:

// POST /hook — receives Claude Code lifecycle events
// GET /status — health check with last event info

Two routes. No middleware chains, no body parsers, no router libraries. The request handler is a single function that checks the URL path with ===, reads the request body with req.on('data'), and calls the appropriate handler.

Is this "webscale"? No. Does it need to be? Also no. This server handles one connection from one iPhone. A routing library would be engineering theater.

Authentication

The auth system supports two header formats for API key verification:

// HTTP endpoints
const apiKey = req.headers['x-api-key'] || req.headers['authorization']?.replace('Bearer ', '')
 
// WebSocket connections
const apiKey =
  req.headers['authorization']?.replace('Bearer ', '') || req.headers['sec-websocket-protocol']

The Sec-WebSocket-Protocol fallback exists because iOS URLSessionWebSocketTask can't set arbitrary headers on the upgrade request. This is a documented iOS limitation — you can set the protocol header, but not custom headers. Rather than pulling in a WebSocket library that works around this, we use the protocol header as an auth channel.

Authentication is checked once at connection time. If it fails, the WebSocket closes with code 1008 (Policy Violation). No session tokens, no refresh flows, no JWT libraries. A static API key, compared with ===.

Rate Limiting

An in-memory sliding window, 40 lines of code:

class RateLimiter {
  constructor(maxAttempts = 30, windowMs = 60000) {
    this.attempts = new Map() // IP -> { count, resetTime }
    this.maxAttempts = maxAttempts
    this.windowMs = windowMs
 
    // Cleanup stale entries every 5 minutes
    setInterval(() => this.cleanup(), 300000)
  }
 
  isRateLimited(ip) {
    const now = Date.now()
    const record = this.attempts.get(ip)
 
    if (!record || now > record.resetTime) {
      this.attempts.set(ip, { count: 1, resetTime: now + this.windowMs })
      return false
    }
 
    record.count++
    return record.count > this.maxAttempts
  }
}

30 failed authentication attempts per minute per IP. If exceeded, the server returns 429 Too Many Requests with a retryAfter field. When the window expires, the counter resets.

A Redis-backed rate limiter would be more sophisticated. It would also add a Redis dependency. For a server handling one client, an in-memory Map with periodic cleanup is appropriate.

Input Validation

Every user input is validated before it touches the system. Here's the complete validation surface:

InputValidationReason
Session names/^[a-zA-Z0-9_-]{1,64}$/tmux session names that could contain shell metacharacters
Claude command/^claude(\s+--?[a-zA-Z0-9-]+)*$/Only claude with optional flags — no pipes, redirects, chains
Tmux key namesExplicit allowlist of 18 keysPrevent arbitrary keystroke injection
File pathspath.resolve() + startsWith()Prevent directory traversal
Single characters/^[a-zA-Z0-9!@#$%^&*()...]$/Only printable ASCII for typing

No validation library. Regular expressions and string comparison, inline and readable.

Process Execution

This is the security-critical piece. The server executes tmux commands on your behalf. If this layer has a vulnerability, an attacker can run arbitrary commands on your machine.

Our answer: child_process.spawn() with shell: false, wrapped in a single function that every external command goes through:

function execSafe(cmd, args = [], opts = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      shell: false, // No shell interpolation. Period.
      ...opts,
    })
 
    let stdout = ''
    let stderr = ''
 
    child.stdout?.on('data', (data) => {
      stdout += data
    })
    child.stderr?.on('data', (data) => {
      stderr += data
    })
 
    child.on('close', (code) => {
      if (code === 0) resolve({ stdout, stderr, code })
      else reject(new Error(`Exit code ${code}: ${stderr}`))
    })
  })
}

shell: false means arguments are passed directly to the process, not through /bin/sh. There is no string interpolation, no template literal expansion, no opportunity for shell injection. If you pass ["send-keys", "-t", sessionName, "-l", content] as the args array, that's exactly what tmux receives as its argv. Semicolons, pipes, backticks, $() — none of them have special meaning.

Every tmux operation is a thin wrapper around this function:

async function tmuxSendKeys(sessionName, keys) {
  return execSafe('tmux', ['send-keys', '-t', sessionName, ...keys])
}
 
async function tmuxCapture(sessionName, lines = 500) {
  const result = await execSafe('tmux', [
    'capture-pane',
    '-t',
    sessionName,
    '-p',
    '-S',
    `-${lines}`,
  ])
  return result.stdout
}
 
async function tmuxNewSession(name, dir) {
  await execSafe('tmux', ['new-session', '-d', '-s', name, '-c', dir])
  await execSafe('tmux', ['set-option', '-t', name, 'history-limit', '50000'])
}

No tmux wrapper library. No ORM for terminal sessions. Just spawn with explicit argument arrays.

Path Traversal Protection

File browsing restricts access to the home directory tree. Sensitive directories are blocked by default:

const SENSITIVE_DIRS = [
  '.ssh',
  '.gnupg',
  '.aws',
  '.kube',
  '.config/gcloud',
  'Library/Keychains',
  'Library/Cookies',
  'Library/Application Support/1Password',
]
 
function containsSensitiveDir(targetPath) {
  const segments = targetPath.split(path.sep)
  return SENSITIVE_DIRS.some((sensitive) => {
    const sensitiveSegments = sensitive.split(path.sep)
    return segments.some((_, i) => sensitiveSegments.every((seg, j) => segments[i + j] === seg))
  })
}

Segment-by-segment matching. Not includes() on the path string, which would false-positive on a directory named my-.ssh-backup. The check looks for consecutive path segments that exactly match the sensitive directory pattern.

All paths are resolved to absolute paths via path.resolve() before checking. This neutralizes .. traversal, symlink attacks, and relative path tricks.

The WebSocket Protocol

The ws package gives us a WebSocket server. On top of it, we built a dual-protocol system that handles both our original flat message format and a newer structured format simultaneously.

Why Two Protocols

We shipped the flat protocol first:

{ "type": "send_to_claude", "content": "fix the bug", "session": "webapp" }

It works, but it has no request correlation. If the client sends two requests back-to-back, responses arrive as separate messages and the client can't tell which response matches which request.

The new protocol adds request IDs:

{"type": "req", "id": "abc-123", "method": "send_to_claude", "params": {"content": "fix the bug"}}
{"type": "res", "id": "abc-123", "ok": true, "payload": {"status": "sent"}}

We couldn't break existing clients by removing the flat format. So the server handles both:

function handleMessage(ws, data) {
    const msg = JSON.parse(data);
 
    if (msg.type === 'req') {
        // New protocol: extract method and params, call handler,
        // respond with matching id
        const result = await handlers[msg.method](msg.params);
        sendToClient(ws, { type: 'res', id: msg.id, ok: true, payload: result });
    } else {
        // Legacy protocol: type IS the method
        const result = await handlers[msg.type](msg);
        sendToClient(ws, { type: 'status', ...result });
    }
}

The iOS app's WebSocketClient does the same on the client side — tries to decode incoming messages as the new Frame protocol first, falls back to legacy ServerMessage if that fails.

Heartbeat

Mobile WebSocket connections are fragile. The phone switches between WiFi and cellular, goes to sleep, enters dead zones. We need to detect dead connections quickly but not kill connections that are just temporarily interrupted.

setInterval(() => {
  this.wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      ws.missedHeartbeats = (ws.missedHeartbeats || 0) + 1
      if (ws.missedHeartbeats >= 2) {
        return ws.terminate()
      }
    } else {
      ws.missedHeartbeats = 0
    }
    ws.isAlive = false
    ws.ping()
  })
}, 45000)

45-second interval, 2-strike tolerance. A client can be unresponsive for up to 90 seconds before being terminated. This is tuned for mobile — a typical WebSocket heartbeat is 15-30 seconds, but mobile clients frequently go through 30-60 second network transitions when switching towers or re-associating with WiFi.

We send both a WebSocket-level ping frame (which the ws library handles automatically) and an application-level {"type": "ping"} message. The application-level ping is for clients that can't access low-level WebSocket frames directly (another iOS URLSessionWebSocketTask constraint).

What We Don't Have

Being honest about what this architecture lacks:

No clustering. The server is single-process, single-machine. It can't scale horizontally. It doesn't need to — it serves one user on one Mac.

No persistence. If the server restarts, the rate limiter state and client registry are lost. tmux sessions survive (they're independent processes), but the server's in-memory state doesn't. For our use case, this is fine — the client reconnects and re-establishes state within seconds.

No graceful shutdown orchestration. When the server stops, WebSocket connections are terminated, not drained. Again, fine for a single-user server — the client handles reconnection.

No request logging or APM. We log to stdout. If you need more, pipe it to a file. There's no structured logging library, no OpenTelemetry integration, no log aggregation.

These are all features that make sense for multi-tenant cloud services. They don't make sense for a server that runs on your desk and serves your phone.

The Maintenance Reality

Since launching, our dependency maintenance has been:

  • Update ws when there's a security patch
  • That's it

No npm audit warnings from transitive dependencies. No Dependabot PRs for libraries we don't directly use. No breaking changes from major version bumps in middleware frameworks.

When the ws library has a vulnerability (it's happened twice since we started), we update one package. The change is small, the risk is contained, and we can audit the diff in five minutes.

Compare this with a typical Express + passport + helmet + compression + cors + body-parser stack. Each of those pulls in its own dependency tree. A single npm audit can flag dozens of transitive vulnerabilities, most of which are theoretical but all of which require investigation.

When This Approach Doesn't Work

To be clear: this is not a general recommendation. One-dependency servers work when:

  • You serve a single user or a small, known set of clients
  • Your HTTP surface is tiny (1-5 routes)
  • You don't need database access, templating, or complex routing
  • Security is better served by simplicity than by layering libraries
  • Your team is comfortable reading Node.js built-in APIs

If you're building a SaaS with user accounts, OAuth, database models, and a REST API, use a framework. Express or Fastify with a proper ORM will save you months of work. The "one dependency" approach is for systems where the dependency cost exceeds the implementation cost.

For Tactic Remote's server — a single-user, single-purpose WebSocket relay between tmux and an iPhone — one dependency is the right number.


For how this server fits into the larger architecture, see Running Claude Code Remotely from Your Phone. For the full WebSocket protocol reference, see the Getting Started guide.

Try Tactic Remote

Control your coding Agents from your phone

Connect to Claude Code, Codex, and other Agents on your Mac, Windows, or Linux computer. Check progress and send the next instruction from iPhone or iPad.