keepbx.cn
← 目录

用 225 行 Node 写一个够用的静态服务器

这个站是纯静态的:构建完只有几张 HTML、一份 CSS、两套字体子集和几张图。上线时遇到一个很窄的问题——容器里不想再装 nginx,也不想为了发文件多引一个运行时依赖。

于是写了一个 225 行的 static.mjs,只用 Node 标准库。它不解析模板、不做路由、不碰数据库,只负责把构建产物按正确的类型、缓存和状态码发出去。

这篇文章拆开讲它做的四件事,以及一件同样重要的事:什么时候这 225 行就不够用了。

约束

先把边界说清楚:

  • 只有 GET 和 HEAD;没有表单、没有 API。
  • 构建产物不可变,文件名带内容哈希;HTML 每次发布都会变。
  • 容器根文件系统只读,只有临时目录可写;进程收到 SIGTERM 要能退出。
  • 站点放在一层反向代理后面,TLS、HTTP/2、限流都不在它这一层。
  • 不引第三方依赖,不装 nginx。

这些约束决定了后面的每个选择。约束变了,代码就该重写,而不是继续加开关。

一、类型与缓存

第一件事是别让浏览器猜类型。MIME 表二十项左右,未知扩展名一律回退到 application/octet-stream,同时所有响应都带 X-Content-Type-Options: nosniff。类型错一次,浏览器就可能把 CSS 当纯文本、把 SVG 当 HTML 执行。

缓存按「这个文件会不会变」分四档:

路径特征 Cache-Control 理由
带内容哈希的静态资源 public, max-age=31536000, immutable 内容变了文件名就变
字体子集 public, max-age=604800 不常变,但会随文章标题重建
图片与图标 public, max-age=2592000 同上
HTML、robots no-cache / 1 小时 每次发布都可能变

这里有一个容易理解反的点:HTML 用 no-cache,不是「不缓存」,而是「可以存,但每次用之前问一下」。配合 ETag,问的结果通常是 304,没有响应体。

二、条件请求

ETag 的生成只用文件元数据:

const etag = `"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`;

不读文件内容。对静态站来说,size 和 mtime 同时不变而内容变了,几乎只可能发生在构建系统出错时;这个概率可以接受,换来的是每个请求少一次读盘。

顺序也重要:先比 If-None-Match,命中就 304 返回,既不压缩也不读文件。

if (req.headers["if-none-match"] === etag) {
  res.writeHead(304);
  res.end();
  return;
}

同时发 Last-Modified,但它只作为元数据:这个服务只按 ETag 判 304,不处理 If-Modified-Since。

三、压缩

只压文本类,且只压 512 B 以上的:太小的文件压完更大,字体和图片本身已压缩,再压是浪费 CPU。

const wantsGzip =
  COMPRESSIBLE.has(ext) &&
  info.size >= 512 &&
  /\bgzip\b/.test(req.headers["accept-encoding"] || "");

压完的结果按文件路径缓存在一个 Map 里。静态站文件数很少,这份缓存不会成为问题;代价是它没有淘汰机制,重新构建后旧条目会留到进程重启。这是有意接受的——重启一次的成本可以忽略。

所有响应都带 Vary: Accept-Encoding,否则中间缓存可能把 gzip 响应发给不支持的客户端。

四、健康检查与退出

/healthz 返回一行 ok,no-store,不查任何依赖。它的作用只是告诉外层反代「进程还活着」,不该顺手检查磁盘或网络——健康检查自己变成故障源,是更常见的坑。

退出要处理 SIGTERM:先停止接收新连接,给在途请求一点时间,5 秒后强制退出。

server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000).unref();

unref() 让这个定时器不阻止进程自然退出。容器编排器通常只给十秒左右,5 秒是一个保守值。

两个安全细节

路径穿越:先解码,再拒绝 NUL,再 path.resolve,最后用 path.relative 判断结果不能以 .. 开头、也不能是绝对路径。顺序不能反:先 resolve 再解码,%2e%2e 就漏过去了。

pathname = decodeURIComponent(url.pathname);
if (pathname.includes("\0")) return notFound(req, res);
const filePath = path.resolve(ROOT, `.${pathname}`);
const rel = path.relative(ROOT, filePath);
if (rel.startsWith("..") || path.isAbsolute(rel)) return notFound(req, res);

响应头:CSP、nosniff、Referrer-Policy、X-Frame-Options 在一个函数里统一加,路由之前就设好。CSP 里 connect-src 'none'、frame-ancestors 'none',因为页面确实不需要发请求、也不该被嵌。

还有一个细节:目录请求少斜杠时返回 301 补上;无扩展名的路径找不到文件时,回退尝试 .html。前者保证相对链接正确,后者让干净的 URL 也能工作。

实测

本机跑构建产物,用 curl 测:

项目 数字
服务端源码 225 行(含注释与空行),只用 Node 标准库
首页 HTML(本文发布后) 7,214 B → gzip 约 2.8 KB(38%)
CSS 13,110 B → gzip 3,753 B(29%)
JS 0 B
字体子集 41,100 B(宋体 13,600 + 楷体 27,500)
首字节(本机回环,5 次) 0.70–0.92 ms
镜像 172 MB,其中站点文件不到 500 KB

304、301、405、/healthz 都单独验过:带 If-None-Match 返回 304;目录少斜杠返回 301;POST 返回 405 并带 Allow: GET, HEAD。

宋体子集这次从 7,900 B 涨到 13,600 B——文章标题也要进子集,这是这套方案唯一的日常维护成本。

镜像大是 Node 基础镜像的重量,不是站点的。可以换更小的运行时,代价是失去用 node -e 做健康检查的便利——这是一个可以单独讨论的取舍。

为什么不直接用 nginx

nginx 能做的远不止这些:HTTP/2、TLS、sendfile、Range、限流、多站点、复杂 location。这个场景只需要四件事,而多一个进程就多一份配置、日志、升级和故障面。

判断标准不是「谁更强」,而是「故障时能不能一个人看完」。225 行一夜能读完;nginx 的配置和模块行为,出问题时往往要靠搜索和试。

也不建议用现成的静态服务包:它们能跑,但缓存头、压缩阈值、404 回退往往不可控;为了省两百行引入一棵依赖树,不划算。

什么时候该换:

  • 需要 Range(大文件、视频);
  • 需要 HTTP/2 或 TLS 在本层终止;
  • 单机多站点、复杂重写;
  • 需要限流、鉴权或 WAF;
  • 流量大到单进程成为瓶颈(这个站的量级远不到)。

已知限制

  • 不支持 Range;大文件只能整个下载。
  • gzip 缓存没有淘汰,重新构建后旧条目留到重启。
  • 单进程、单线程事件循环;CPU 密集的压缩会阻塞(本站文件小,可忽略)。
  • 只处理 If-None-Match,不处理 If-Modified-Since。
  • 只做 HTTP/1.1;HTTP/2 交给外层。
  • 没有访问日志轮转;日志交给容器运行时。
  • 缓存策略写死在代码里;改策略要改代码,不是改配置。

这些不是「以后再做」的清单,而是当前边界。写清楚边界,比假装什么都能做更有用。

结尾

225 行里没有一行是「以防万一」。每一行都对应一个具体约束:只读容器、带哈希的文件名、外层反代、SIGTERM。

静态站的服务端可以很小,但小不等于简陋:类型、缓存、条件请求、压缩、健康检查、退出、路径安全,一个都不能少。把这些做对,剩下的交给反向代理和 CDN。


初稿由 AI 协助整理,事实与结论经作者核对。