首页
/ Superpowers 零依赖 Brainstorm 服务器:单文件 Node.js 内置模块实现 RFC 6455 与文件监听

Superpowers 零依赖 Brainstorm 服务器:单文件 Node.js 内置模块实现 RFC 6455 与文件监听

2026-09-04 16:27:32作者:柏廷章Berta

在 Superpowers 的视觉头脑风暴(visual brainstorming)技能中,Agent 会把 HTML 界面推送到一个本地伴生服务器,由用户在浏览器中实时查看并点击选择。本文基于仓库中的设计规格 zero-dep-brainstorm-server-design.md 与最终落地实现 server.cjs,讲解如何用仅依赖 httpcryptofspath 等 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.shvisual-companion.md 及所有对外行为契约保持不变。也就是说,这是一次内部实现替换而非接口重设计——外部契约(stdout 上的 JSON、文件监听行为、事件格式)全部保留。

总体架构:一个文件,两种角色

设计文档规定整个服务器是一个约 250–300 行的 server.js(实际实现为 server.cjs,因后续演进扩展到约 720 行),只使用 Node.js 内置模块,并承担两种角色:

  • 直接运行node server.js):启动 HTTP/WebSocket 服务器;
  • 被 requirerequire('./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 };
}

注意两个设计细节:

  1. 不完整与非法用不同信号区分——缓冲区不足返回 null(调用方等下一段数据),协议违规(未掩码、超尺寸)抛异常(调用方断开连接)。实现还比设计文档多了一道防线:64 位扩展长度超过 MAX_FRAME_PAYLOAD_BYTES(10 MB)时在分配 payload 缓冲之前就拒绝,避免恶意超大声明导致内存分配;
  2. 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-L497while (buffer.length > 0) 的解帧循环,buffer = buffer.slice(result.bytesConsumed) 完成帧消费。

HTTP 服务器:三个路由

设计文档规定 HTTP 层只有三个路由,WebSocket 升级通过 HTTP 服务器的 'upgrade' 事件独立处理,不混入请求处理器。

GET / —— 服务最新的屏幕

行为链条:

  1. 从屏幕目录中按 mtime 选出最新的 .html 文件;
  2. 区分完整文档片段:以 <!doctype<html 开头的按原文返回,否则用 frame-template.html 包装(替换 <!-- CONTENT --> 占位符);
  3. 注入 helper.js 内容(</body> 前插入);
  4. 没有 .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;
}

getNewestScreenserver.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

对应 server.cjs#L100-L102

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 契约

设计文档规定六步启动序列,实现(startServeronListen)逐条对应:

  1. 创建屏幕目录——fs.mkdirSync(dir, { recursive: true }),实现中同时创建 content/state/ 两个子目录;
  2. __dirname 加载模板——frame-template.htmlhelper.js 在服务启动时一次性读入内存(server.cjs#L202-L204);
  3. 在配置的 host/port 启动 HTTP 服务器
  4. 启动 fs.watch 监听屏幕目录
  5. listen 成功后向 stdout 输出 server-started JSON{ type, port, host, url_host, url, screen_dir }
  6. 把同样的 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/.eventshandleMessage 与之一致(事件文件位于 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.jslifecycle.test.jsstart-server.test.shstop-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 等后续文档描述,本文不展开,但读源码时应知道:认证层是设计基线之后的增量,而非零依赖方案的一部分

小结:这套方案的可迁移价值

把设计文档的要点浓缩成三条可复用的工程经验:

  1. 协议库可以"最小化实现"——只要你的业务只走文本帧、本地回环,RFC 6455 的核心(握手 + 三种长度编码 + 掩码 + 四个操作码)就是 80 行代码,且"不宣告扩展"本身就是零成本的安全决策;
  2. 单文件双角色(run 即服务 / require 即测试库) 让协议层可被纯函数测试穷尽,配合集成测试用真实库当客户端,形成零第三方依赖的完整测试闭环;
  3. stdout JSON + 磁盘 info 文件双通道是"服务器供 Agent 使用"的可靠契约——stdout 可能被吞,文件不会。

对 Superpowers 而言,这次替换用不到 720 行的 server.cjs 换掉了 714 个 vendor 文件,外部契约一行未变,这正是设计文档"What Stays the Same"一节承诺的边界——也是零依赖重构应当追求的典型形态。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384