H3 响应工具

2026-10-30 17:48 更新

响应侧的工具分成三组:构造特殊响应(重定向、空响应、流式、静态文件)、清理与收尾(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 流式响应
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号