博客
一个依赖:仅用 ws 包构建生产级服务器
我们的 Node.js 服务器只有一个运行时依赖。一个。这里解释我们为什么做出这个选择,以及它对可靠性、安全性和维护意味着什么。
继续查看该栏目下的产品与工程更新。
浏览此分类打开 Tactic Remote 服务器的 package.json。在 dependencies 下,你只会看到一个条目:
就这些。一个包。ws WebSocket 库。其他一切——HTTP 服务器、认证、速率限制、终端管理、文件浏览、输入校验、进程执行——都基于 Node.js 内置模块。
这不是偶然的极简主义。这是一个经过深思熟虑的工程决策,在可靠性、安全性以及使用我们软件的用户体验方面都有实际的影响。
为什么这很重要
这个服务器运行在你的 Mac 上,管理你的 Claude Code 会话。它可以访问你的终端、文件系统和正在运行的进程。你添加的每一个依赖都是:
- 一个攻击面。 每个 npm 包都是以你的权限运行的代码,由你不认识的人编写,带有你未审计过的传递性依赖。
- 一个维护负担。 依赖需要更新。更新可能引入破坏性变更或漏洞。依赖越多,你花在
npm audit fix上的时间就越多,而不是在构建功能。 - 一个可靠性风险。 当依赖出问题(leftpad、colors、node-ipc),你的服务器就会出问题。你用户的开发工作流就会中断。
对于一个介于人类和 AI 之间、在用户机器上编写代码的服务器来说,依赖被攻破的代价是严重的。我们决定外部依赖的正确数量应该尽可能接近零。
不用依赖我们构建了什么
HTTP 服务器
Node.js 自带 http.createServer()。它不是 Express,但我们只需要提供两个 HTTP 端点:
两个路由。没有中间件链,没有请求体解析器,没有路由库。请求处理器是一个简单的函数,用 === 检查 URL 路径,用 req.on('data') 读取请求体,然后调用对应的处理函数。
这是"可扩展"的吗?不是。需要吗?也不需要。这个服务器只处理来自一台 iPhone 的一个连接。用路由库纯粹是工程表演。
认证
认证系统支持两种请求头格式来验证 API Key:
Sec-WebSocket-Protocol 回退的存在是因为 iOS URLSessionWebSocketTask 无法在升级请求中设置自定义头。这是一个有文档记录的 iOS 限制——你可以设置协议头,但不能设置自定义头。与其引入一个绕过此限制的 WebSocket 库,我们直接将协议头用作认证通道。
认证在连接时检查一次。如果失败,WebSocket 以代码 1008(策略违规)关闭。没有会话令牌,没有刷新流程,没有 JWT 库。一个静态 API Key,用 === 比较。
速率限制
一个内存中的滑动窗口,40 行代码:
每个 IP 每分钟 30 次失败认证尝试。超过后,服务器返回 429 Too Many Requests 并带有 retryAfter 字段。窗口过期后,计数器重置。
基于 Redis 的速率限制器会更复杂精细。但它也会增加一个 Redis 依赖。对于只处理一个客户端的服务器来说,一个内存中的 Map 加上定期清理就足够了。
输入校验
每个用户输入在接触系统之前都会被校验。以下是完整的校验面:
| 输入 | 校验 | 原因 |
|---|---|---|
| 会话名 | /^[a-zA-Z0-9_-]{1,64}$/ | 可能包含 shell 元字符的 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,封装在一个所有外部命令都必须经过的单一函数中:
shell: false 意味着参数直接传递给进程,不经过 /bin/sh。没有字符串插值,没有模板字面量展开,没有 shell 注入的机会。如果你将 ["send-keys", "-t", sessionName, "-l", content] 作为 args 数组传递,那就是 tmux 接收到的 argv。分号、管道、反引号、$()——它们都没有特殊含义。
每个 tmux 操作都是对这个函数的薄封装:
没有 tmux 封装库。没有终端会话的 ORM。只有 spawn 配合显式的参数数组。
路径遍历保护
文件浏览限制在主目录树内。敏感目录始终被阻止:
逐段匹配。不是对路径字符串使用 includes()——那样会对名为 my-.ssh-backup 的目录产生误报。检查会寻找与敏感目录模式精确匹配的连续路径段。
所有路径在检查之前都通过 path.resolve() 解析为绝对路径。这可以消除 .. 遍历、符号链接攻击和相对路径技巧。
WebSocket 协议
ws 包为我们提供了 WebSocket 服务器。在此基础上,我们构建了一个双协议系统,同时处理我们最初的扁平消息格式和较新的结构化格式。
为什么有两种协议
我们最初发布的是扁平协议:
它能工作,但没有请求关联。如果客户端连续发送两个请求,响应会作为独立的消息到达,客户端无法判断哪个响应对应哪个请求。
新协议添加了请求 ID:
我们不能通过移除扁平格式来破坏现有客户端。所以服务器同时处理两种格式:
iOS 应用的 WebSocketClient 在客户端做了同样的事情——先尝试将收到的消息解码为新的 Frame 协议,如果失败则回退到旧版 ServerMessage。
心跳
移动端 WebSocket 连接是脆弱的。手机会在 WiFi 和蜂窝网络之间切换,进入睡眠状态,进入信号死区。我们需要快速检测死连接,但不能杀掉只是暂时中断的连接。
45 秒间隔,2 次未响应容忍。一个客户端最多可以在 90 秒内无响应才会被终止。这是为移动端调优的——典型的 WebSocket 心跳间隔是 15-30 秒,但移动客户端在切换基站或重新关联 WiFi 时经常经历 30-60 秒的网络切换。
我们同时发送 WebSocket 层级的 ping 帧(ws 库自动处理)和应用层级的 {"type": "ping"} 消息。应用层级的 ping 是为那些无法直接访问底层 WebSocket 帧的客户端准备的(这是 iOS URLSessionWebSocketTask 的另一个限制)。
我们没有的东西
坦诚地说说这个架构缺少什么:
没有集群。 服务器是单进程、单机器的。它无法水平扩展。但也不需要——它只服务一台 Mac 上的一个用户。
没有持久化。 如果服务器重启,速率限制器状态和客户端注册表会丢失。tmux 会话能幸存(它们是独立进程),但服务器的内存状态不会。对我们的场景来说没问题——客户端会在几秒内重新连接并重建状态。
没有优雅关闭编排。 服务器停止时,WebSocket 连接是直接终止的,而不是排空的。同样,对于单用户服务器来说没问题——客户端会处理重连。
没有请求日志或 APM。 我们输出到标准输出。如果你需要更多,可以管道到文件。没有结构化日志库,没有 OpenTelemetry 集成,没有日志聚合。
这些都是对多租户云服务有意义的功能。对于运行在你桌上、服务你手机的服务器来说,它们没有意义。
维护现实
自上线以来,我们的依赖维护工作是:
- 当有安全补丁时更新
ws - 没了
没有来自传递性依赖的 npm audit 警告。没有我们不直接使用的库的 Dependabot PR。没有中间件框架大版本升级带来的破坏性变更。
当 ws 库有漏洞时(自我们开始以来发生过两次),我们更新一个包。变更很小,风险可控,我们可以在五分钟内审计完差异。
对比一下典型的 Express + passport + helmet + compression + cors + body-parser 技术栈。每一个都带来自己的依赖树。一次 npm audit 可能标记数十个传递性漏洞,其中大部分是理论上的,但每个都需要调查。
这种方式什么时候不适用
需要说清楚:这不是一个通用建议。单依赖服务器适用于以下场景:
- 你服务的是单个用户或少量已知客户端
- 你的 HTTP 表面很小(1-5 个路由)
- 你不需要数据库访问、模板引擎或复杂路由
- 安全性更适合通过简单性而非层叠库来实现
- 你的团队能够熟练阅读 Node.js 内置 API
如果你在构建一个有用户账户、OAuth、数据库模型和 REST API 的 SaaS,请使用框架。Express 或 Fastify 配合合适的 ORM 会为你节省数月的工作。"单依赖"方案适用于依赖成本超过实现成本的系统。
对于 Tactic Remote 的服务器——一个单用户、单用途、在 tmux 和 iPhone 之间的 WebSocket 中继——一个依赖就是正确的数量。
了解这个服务器如何融入整体架构,参阅从手机远程运行 Claude Code。完整的 WebSocket 协议参考,参阅快速入门指南。