Superpowers 零依赖 Brainstorm 服务器:单文件 Node.js 内置模块实现 RFC 6455 与文件监听
在 Superpowers 的视觉头脑风暴(visual brainstorming)技能中,Agent 会把 HTML 界面推送到一个本地伴生服务器,由用户在浏览器中实时查看并点击选择。本文基于仓库中的设计规格 zero-dep-brainstorm-server-design.md 与最终落地实现 server.cjs,讲解如何用仅依赖 http、crypto、fs、path 等 Node.js 内置模块的单个文件,替代原先 vendor 进 git 仓库的 714 个 node_modules 文件(express、ws、chokidar),并完整覆盖 WebSocket 协议手工实现、HTTP 路由、文件监听、启动序列与测试策略。读完后你能掌握手写 RFC 6455 文本帧编解码、零依赖 HTTP/WS 服务器的工程实践,以及该伴生服务器在 Superpowers 中的完整生命周期。
背景:为什么要消除 vendored 依赖
Superpowers 的 brainstorm 伴生服务器最初把 express、ws、chokidar 连同完整的 node_modules 一起提交进了 git 仓库。设计文档明确指出这带来供应链风险:
- 冻结的依赖收不到安全补丁——vendor 进来的代码版本定格在提交时刻;
- 714 个第三方文件未经审计就进了仓库,任何对 vendor 代码的修改在 diff 里看起来都像一次普通提交;
- 虽然该服务器只绑定本地回环地址、实际风险很低,但消除它"很直接"(straightforward)。
设计文档给出的前后对比:
| 变更前 | 变更后 |
|---|---|
index.js + package.json + package-lock.json + 714 个 node_modules 文件 |
单个 server.js 文件 |
| express、ws、chokidar 依赖 | 无第三方依赖 |
| 无静态文件服务 | /files/* 从屏幕目录服务静态文件 |
保持不变的边界同样明确:helper.js(浏览器端客户端)、frame-template.html(片段包装模板)不做修改;start-server.sh 只需一行改动(入口从 index.js 改为 server.js,对应实现中为 server.cjs);stop-server.sh、visual-companion.md 及所有对外行为契约保持不变。也就是说,这是一次内部实现替换而非接口重设计——外部契约(stdout 上的 JSON、文件监听行为、事件格式)全部保留。
总体架构:一个文件,两种角色
设计文档规定整个服务器是一个约 250–300 行的 server.js(实际实现为 server.cjs,因后续演进扩展到约 720 行),只使用 Node.js 内置模块,并承担两种角色:
- 直接运行(
node server.js):启动 HTTP/WebSocket 服务器; - 被 require(
require('./server.js')):导出 WebSocket 协议函数供单元测试直接调用。
实现中这一双角色通过文件尾部的守卫与导出体现(server.cjs):
if (require.main === module) {
startServer();
}
module.exports = {
computeAcceptKey,
encodeFrame,
decodeFrame,
browserLauncherForPlatform,
OPCODES,
MAX_FRAME_PAYLOAD_BYTES
};
这个"直接运行即服务、require 即测试库"的模式,正是设计文档中"单元测试通过 require 导出直接测试协议层"的前提:协议函数与 HTTP 服务器彻底解耦,测试无需起任何网络服务。
手写 RFC 6455 WebSocket 协议层
这是本次替换中最硬核的部分:ws 库被一段约 80 行的手工实现取代。设计文档将其拆为握手、帧解码、帧编码、操作码、缓冲累积五个子问题,下面逐一对应到 server.cjs 的实现。
握手:Sec-WebSocket-Accept 的计算
WebSocket 升级握手要求服务端用客户端发来的 Sec-WebSocket-Key 拼接 RFC 6455 的魔法 GUID,做 SHA-1 后 Base64 编码,返回 101 Switching Protocols。实现只有三行(server.cjs#L9-L14):
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
function computeAcceptKey(clientKey) {
return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
}
单元测试 ws-protocol.test.js 直接用 RFC 6455 第 4.2.2 节的官方示例验证:key 为 dGhlIHNhbXBsZSBub25jZQ== 时,accept 值必须精确等于 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=——这是协议实现正确性的黄金校验。
帧解码(客户端 → 服务端):三种长度编码 + 强制掩码
RFC 6455 的帧头 payload 长度有三种编码:小于 126 字节直接写在第二位;126–65535 字节用 126 标记加 16 位扩展长度;超过 65535 字节用 127 标记加 64 位扩展长度。设计文档要求解码函数返回 { opcode, payload, bytesConsumed },缓冲区不足时返回 null(等更多数据),并拒绝未掩码的客户端帧。
decodeFrame 的完整逻辑:
function decodeFrame(buffer) {
if (buffer.length < 2) return null;
const secondByte = buffer[1];
const opcode = buffer[0] & 0x0F;
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7F;
let offset = 2;
if (!masked) throw new Error('Client frames must be masked');
if (payloadLen === 126) {
if (buffer.length < 4) return null;
payloadLen = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLen === 127) {
if (buffer.length < 10) return null;
const extendedLen = buffer.readBigUInt64BE(2);
if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) {
throw new Error('WebSocket frame payload exceeds maximum allowed size');
}
payloadLen = Number(extendedLen);
offset = 10;
}
const maskOffset = offset;
const dataOffset = offset + 4;
const totalLen = dataOffset + payloadLen;
if (buffer.length < totalLen) return null; // 不完整缓冲区
const mask = buffer.slice(maskOffset, dataOffset);
const data = Buffer.alloc(payloadLen);
for (let i = 0; i < payloadLen; i++) {
data[i] = buffer[dataOffset + i] ^ mask[i % 4]; // XOR 解掩码
}
return { opcode, payload: data, bytesConsumed: totalLen };
}
注意两个设计细节:
- 不完整与非法用不同信号区分——缓冲区不足返回
null(调用方等下一段数据),协议违规(未掩码、超尺寸)抛异常(调用方断开连接)。实现还比设计文档多了一道防线:64 位扩展长度超过MAX_FRAME_PAYLOAD_BYTES(10 MB)时在分配 payload 缓冲之前就拒绝,避免恶意超大声明导致内存分配; bytesConsumed让调用方精确知道消耗了多少字节,从而支持一个 TCP 数据包里粘连多个帧的情况。
单元测试 ws-protocol.test.js 为此构造了一个 makeClientFrame 辅助函数生成带随机掩码的客户端帧,覆盖:三种长度编码的解码、125/126 与 65535/65536 边界值、截断帧返回 null、未掩码帧抛异常、单缓冲区多帧、固定掩码字节(FF 00 AA 55)验证 XOR 运算正确性。
帧编码(服务端 → 客户端)
服务端帧与客户端帧同构,只是不设掩码位。encodeFrame 按 126/65536 两个阈值选择 2 字节、4 字节或 10 字节头,FIN 位固定为 0x80(本片即完整消息):
if (len < 126) {
header = Buffer.alloc(2);
header[0] = fin | opcode;
header[1] = len;
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = fin | opcode;
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = fin | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
测试中有一条专门断言"服务端帧永不带掩码位"(frame[1] & 0x80 === 0,见 ws-protocol.test.js#L139-L143),这是 RFC 6455 的硬性要求。
操作码处理
设计文档规定只处理四种操作码,其余一律以状态码 1003(Unsupported Data)关闭。handleUpgrade 中的分发逻辑:
switch (result.opcode) {
case OPCODES.TEXT: handleMessage(result.payload.toString()); break;
case OPCODES.CLOSE: socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0))); clients.delete(socket); return;
case OPCODES.PING: socket.write(encodeFrame(OPCODES.PONG, result.payload)); break;
case OPCODES.PONG: break;
default: {
const closeBuf = Buffer.alloc(2);
closeBuf.writeUInt16BE(1003);
socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
clients.delete(socket);
return;
}
}
有意跳过与缓冲累积
设计文档明确列出了"deliberately skipped"清单:二进制帧、分片消息、扩展(permessage-deflate)、子协议——理由是对本地回环客户端之间的小 JSON 文本消息没有必要。其中扩展和子协议还有个精巧的规避方式:它们在握手阶段协商,服务端只要不宣告,就永远不会被激活,一行代码都不用写。
缓冲累积方面,每个连接维护一个 Buffer,data 事件到来时追加并循环调用 decodeFrame 直到返回 null 或缓冲区清空——对应 server.cjs#L461-L497 中 while (buffer.length > 0) 的解帧循环,buffer = buffer.slice(result.bytesConsumed) 完成帧消费。
HTTP 服务器:三个路由
设计文档规定 HTTP 层只有三个路由,WebSocket 升级通过 HTTP 服务器的 'upgrade' 事件独立处理,不混入请求处理器。
GET / —— 服务最新的屏幕
行为链条:
- 从屏幕目录中按 mtime 选出最新的
.html文件; - 区分完整文档与片段:以
<!doctype或<html开头的按原文返回,否则用frame-template.html包装(替换<!-- CONTENT -->占位符); - 注入
helper.js内容(</body>前插入); - 没有
.html文件时,返回硬编码的等待页("Waiting for the agent to push a screen..."),同样注入 helper.js。
handleRequest 中核心几行:
const screenFile = getNewestScreen();
let html = screenFile
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
: waitingPage();
if (html.includes('</body>')) {
html = html.replace('</body>', helperInjection + '\n</body>');
} else {
html += helperInjection;
}
getNewestScreen(server.cjs#L267-L278)过滤掉点文件并校验候选文件确实是内容目录内的常规文件(防符号链接/硬链接逃逸,后文详述),再按 mtime.getTime() 降序取第一个。"按 mtime 选最新"意味着 Agent 只需不断写新文件,服务器自动展示最新屏幕——这是设计文档隐含的核心工作流。
GET /files/* —— 静态文件
从屏幕目录提供静态文件,MIME 类型来自硬编码扩展名映射。实现的 MIME_TYPES 覆盖了设计文档列出的 html/css/js/png/jpg/gif/svg/json(并补了 jpeg),未知扩展回落 application/octet-stream,找不到返回 404。
其余路径 —— 404
未匹配任何路由的请求一律 404。server.on('upgrade', handleUpgrade) 注册在 startServer 中,与请求处理器完全分离,正如设计文档所述。
配置:环境变量
设计文档定义了四个可选环境变量,实现全部保留并做了向后兼容的扩展:
| 变量 | 作用 | 默认值 |
|---|---|---|
BRAINSTORM_PORT |
绑定端口 | 随机高位端口 49152–65535 |
BRAINSTORM_HOST |
绑定接口 | 127.0.0.1 |
BRAINSTORM_URL_HOST |
启动 JSON 中 URL 的主机名 | host 为 127.0.0.1 时取 localhost,否则同 host |
BRAINSTORM_DIR |
屏幕目录路径 | /tmp/brainstorm |
const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
随机高位端口的实现是一行区间计算(server.cjs#L86):49152 + Math.floor(Math.random() * 16383),即 IANA 规定的动态/私有端口范围 49152–65534,避开常见固定端口冲突。
从源码结构看,实现还引入了设计文档之后的扩展变量,用于会话恢复与生命周期:BRAINSTORM_PORT_FILE(重启时复用上次绑定端口,让已打开的浏览器标签页无缝重连)、BRAINSTORM_TOKEN_FILE(持久化会话密钥)、BRAINSTORM_OWNER_PID(监听宿主进程存活)、BRAINSTORM_IDLE_TIMEOUT_MS / BRAINSTORM_LIFECYCLE_CHECK_MS(空闲超时,默认 4 小时;巡检间隔默认 60 秒)。这些是同一服务器在后续加固迭代中的增量,属于"当前仓库实际内容",使用时以 start-server.sh 的注释为准。
启动序列与 server-started 契约
设计文档规定六步启动序列,实现(startServer 与 onListen)逐条对应:
- 创建屏幕目录——
fs.mkdirSync(dir, { recursive: true }),实现中同时创建content/与state/两个子目录; - 从
__dirname加载模板——frame-template.html与helper.js在服务启动时一次性读入内存(server.cjs#L202-L204); - 在配置的 host/port 启动 HTTP 服务器;
- 启动
fs.watch监听屏幕目录; - listen 成功后向 stdout 输出
server-startedJSON:{ type, port, host, url_host, url, screen_dir }; - 把同样的 JSON 写入
screen_dir/.server-info——因为服务器常以后台方式启动、stdout 被重定向到日志,Agent 可以从文件里拿到连接信息(当前实现写入 state/server-info,权限 0o600,因为其中含会话密钥)。
第 5/6 步是服务器与 Agent 之间的核心契约:start-server.sh 通过 grep -q "server-started" "$LOG_FILE" 轮询日志文件(最多 50 次 × 0.1 秒)确认服务器就绪,然后把该行 JSON 输出给调用方;集成测试 server.test.js 则断言 stdout 行与 state/server-info 文件内容一致、字段齐全。
应用层 WebSocket 消息与文件监听
用户事件回传
浏览器端 helper.js 通过 WebSocket 把用户点击、选择发回服务器。设计文档规定 TEXT 帧到达时的处理:解析 JSON,失败则记 stderr 并继续;成功则向 stdout 记录 { source: 'user-event', ...event };若事件含 choice 属性,追加一行 JSON 到 SCREEN_DIR/.events。handleMessage 与之一致(事件文件位于 state/events):
console.log(JSON.stringify({ source: 'user-event', ...event }));
if (event && event.choice) {
const eventsFile = path.join(STATE_DIR, 'events');
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
}
这条链路完成了"用户点选 → Agent 读取决策"的闭环:Agent 轮询日志或事件文件即可知道用户选了哪个选项。
文件监听:fs.watch 替代 chokidar
设计文档规定用 fs.watch(SCREEN_DIR) 替代 chokidar,按文件名做约 100 ms 防抖(macOS 和 Linux 上常见重复事件),并区分两类事件:
- 新文件:删除
.events文件(如存在),stdout 记screen-added; - 文件变更:stdout 记
screen-updated(不清空.events); - 两者都:向所有已连接 WebSocket 客户端广播
{ type: 'reload' }。
实现见 server.cjs#L590-L613,其中有一个设计文档没有明说但实测必要的技巧:fs.watch 在 macOS 上新文件和覆盖写都报 rename,不能只靠 eventType 区分新旧,因此启动时先扫描一遍目录建立 knownFiles 集合——首次见到的文件名即"新屏幕",之后出现即"更新"。广播函数 broadcast 把消息编码为 TEXT 帧后遍历 clients 集合逐个写入,写失败即移除该 socket,对应设计文档"客户端断开时从广播集合移除"的要求。
错误处理策略
设计文档列出的五类错误处理在实现中逐一可见:
| 情形 | 处理 | 实现位置 |
|---|---|---|
| 客户端发来非法 JSON | 记 stderr,继续运行 | handleMessage |
| 未识别操作码 | 以状态码 1003 关闭 | handleUpgrade |
| 客户端断开 | 从广播集合移除 | socket.on('close', ...)(server.cjs#L499-L500) |
fs.watch 错误 |
记 stderr,继续 | watcher.on('error', ...)(server.cjs#L614) |
| 进程生命周期 | 设计阶段交由 shell 脚本经 SIGTERM 管理 | start-server.sh / stop-server.sh |
关于最后一条,仓库现状比设计时更进一步:服务器内部现在有一个生命周期巡检定时器,宿主进程(BRAINSTORM_OWNER_PID)死亡或空闲超时即自行 shutdown;而 stop-server.sh 在按 PID 发信号前,会先核对目标进程命令行中带有本次启动的 --brainstorm-server-id 实例标识,防止陈旧 PID 文件(重启、PID 复用场景)误杀无关进程。脚本层先 kill、等约 2 秒、仍存活再 kill -9 的"优雅优先"策略与 SIGTERM 约定一致。
测试策略:协议单元测试 + 服务器集成测试
设计文档的测试方案在 tests/brainstorm-server/ 中完整落地,npm test 依次执行 8 个测试文件:
单元测试(ws-protocol.test.js):通过 require('../../skills/brainstorming/scripts/server.cjs') 直接调用导出的 computeAcceptKey / encodeFrame / decodeFrame,不需要启动任何服务器。407 行测试覆盖握手 RFC 官方示例、三种长度编码及全部边界值(125/126、65535/65536)、截断帧、未掩码帧拒绝、超大 64 位声明提前拒绝、close 帧状态码解析、JSON 往返、"单缓冲区多帧"等协议边缘情况。文件头注释还记录了它的 TDD 来源——测试先于实现存在,模块不存在时明确提示"这是预期的"。
集成测试(server.test.js):真正 spawn 起服务器(固定端口 3334、固定 token),覆盖启动 JSON 契约、等待页与 helper 注入、完整文档不包装/片段包装、按 mtime 取最新、macOS ._*.html 资源叉点文件被忽略、/files/ 的 404 与空名安全处理、符号链接/硬链接逃逸拒绝、WebSocket 升级与用户事件转发等完整工作流。这里 ws npm 包仅作为测试客户端依赖(见 package.json),随测试目录安装,不随技能发布——正是设计文档"test-only dependency (not shipped to end users)"的落法。
此外还有 helper.test.js(客户端重连退避)、auth.test.js、lifecycle.test.js、start-server.test.sh、stop-server.test.sh 等配套测试,run-all 顺序写在 test 脚本里,构成对该单文件服务器的完整回归面。
平台兼容性与演进边界
设计文档的兼容性结论:server.js 只用跨平台 Node 内置模块;fs.watch 在 macOS、Linux、Windows 上对单一扁平目录可靠(本服务器的屏幕目录正是扁平的,无递归监听需求);shell 脚本要求 bash,Windows 上用 Git Bash(Claude Code 的前置要求)。start-server.sh 中有对应的平台适配:检测到 Git Bash/MSYS 环境时自动切前台模式(Windows 会回收 nohup 后台进程)、清空 OWNER_PID(Node 无法验证 MSYS2 命名空间的 PID)。
值得注意的演进边界:设计文档描述的是"零依赖 + 本地回环"的基线;当前仓库中的 server.cjs 在此之上叠加了会话密钥认证(?key= 查询参数 + HttpOnly cookie + 恒定时间比较)、安全响应头(X-Frame-Options: DENY、CSP 等)、WebSocket Origin 校验与基于 realpath/nlink 的内容目录逃逸防护(isRegularFileInsideContentDir)。这些加固由同目录规格 2026-06-10-visual-companion-auth-hardening-design.md 等后续文档描述,本文不展开,但读源码时应知道:认证层是设计基线之后的增量,而非零依赖方案的一部分。
小结:这套方案的可迁移价值
把设计文档的要点浓缩成三条可复用的工程经验:
- 协议库可以"最小化实现"——只要你的业务只走文本帧、本地回环,RFC 6455 的核心(握手 + 三种长度编码 + 掩码 + 四个操作码)就是 80 行代码,且"不宣告扩展"本身就是零成本的安全决策;
- 单文件双角色(run 即服务 / require 即测试库) 让协议层可被纯函数测试穷尽,配合集成测试用真实库当客户端,形成零第三方依赖的完整测试闭环;
- stdout JSON + 磁盘 info 文件双通道是"服务器供 Agent 使用"的可靠契约——stdout 可能被吞,文件不会。
对 Superpowers 而言,这次替换用不到 720 行的 server.cjs 换掉了 714 个 vendor 文件,外部契约一行未变,这正是设计文档"What Stays the Same"一节承诺的边界——也是零依赖重构应当追求的典型形态。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00