H3 响应工具
响应侧的工具分成三组:构造特殊响应(重定向、空响应、流式、静态文件)、清理与收尾(onDispose)、修补与校验(状态码清洗、Early Hints)。它们大多返回一个可以直接 return 的响应对象。
流式输出:iterable
想在同一个响应里边算边发,用 iterable 包一个生成器:
import { H3, iterable } from "h3";
const app = new H3().get("/progress", (event) => {
return iterable(async function* work() {
// 第一块发出前,响应还没创建,此时设的头仍然有效
event.res.headers.set("content-type", "text/html; charset=utf-8");
yield "<!doctype html><h1>处理中</h1><ol>";
for (let i = 1; i <= 3; i++) {
await new Promise((r) => setTimeout(r, 300));
// 每 yield 一次就发一块,客户端立刻能收到
yield `<li>第 ${i} 步完成</li>`;
}
// 生成器的 return 值同样会被发出去
return "</ol>";
});
});
两个关键点:
第一块的时机决定头能不能改。 首块被 await 之前,状态码和头都还可以设;首块一旦发出,头已经上线,之后再设就被忽略。这是 iterable 比裸 ReadableStream 好用之处——后者在读取之前响应就建好了,完全没有这个窗口。
每块必须是字符串或 Buffer。 数字、对象都不行,得自己 String()。
HTML 与信任标记:html、raw
import { H3, html, raw } from "h3";
const app = new H3().get("/page", (event) => {
const userName = event.url.searchParams.get("name") ?? "访客";
const trustedHeading = "<em>欢迎</em>";
// 模板里插入的值默认被转义;raw() 标记的片段原样输出
return html`<h1>${raw(trustedHeading)}</h1><p>你好,${userName}</p>`;
});
html 会自动转义插值,raw 是唯一的「我保证这是安全标记」通道。把用户输入塞进 raw 就是标准的 XSS——这个函数只有在你完全控制那段字符串时才用。
空响应与重定向
import { H3, noContent, redirect, redirectBack } from "h3";
const app = new H3()
// 204:语义上「成功但没内容」,比返回空字符串更准确
.get("/ping", () => noContent())
// 默认 302;第二个参数可给 301/307/308
.get("/old", () => redirect("/new", 301))
// 回跳到来源页:只认同源 referer,跨域或缺失时用 fallback
.post("/submit", (event) => redirectBack(event, { fallback: "/form" }));
redirect 的响应体里带一个 meta refresh 页面,用于响应头被忽略的场景——所以它不是「只设了 Location 头」,体积会比想象的大一点。
redirectBack 的安全设计值得单独讲:
| 规则 | 原因 |
|---|---|
只接受同源 referer |
否则任何站点都能让你的用户跳到钓鱼页 |
| 默认只取路径部分(丢掉 query 与 hash) | 防止伪造 referer 携带非预期参数 |
allowQuery: true 才保留 query |
表单回填场景需要,但要自己确认参数安全 |
fallback 必须是硬编码的可信路径 |
传用户输入就是开放重定向漏洞 |
同理,redirect(location) 如果 location 来自查询参数或表单,必须先做白名单校验。
请求结束后清理:onDispose
import { H3, onDispose } from "h3";
const app = new H3().get("/sse", (event) => {
const timer = setInterval(() => {}, 1000);
// 响应体流完、客户端断开、或出错时都会执行
onDispose(event, (reason) => {
clearInterval(timer);
// 正常结束 reason 是 undefined;取消/中断时是具体原因
});
return someStream;
});
行为细节比想象中多:
| 场景 | 行为 |
|---|---|
| 正常完成 / 客户端断开 / 出错 | 都会触发,且每个运行时都支持(不只是 Node) |
| 触发顺序 | 按注册顺序执行,在全局 onResponse 之后 |
| 回调里抛错或 Promise reject | 被吞掉,除非 silent 否则打到 console.error |
| 回调返回 Promise | 交给 waitUntil,不会被提前掐断 |
| 事件已经结束才注册 | 立即执行一次 |
只在中间件 / handler / onResponse 里注册 |
才保证能观察到结束 |
最后一条最容易误用:在定时器或后台任务里注册 onDispose,事件可能早就结束了。
还有一个语义边界要分清:onDispose 表示「H3 处理完了这个事件」,不等于「客户端收到了响应」。非流式响应在某些运行时上,交出去就会触发。要感知「客户端中途断开、需要中止上游请求」,用 event.req.signal。
动态提供静态文件:serveStatic
import { H3, serveStatic } from "h3";
const app = new H3().get("/assets/**", (event) =>
serveStatic(event, {
// id 已经过规范化,用它去资产根目录里查
getMeta: async (id) => {
const stat = await statAsset(id);
return stat ? { size: stat.size, mtime: stat.mtime } : undefined;
},
getContents: (id) => readAsset(id),
indexNames: ["index.html"],
// 找不到时返回 undefined 交给后续 handler(null 则直接结束)
fallthrough: true,
}),
);
它的路径处理规则是本节的第二个安全重点:
| 输入 | 传给 getMeta 的 id |
原因 |
|---|---|---|
/private%5cx |
/private%5cx |
编码分隔符保持编码,不能变成两段 |
/50%25.png |
/50%.png |
其余转义解一层,文件名才能对上 |
/a%252fb |
/a%2fb |
只剥一层 %25,仍然是字面 %2f,不成为边界 |
//private/x、/pub/%252e%252e/x |
不服务(404 或 fallthrough) | 非规范路径会绕过更窄的 use("/private/**") 守卫 |
一句话:你拿到的 id 与路由匹配用的字符串是同一套分段结构,把它当不透明字符串去拼资产根路径,不要在 backend 里再解码一次——再解就重新打开了穿越口子。
两个它做不到的事需要自己补:大小写不敏感的文件系统(macOS、Windows)上,白名单/黑名单两侧都要统一大小写,否则 /SECRET.env 能绕过针对 /secret.env 的检查;符号链接要在 follow 之后用 realpath 重新校验是否还在资产根内。
修补工具:状态码与 Early Hints
import { H3, sanitizeStatusCode, sanitizeStatusMessage, writeEarlyHints } from "h3";
const app = new H3().get("/", (event) => {
// 非法的状态码退回默认值,避免运行时抛错
event.res.status = sanitizeStatusCode(Number(event.url.searchParams.get("code")), 200);
// 状态文本只允许可见 ASCII 与水平制表符/空格(RFC 7230)
event.res.statusText = sanitizeStatusMessage("OK");
// 提前把关键资源推给客户端;不支持的运行时会降级成响应头
writeEarlyHints(event, { link: "</style.css>; rel=preload; as=style" });
return "ok";
});
状态码为什么需要清洗?因为有些客户端/中间层会把用户输入的状态码直接塞进 setResponseStatus,非法值会在运行时抛异常,或者产生客户端无法解析的响应行。
其余两个
| 函数 | 说明 |
|---|---|
EventStream |
SSE 流,继承自 HTTPResponse,直接 return 即可;完整用法见 5.2 |
isEventStream(input) |
判断某个值是不是 EventStream 实例,写通用响应处理时用 |
排查表
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 流式响应的头没生效 | 头设在首块发出之后 | 在首块 yield 之前设 |
| 页面把用户输入原样渲染了 | 用了 raw() 包用户输入 |
只对可信标记用 raw |
| 静态文件 404 但文件确实存在 | 路径非规范被拒 | 用规范拼写访问,别用 // 或 %252e |
| 静态服务读到了不该给的文件 | backend 里又 decode 了 id |
把 id 当不透明字符串 |
onDispose 有时不触发 |
在事件结束后才注册 | 只在中间件/handler 里注册 |
| 客户端断开后上游请求没停 | 误以为 onDispose 能感知断连 |
用 event.req.signal |
| 跳转被安全扫描判定为开放重定向 | redirect 的 location 来自用户输入 |
加白名单,或用 redirectBack |
延伸阅读
- 返回值如何变成响应、
event.res何时失效:见 2.5 发送响应 - SSE 流与 WebSocket 的取舍:见 5.2 WebSocket 与 SSE
- 路径规范化与 CORS:见 6.6 安全工具
- 流式响应的完整示例:见 7.6 流式响应

TRAE-AI编程


更多建议: