Volver a la lista

Blog

Una sola dependencia: construyendo un servidor de producción solo con el paquete ws

Nuestro servidor Node.js tiene una dependencia en tiempo de ejecución. Una. Así fue la decisión y qué significa para la fiabilidad, la seguridad y el mantenimiento.

Publicado Etiquetas: ingeniería / arquitectura / node.js / seguridad
Blog

Sigue las novedades de producto e ingeniería de esta categoría.

Ver categoría

Abre el package.json del servidor de Tactic Remote. Bajo dependencies, encontrarás exactamente una entrada:

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

Eso es todo. Un paquete. La librería WebSocket ws. Todo lo demás — el servidor HTTP, la autenticación, el rate limiting, la gestión de terminales, la navegación de archivos, la validación de entrada, la ejecución de procesos — está construido sobre las APIs nativas de Node.js.

Este no es minimalismo accidental. Es una decisión de ingeniería deliberada con consecuencias concretas para la fiabilidad, la seguridad y las personas que usan nuestro software.

Por qué esto importa

El servidor corre en tu Mac, gestionando tus sesiones de Claude Code. Tiene acceso a tu terminal, tu sistema de archivos y tus procesos en ejecución. Cada dependencia que añades a un sistema así es:

  1. Una superficie de ataque. Cada paquete npm es código que se ejecuta con tus permisos, escrito por alguien que no conoces, con dependencias transitivas que no has auditado.
  2. Una carga de mantenimiento. Las dependencias necesitan actualizaciones. Las actualizaciones pueden introducir cambios que rompen o vulnerabilidades. Cuantas más tengas, más a menudo estarás haciendo npm audit fix en lugar de construir funcionalidades.
  3. Un riesgo de fiabilidad. Cuando una dependencia se rompe (leftpad, colors, node-ipc), tu servidor se rompe. Los flujos de desarrollo de tus usuarios se detienen.

Para un servidor que se sitúa entre un humano y una IA escribiendo código en su máquina, el coste de un compromiso de dependencia es severo. Decidimos que el número correcto de dependencias externas era lo más cercano a cero posible.

Qué construimos sin dependencias

Servidor HTTP

Node.js incluye http.createServer(). No es Express, pero servimos exactamente dos endpoints HTTP:

// POST /hook — recibe eventos del ciclo de vida de Claude Code
// GET /status — health check con info del último evento

Dos rutas. Sin cadenas de middleware, sin body parsers, sin librerías de routing. El handler de requests es una única función que verifica la ruta URL con ===, lee el cuerpo de la petición con req.on('data') y llama al handler correspondiente.

¿Es esto "webscale"? No. ¿Necesita serlo? Tampoco. Este servidor maneja una conexión de un iPhone. Una librería de routing sería teatro de ingeniería.

Autenticación

El sistema de auth soporta dos formatos de headers para verificación de API key:

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

El fallback a Sec-WebSocket-Protocol existe porque URLSessionWebSocketTask de iOS no puede establecer headers arbitrarios en la petición de upgrade. Esta es una limitación documentada de iOS — puedes establecer el header de protocolo, pero no headers personalizados. En lugar de usar una librería WebSocket que solucione esto, usamos el header de protocolo como canal de auth.

La autenticación se verifica una vez al momento de la conexión. Si falla, el WebSocket se cierra con código 1008 (Policy Violation). Sin tokens de sesión, sin flujos de refresco, sin librerías JWT. Una API key estática, comparada con ===.

Rate limiting

Una ventana deslizante en memoria, 40 líneas de código:

