一覧に戻る

ブログ

依存パッケージ1つ:ws パッケージだけで本番サーバーを構築する

Node.js サーバーのランタイム依存パッケージは1つだけ。たった1つ。なぜその選択をしたのか、そして信頼性・セキュリティ・メンテナンスにとって何を意味するのか。

公開日 タグ: engineering / architecture / node.js / security
ブログ

このカテゴリのプロダクト/開発アップデートを確認できます。

カテゴリを見る

Tactic Remote サーバーの package.json を開いてみてください。dependencies にはエントリが1つだけあります:

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

これだけです。パッケージ1つ。ws WebSocket ライブラリ。それ以外のすべて — HTTP サーバー、認証、レート制限、ターミナル管理、ファイルブラウジング、入力検証、プロセス実行 — は Node.js の組み込み機能で構築されています。

これは偶然のミニマリズムではありません。信頼性、セキュリティ、そしてソフトウェアを使う人々にとって具体的な影響をもたらす、意図的なエンジニアリング判断です。

なぜ重要か

サーバーは Mac 上で動作し、Claude Code セッションを管理します。ターミナル、ファイルシステム、実行中のプロセスへのアクセス権を持っています。このようなシステムに追加する依存パッケージはそれぞれ:

  1. 攻撃面。 各 npm パッケージは自分の権限で動作するコードであり、知らない人が書いたもので、監査していない推移的依存を持っています。
  2. メンテナンス負担。 依存パッケージには更新が必要です。更新は破壊的変更や脆弱性をもたらす可能性があります。多ければ多いほど、機能開発ではなく npm audit fix に時間を費やすことになります。
  3. 信頼性リスク。 依存パッケージが壊れれば(leftpad、colors、node-ipc)、サーバーも壊れます。ユーザーの開発ワークフローが止まります。

人間とマシン上でコードを書く AI の間に位置するサーバーにとって、依存パッケージの侵害コストは深刻です。外部依存の適切な数はゼロに可能な限り近づけるべきだと判断しました。

依存なしで構築したもの

HTTP サーバー

Node.js には http.createServer() が付属しています。Express ではありませんが、提供する HTTP エンドポイントはちょうど2つだけです:

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

2ルート。ミドルウェアチェーンなし、ボディパーサーなし、ルーターライブラリなし。リクエストハンドラは URL パスを === でチェックし、req.on('data') でリクエストボディを読み取り、適切なハンドラを呼び出す単一の関数です。

「ウェブスケール」か?いいえ。その必要があるか?これもいいえ。このサーバーは1台の iPhone からの1つの接続を処理します。ルーティングライブラリはエンジニアリングの見せかけにすぎません。

認証

認証システムは API キー検証のために2つのヘッダー形式をサポートしています:

// 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']

Sec-WebSocket-Protocol フォールバックが存在するのは、iOS の URLSessionWebSocketTask がアップグレードリクエストに任意のヘッダーを設定できないためです。これは文書化された iOS の制約です — プロトコルヘッダーは設定できますが、カスタムヘッダーはできません。これを回避する WebSocket ライブラリを導入する代わりに、プロトコルヘッダーを認証チャネルとして使用しています。

認証は接続時に一度だけチェックされます。失敗すると、WebSocket はコード 1008(Policy Violation)でクローズされます。セッショントークンなし、リフレッシュフローなし、JWT ライブラリなし。静的な API キーを === で比較するだけです。

レート制限

インメモリのスライディングウィンドウ、40行のコード:

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

IP あたり毎分30回の認証失敗試行。超過すると、サーバーは retryAfter フィールド付きの 429 Too Many Requests を返します。ウィンドウが期限切れになると、カウンターがリセットされます。

Redis ベースのレートリミッターならより洗練されたものになるでしょう。しかし Redis の依存も追加されます。単一クライアントを処理するサーバーには、定期的なクリーンアップを伴うインメモリの Map が適切です。

入力検証

すべてのユーザー入力はシステムに触れる前に検証されます。検証対象の全体像は以下の通りです:

