用 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 协助整理,事实与结论经作者核对。