Node 适配器(s200/node)
在 Node 的 http/https/http2 服务器上服务应用。
serve(app, options?) → Promise<NodeServer>
import { serve } from 's200/node';
const server = await serve(app, { port: 3000 });
console.log(server.url); // http://127.0.0.1:3000
await server.close();NodeServeOptions:
| 字段 | 默认 | 含义 |
|---|---|---|
port | 0(临时) | 监听端口 |
host | — | 主机名 |
upgrade | — | WebSocket 'upgrade' 回调 —— 传 s200/websocket/node 的 createUpgradeHandler(app)(见 s200/websocket) |
light | false | 启用快速路径:适配器构造轻量 Request/Response 对象(不打全局补丁;handler 仍见 Web Standard 形状)。注意:clone() 仅对字节支撑体有效;奇异 BodyInit(Blob/FormData)需一次真实 Response 构造 |
abortOnDisconnect | true | 客户端在响应完成前断开时中止 ctx.signal;false 跳过每请求 AbortController |
https | — | NodeHttpsOptions:经 node:https 提供 TLS,或 http2: true 走 HTTP/2-over-TLS(不支持明文 h2c;HTTP/2 无 upgrade 事件,故 http2: true + upgrade 在 serve 时抛错 —— WSS 需要 HTTP/1.1-over-TLS) |
server | — | NodeServerTunables:精选的 node 服务器旋钮(maxHeaderSize、requestTimeout、headersTimeout、keepAliveTimeout、connectionsCheckingInterval、keepAlive、noDelay、highWaterMark —— 每条路径的构造器选项;maxConnections(每条路径)与 maxRequestsPerSocket(仅 HTTP/1.1)为构造后的 setter;backlog 经 listen 传入)—— 见服务器旋钮 |
closeGraceMs | 10_000 | 优雅关闭预算(ms)。close() 停止接受新连接、销毁空闲 keep-alive socket、放行在途响应 —— 最后一个落地即 resolve。预算耗尽时仍未结束的连接被硬杀(closeAllConnections(),外加适配器自行登记的 upgraded/websocket socket 与 HTTP/2 会话)。0 立即硬杀:调用 close() 的那一刻在途响应即死 |
clientError | — | (err, socket) => void,接到服务器的 'clientError' 事件 —— 畸形请求进入你的钩子而非 node 默认行为(销毁肇事 socket)。不传时 node 自身行为原样保留 |
NodeServer:
| 字段 | 含义 |
|---|---|
server | 原始 NodeServerKind(HttpServer | HttpsServer | Http2SecureServer)—— s200/lifecycle 的优雅排空面 |
url | http://host:port |
port | 已绑定端口 |
close() | 默认优雅:停止接受新连接,在 closeGraceMs 内排空在途请求后 resolve(见下) |
NodeUpgradeHandler:node:http 'upgrade' 事件签名 (req, socket, head) => void。
优雅关闭
close() 默认优雅 —— 即 s200/lifecycle 与分片执行器共用的排空模式:
- 停止接受 ——
server.close():自此刻起新连接一律拒绝。 - 销毁空闲 keep-alive socket,并在此后排空中反复扫(响应落地会把它的 keep-alive socket 留在空闲态;不补扫的话
close()要等满该 socket 自己的keepAliveTimeout)。 - 等在途响应 —— 最后一个落地时
close()的 promise 即刻 resolve,不用等满预算。 - 到预算即硬杀 ——
closeGraceMs(默认10_000)耗尽时,closeAllConnections()加上适配器自建的 upgraded(websocket)socket 与 HTTP/2 会话登记表,销毁一切仍打开的连接。被'upgrade'接管的 socket 对 node 自己的closeAllConnections()不可见,而 HTTP/2 根本没有这类调用;正是这些登记表让预算对 websocket 与 HTTP/2 服务器成为真正的上界。
closeGraceMs: 0 跳过以上全部:立即销毁一切,在途响应一并杀死。
HTTP/2-over-TLS 上跑同一序列:适配器从 'session' 事件登记会话,排空阶段先对每个会话发送优雅 GOAWAY(在途流继续完成,空闲会话立即结束),到期再销毁幸存者。较新的 Node 版本自身也会在 server.close() 时关闭会话;适配器的登记表覆盖不这么做的版本,并让强杀在任何版本上都是真正的上界。
服务器旋钮
server: NodeServerTunables 把精选的 node 服务器旋钮传给底层构造器 —— 八个构造器认识的字段在 http、https、HTTP/2-over-TLS 三条路径都生效:
| 字段 | node 默认 | 含义 |
|---|---|---|
maxHeaderSize | 16384 | 每请求头部预算(字节) |
requestTimeout | 300_000 | 整个请求的期限(ms,0 关闭) |
headersTimeout | 60_000 | 仅头部的期限(ms)—— 必须 ≤ requestTimeout |
keepAliveTimeout | 5_000 | keep-alive 空闲窗口(ms) |
connectionsCheckingInterval | 30_000 | 未完成请求的清账周期(ms) |
keepAlive | false | socket 上的 TCP keep-alive 探测 |
noDelay | true | socket.setNoDelay —— 关闭 Nagle |
highWaterMark | 16384 | 每 socket 流缓冲大小(字节) |
另外三个字段不走构造器 —— node 的构造器不从 options 读它们:
| 字段 | node 默认 | 生效方式 |
|---|---|---|
maxConnections | 无限制 | server.maxConnections setter —— 超过后 node 在连接到达时直接销毁。每条路径:http、https、HTTP/2-over-TLS |
maxRequestsPerSocket | 0(关闭) | server.maxRequestsPerSocket setter —— 一个 HTTP/1.x socket 最多服务的请求数,达到后 node 关闭该 keep-alive 连接。仅 HTTP/1.1:HTTP/2 在一个会话上多路复用大量 stream,该字段在 http2 路径不适用、被忽略 |
backlog | 511 | listen(port, host, backlog) 的第三个参数 —— 内核 accept 队列深度,即进程忙于接受时可等待的连接数。每条路径 |
生产提示。 让 keepAliveTimeout 明显低于上游负载均衡的空闲超时:LB 认为连接已闲置而丢弃、后端却仍持有它,是经典的静默 502 陷阱(下一个请求骑上了 LB 早已抛弃的 socket)。惯用配方是 LB 空闲超时 ≥ keepAliveTimeout + 几秒余量。requestTimeout 限制的是整个请求(含 body)—— 超长时间流式上传/下载会被它拦腰截断,流式端点请自行调大(或设 0 关闭,改靠 headersTimeout + 自己的 body 限制)。
Host 头加固
适配器用 Host 头(HTTP/2 上是 :authority)拼出每个请求的 URL。超出 authority 语法的 Host —— 除字母数字、.、-、:(端口)、[/](IPv6 字面量)之外的任何字符,包括 / \ ? # @、空白、percent 转义与空值 —— 一律回退为 localhost,不进入 URL:伪造的 Host: evil.com/a 既污染不了解析出的 pathname,也不会让 URL 无法解析。合法 Host 原样通过 —— example.com、example.com:8080、[::1]:3000。
onFinish(ctx, fn) / finishersIdle()(core barrel)
import { onFinish } from 's200';
get(app, '/report', (ctx) => {
onFinish(ctx, ({ aborted }) => {
console.log('report response finished', aborted ? 'early' : 'cleanly');
});
return json(ctx, buildReport());
});注册一个每请求恰好运行一次的回调 —— 先到先触发,后续触发是 no-op:
{ aborted: false }—— 响应已完整写完。{ aborted: true }—— 连接先行结束:客户端断开、响应体被 cancel,或写中途失败。
回调是 fire-and-forget:同步抛错(或返回的 promise 拒绝)会经 app 的 logError 记录,绝不打断响应或其他回调。请在 finish 之前注册 —— 之后的注册会被静默丢弃。
node 适配器上的触发点是 socket 写完成与提前 'close';有无 light 都会触发,abortOnDisconnect: false 只跳过 ctx.signal 的中止 —— finish 回调照常运行。
finishersIdle()(同为 core barrel 导出)在所有在途 onFinish 回调落定后 resolve —— fire-and-forget 契约的排空侧伙伴。s200/lifecycle 在连接排空后等待它(waitForFinishers,默认开启),异步 finisher(指标刷写、缓冲写盘)因此不会被停机拦腰截断。只有返回 promise 的回调被计数(同步回调在任何等待者能观察到之前就已落定);调用时没有在途回调则立即 resolve;等待本身不设时限:永不落定的回调会一直挂住它。
流式响应的断连传播
拉取流式响应体期间,客户端断开(abortOnDisconnect 的每请求 signal,默认开启)会 cancel body reader:handler 侧的生成器收到 finally,等待中的背压等待立即拒绝而不是挂在死掉的 socket 上,响应随后被销毁。abortOnDisconnect: false 时循环只走普通 drain 背压,不传播 cancel。
供 serveStatic 注入的文件系统函数
适配器实现 serveStatic 的注入面,核心因此不碰文件系统:
| 函数 | 签名 | 含义 |
|---|---|---|
createFileReader(root, { stream?, realPath?, streamThreshold? }) | (path) => Promise<Uint8Array | ReadableStream | null> | 默认即安全:解析出的真实路径必须留在 root 内(指向别处的符号链接按 miss 处理;realPath: false 关闭),且严格大于 streamThreshold(默认 16 MiB)的文件流式返回而非缓冲。{ stream: true } 不论大小一律流式 |
createFileStat(root) | (path) => Promise<StaticFileInfo | null> | 条件请求 + 流式 content-length 所需的文件元数据;与 createFileReader 相同的 realpath 包含性检查与目录拒绝 |
createFileRangeReader(root) | (path, start, end) => Promise<ReadableStream | null> | 内存安全的 [start, end] range 流;与 createFileReader 相同的 realpath 包含性检查与目录拒绝 |
createRealPathGuard(root) | (path) => Promise<string | null> | 符号链接逃逸守卫:条目存在但真实路径逃出 root(或 root 本身无法解析)时返回 null,否则返回解析出的路径 —— 不存在的条目是未命中,不是逃逸 |
超过阈值的文件走流式,请在 reader 旁一并注入 createFileStat/createFileRangeReader,让这些响应保住 content-length、校验器(ETag/Last-Modified/304)与 206/416 range 语义。
三个工厂函数都默认应用 realpath 包含性检查:root 内指向别处的符号链接在每条路径上都被拒绝 —— 普通读取、stat 与 Range 一视同仁;目录(含指向目录的符号链接)按未命中处理,而不是启动一个中途失败并重置连接的响应。守卫的 null 只保留给逃逸:仅仅不存在的查找返回其解析出的(不存在的)路径,这正是 serveStatic 的目录重定向/SPA 回退在未命中时仍能执行的原因。createFileReader(root, { realPath: false }) 可在确认无符号链接时关闭普通读取器的守卫。
NodeHttpsOptions:{ key, cert, ca?, http2? } —— key/cert 接受字符串、Buffer 或数组(node:https 形状)。
brotliCompress(bytes) → Promise<Uint8Array>
Node 的 CompressionStream 没有 brotli —— 这是 s200/compress 接受的注入编码器:
import { brotliCompress } from 's200/node';
use(app, compress({ brotli: { compress: brotliCompress } }));