入力検証理由
セッション名/^[a-zA-Z0-9_-]{1,64}$/シェルメタ文字を含む可能性のある tmux セッション名
Claude コマンド/^claude(\s+--?[a-zA-Z0-9-]+)*$/claude とオプションフラグのみ — パイプ・リダイレクト・チェーンなし
tmux キー名18キーの明示的許可リスト任意のキーストローク注入を防止
ファイルパスpath.resolve() + startsWith()ディレクトリトラバーサルを防止
単一文字/^[a-zA-Z0-9!@#$%^&*()...]$/タイピング用の印字可能 ASCII のみ

バリデーションライブラリなし。正規表現と文字列比較、インラインで読みやすい形式です。

プロセス実行

これがセキュリティ上最も重要な部分です。サーバーはあなたの代わりに tmux コマンドを実行します。このレイヤーに脆弱性があれば、攻撃者がマシン上で任意のコマンドを実行できてしまいます。

私たちの回答:child_process.spawn() に shell: false を付け、すべての外部コマンドが通過する単一の関数でラップしています:

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 は引数がプロセスに直接渡され、/bin/sh を経由しないことを意味します。文字列補間もテンプレートリテラル展開もシェルインジェクションの機会もありません。args 配列として ["send-keys", "-t", sessionName, "-l", content] を渡せば、tmux が受け取る argv はまさにそれです。セミコロン、パイプ、バッククォート、$() — いずれも特殊な意味を持ちません。

すべての tmux 操作はこの関数の薄いラッパーです:

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

tmux ラッパーライブラリなし。ターミナルセッション用の ORM なし。明示的な引数配列を持つ spawn だけです。

パストラバーサル保護

ファイルブラウジングはホームディレクトリツリーへのアクセスに制限されています。機密ディレクトリは常にブロックされます:

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

セグメント単位のマッチングです。パス文字列に対する includes() ではありません。my-.ssh-backup のようなディレクトリ名で誤検出を起こしません。チェックは機密ディレクトリパターンに完全一致する連続したパスセグメントを探します。

すべてのパスはチェック前に path.resolve() で絶対パスに解決されます。これにより .. トラバーサル、シンボリックリンク攻撃、相対パスの悪用が無効化されます。

WebSocket プロトコル

ws パッケージは WebSocket サーバーを提供します。その上に、オリジナルのフラットメッセージ形式と新しい構造化形式の両方を同時に処理するデュアルプロトコルシステムを構築しました。

2つのプロトコルがある理由

最初にフラットプロトコルをリリースしました:

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

動作しますが、リクエストの相関がありません。クライアントが2つのリクエストを連続送信すると、レスポンスは別々のメッセージとして到着し、どのレスポンスがどのリクエストに対応するかクライアントには判別できません。

新しいプロトコルはリクエスト ID を追加します:

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

フラット形式を削除して既存クライアントを壊すわけにはいきません。そこでサーバーは両方を処理します:

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

iOS アプリの WebSocketClient もクライアント側で同じことをしています — 受信メッセージをまず新しい Frame プロトコルとしてデコードし、失敗した場合はレガシーの ServerMessage にフォールバックします。

ハートビート

モバイルの WebSocket 接続は脆弱です。電話は WiFi とセルラーの間を切り替え、スリープに入り、圏外エリアに入ります。切断された接続を素早く検出する必要がありますが、一時的に中断されているだけの接続を切断してはいけません。

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秒間隔、2回の無応答で終了。クライアントは終了されるまで最大90秒間無応答でいられます。これはモバイル向けにチューニングされています — 一般的な WebSocket ハートビートは15〜30秒ですが、モバイルクライアントは基地局の切り替えや WiFi への再接続時に30〜60秒のネットワーク遷移を頻繁に経験します。

WebSocket レベルの ping フレーム(ws ライブラリが自動的に処理)とアプリケーションレベルの {"type": "ping"} メッセージの両方を送信します。アプリケーションレベルの ping は、低レベルの WebSocket フレームに直接アクセスできないクライアント向けです(これも iOS の URLSessionWebSocketTask の制約です)。

備えていないもの

このアーキテクチャに欠けているものを正直に述べます:

クラスタリングなし。 サーバーはシングルプロセス、シングルマシンです。水平スケーリングはできません。その必要もありません — 1台の Mac で1人のユーザーにサービスを提供します。

永続化なし。 サーバーが再起動すると、レートリミッターの状態とクライアントレジストリは失われます。tmux セッションは存続します(独立したプロセスなので)が、サーバーのインメモリ状態は存続しません。私たちのユースケースでは問題ありません — クライアントが再接続して数秒以内に状態を再確立します。

グレースフルシャットダウンのオーケストレーションなし。 サーバーが停止すると、WebSocket 接続はドレインされるのではなく終了されます。ここでも、シングルユーザーサーバーなので問題ありません — クライアントが再接続を処理します。

リクエストログや APM なし。 stdout にログ出力します。それ以上が必要ならファイルにパイプしてください。構造化ログライブラリも、OpenTelemetry 統合も、ログ集約もありません。

これらはすべてマルチテナントクラウドサービスでは理にかなった機能です。デスクの上で動き、あなたの電話にサービスを提供するサーバーには必要ありません。

メンテナンスの実態

リリース以降、依存パッケージのメンテナンスは:

  • セキュリティパッチがあれば ws を更新
  • 以上

推移的依存からの npm audit 警告なし。直接使っていないライブラリの Dependabot PR なし。ミドルウェアフレームワークのメジャーバージョンアップによる破壊的変更なし。

ws ライブラリに脆弱性が見つかった場合(開始以来2回ありました)、1つのパッケージを更新します。変更は小さく、リスクは限定的で、diff は5分で監査できます。

これを典型的な Express + passport + helmet + compression + cors + body-parser スタックと比較してください。それぞれが独自の依存ツリーを持ちます。1回の npm audit で数十の推移的脆弱性がフラグされることがあり、そのほとんどは理論的なものですが、すべて調査が必要です。

このアプローチが適さない場合

念のため明確にしておきます:これは一般的な推奨ではありません。1依存サーバーが機能するのは:

  • 単一ユーザーまたは少数の既知クライアントにサービスを提供する場合
  • HTTP の面が極小の場合(1〜5ルート)
  • データベースアクセス、テンプレート、複雑なルーティングが不要な場合
  • セキュリティがライブラリの積み重ねよりもシンプルさで実現される場合
  • チームが Node.js の組み込み API を読み慣れている場合

ユーザーアカウント、OAuth、データベースモデル、REST API を持つ SaaS を構築するなら、フレームワークを使ってください。適切な ORM を備えた Express や Fastify で何ヶ月もの作業を節約できます。「1依存」アプローチは、依存のコストが実装コストを上回るシステム向けです。

Tactic Remote のサーバー — tmux と iPhone の間の単一ユーザー・単一目的の WebSocket リレー — には、1依存が正しい数字です。


このサーバーがより大きなアーキテクチャにどう組み込まれるかについては、スマートフォンから Claude Code をリモート実行するをご覧ください。WebSocket プロトコルの完全なリファレンスについては、はじめにをご覧ください。

Tactic Remote を試す

スマートフォンからコーディングエージェントを操作

Mac、Windows、Linux 上の Claude Code や Codex などに接続。iPhone や iPad で進捗を確認し、次の指示を送れます。