class RateLimiter {
  constructor(maxAttempts = 30, windowMs = 60000) {
    this.attempts = new Map() // IP -> { count, resetTime }
    this.maxAttempts = maxAttempts
    this.windowMs = windowMs
 
    // Limpieza de entradas obsoletas cada 5 minutos
    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 intentos fallidos de autenticación por minuto por IP. Si se excede, el servidor retorna 429 Too Many Requests con un campo retryAfter. Cuando la ventana expira, el contador se reinicia.

Un rate limiter respaldado por Redis sería más sofisticado. También añadiría una dependencia de Redis. Para un servidor que maneja un cliente, un Map en memoria con limpieza periódica es apropiado.

Validación de entrada

Cada entrada de usuario se valida antes de tocar el sistema. Esta es la superficie completa de validación:

EntradaValidaciónRazón
Nombres de sesión/^[a-zA-Z0-9_-]{1,64}$/Nombres de sesión tmux que podrían contener metacaracteres de shell
Comando claude/^claude(\s+--?[a-zA-Z0-9-]+)*$/Solo claude con flags opcionales — sin pipes, redirecciones, encadenamientos
Nombres de tecla tmuxLista explícita de 18 teclas permitidasPrevenir inyección de pulsaciones arbitrarias
Rutas de archivopath.resolve() + startsWith()Prevenir traversal de directorios
Caracteres individuales/^[a-zA-Z0-9!@#$%^&*()...]$/Solo ASCII imprimible para escritura

Sin librería de validación. Expresiones regulares y comparación de strings, inline y legibles.

Ejecución de procesos

Esta es la pieza crítica de seguridad. El servidor ejecuta comandos tmux en tu nombre. Si esta capa tiene una vulnerabilidad, un atacante puede ejecutar comandos arbitrarios en tu máquina.

Nuestra respuesta: child_process.spawn() con shell: false, envuelto en una única función por la que pasa cada comando externo:

function execSafe(cmd, args = [], opts = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      shell: false, // Sin interpolación de shell. Punto.
      ...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 significa que los argumentos se pasan directamente al proceso, no a través de /bin/sh. No hay interpolación de strings, no hay expansión de template literals, no hay oportunidad de inyección de shell. Si pasas ["send-keys", "-t", sessionName, "-l", content] como array de args, eso es exactamente lo que tmux recibe como su argv. Punto y coma, pipes, backticks, $() — ninguno tiene significado especial.

Cada operación tmux es un wrapper delgado sobre esta función:

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'])
}

Sin librería wrapper de tmux. Sin ORM para sesiones de terminal. Solo spawn con arrays de argumentos explícitos.

Protección contra traversal de rutas

La navegación de archivos restringe el acceso al árbol del directorio home. Los directorios sensibles siempre están bloqueados:

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

Coincidencia segmento por segmento. No includes() sobre el string de la ruta, lo cual daría falsos positivos en un directorio llamado my-.ssh-backup. La verificación busca segmentos de ruta consecutivos que coincidan exactamente con el patrón del directorio sensible.

Todas las rutas se resuelven a rutas absolutas vía path.resolve() antes de la verificación. Esto neutraliza traversal con .., ataques de symlinks y trucos con rutas relativas.

El protocolo WebSocket

El paquete ws nos da un servidor WebSocket. Sobre él, construimos un sistema de protocolo dual que maneja tanto nuestro formato plano original como un formato estructurado más nuevo simultáneamente.

Por qué dos protocolos

Enviamos el protocolo plano primero:

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

Funciona, pero no tiene correlación de peticiones. Si el cliente envía dos peticiones seguidas, las respuestas llegan como mensajes separados y el cliente no puede saber qué respuesta corresponde a qué petición.

El nuevo protocolo añade IDs de petición:

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

No podíamos romper clientes existentes eliminando el formato plano. Así que el servidor maneja ambos:

function handleMessage(ws, data) {
    const msg = JSON.parse(data);
 
    if (msg.type === 'req') {
        // Nuevo protocolo: extrae method y params, llama al handler,
        // responde con id coincidente
        const result = await handlers[msg.method](msg.params);
        sendToClient(ws, { type: 'res', id: msg.id, ok: true, payload: result });
    } else {
        // Protocolo legacy: type ES el method
        const result = await handlers[msg.type](msg);
        sendToClient(ws, { type: 'status', ...result });
    }
}

El WebSocketClient de la app iOS hace lo mismo del lado del cliente — intenta decodificar mensajes entrantes como el nuevo protocolo Frame primero, y cae al ServerMessage legacy si falla.

Heartbeat

Las conexiones WebSocket móviles son frágiles. El teléfono cambia entre WiFi y celular, se suspende, entra en zonas muertas. Necesitamos detectar conexiones muertas rápido pero no matar conexiones que están solo temporalmente interrumpidas.

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)

Intervalo de 45 segundos, tolerancia de 2 fallos. Un cliente puede no responder hasta 90 segundos antes de ser terminado. Esto está ajustado para móvil — un heartbeat WebSocket típico es de 15-30 segundos, pero los clientes móviles frecuentemente pasan por transiciones de red de 30-60 segundos al cambiar de antena o reconectarse al WiFi.

Enviamos tanto un ping a nivel de WebSocket (que la librería ws maneja automáticamente) como un mensaje a nivel de aplicación {"type": "ping"}. El ping a nivel de aplicación es para clientes que no pueden acceder a los frames WebSocket de bajo nivel directamente (otra restricción de URLSessionWebSocketTask de iOS).

Lo que no tenemos

Siendo honestos sobre lo que le falta a esta arquitectura:

Sin clustering. El servidor es un único proceso, una única máquina. No puede escalar horizontalmente. No necesita hacerlo — sirve a un usuario en un Mac.

Sin persistencia. Si el servidor se reinicia, el estado del rate limiter y el registro de clientes se pierden. Las sesiones tmux sobreviven (son procesos independientes), pero el estado en memoria del servidor no. Para nuestro caso de uso, esto está bien — el cliente reconecta y reestablece el estado en segundos.

Sin orquestación de cierre graceful. Cuando el servidor se detiene, las conexiones WebSocket se terminan, no se drenan. De nuevo, bien para un servidor de un solo usuario — el cliente maneja la reconexión.

Sin logging de peticiones ni APM. Logueamos a stdout. Si necesitas más, redirige a un archivo. No hay librería de logging estructurado, no hay integración con OpenTelemetry, no hay agregación de logs.

Todas estas son funcionalidades que tienen sentido para servicios cloud multi-tenant. No tienen sentido para un servidor que corre en tu escritorio y sirve a tu teléfono.

La realidad del mantenimiento

Desde el lanzamiento, nuestro mantenimiento de dependencias ha sido:

  • Actualizar ws cuando hay un parche de seguridad
  • Eso es todo

Sin advertencias de npm audit por dependencias transitivas. Sin PRs de Dependabot para librerías que no usamos directamente. Sin cambios que rompen por major version bumps en frameworks de middleware.

Cuando la librería ws tiene una vulnerabilidad (ha pasado dos veces desde que empezamos), actualizamos un paquete. El cambio es pequeño, el riesgo está contenido y podemos auditar el diff en cinco minutos.

Compara esto con un stack típico de Express + passport + helmet + compression + cors + body-parser. Cada uno trae su propio árbol de dependencias. Un solo npm audit puede señalar decenas de vulnerabilidades transitivas, la mayoría teóricas pero todas requieren investigación.

Cuándo este enfoque no funciona

Para ser claros: esto no es una recomendación general. Los servidores de una sola dependencia funcionan cuando:

  • Sirves a un solo usuario o un conjunto pequeño y conocido de clientes
  • Tu superficie HTTP es mínima (1-5 rutas)
  • No necesitas acceso a base de datos, templating o routing complejo
  • La seguridad se sirve mejor con simplicidad que apilando librerías
  • Tu equipo se siente cómodo leyendo las APIs nativas de Node.js

Si estás construyendo un SaaS con cuentas de usuario, OAuth, modelos de base de datos y una API REST, usa un framework. Express o Fastify con un ORM apropiado te ahorrarán meses de trabajo. El enfoque de "una dependencia" es para sistemas donde el coste de la dependencia excede el coste de implementación.

Para el servidor de Tactic Remote — un relay WebSocket de un solo usuario y propósito único entre tmux y un iPhone — una dependencia es el número correcto.


Para ver cómo este servidor encaja en la arquitectura general, consulta Ejecutar Claude Code remotamente desde tu teléfono. Para la referencia completa del protocolo WebSocket, consulta la guía de Inicio rápido.

Prueba Tactic Remote

Controla tus agentes de programación desde el móvil

Conéctate a Claude Code, Codex y otros agentes en tu Mac, Windows o Linux. Consulta el progreso y envía la siguiente instrucción desde el iPhone o iPad.