← 中文资源

简体中文版本

原始 HTTP 头块解析器

解析原始 HTTP 头块,支持折行、重复字段、Set-Cookie 数组、大小写无关与体积限制。

阅读英文原文

功能说明

该示例把抓包或代理得到的原始头块解析为可查询的结构。它规范化 CRLF 与 LF,支持已废弃但现实中仍出现的折行续写,按大小写无关的方式合并重复字段,并把 Set-Cookie 保留为数组以免多条 Cookie 被错误拼接。HeaderBlock 提供 get、getAll、has、names 与 toObject,方便在日志分析、测试夹具或轻量代理中使用。

关键代码

构造器使用私有 Map 存储字段,add 在写入前用 RFC 允许的 token 字符集校验字段名,并统一转小写。普通重复字段用逗号连接,Set-Cookie 则先归一化为数组再追加。解析循环遇到空行即停止,遇到以空格或制表符开头的行会拼接到上一个字段;没有前置字段的续行直接报错。解析前用 TextEncoder 计算字节数,循环中累计行数,分别与 maxBytes、maxLines 比较,避免恶意超长输入。

使用注意

折行处理会把续行内容用单个空格连接,并再次经过字段校验;已经过时的折行在 HTTP/2 中不允许,仅在解析历史数据时需要。get 对 Set-Cookie 返回逗号连接结果,若要逐条读取必须使用 getAll。合并普通字段可能改变语义,例如重复的 Content-Length 本应视为错误,生产代理应针对敏感字段做额外校验。该解析器不处理请求行或状态行。

验证方式

构造含 CRLF、折行、重复 Set-Cookie 与大小写混合字段的文本,验证 names 全为小写、getAll 保留两条 Cookie、普通重复字段被逗号合并;测试空行后的内容被忽略;传入无冒号行与孤立续行断言 SyntaxError;用极小的 maxBytes 与 maxLines 验证 RangeError。

代码示例

const HEADER_NAME = /^[!#$%&'*+\-.^_\x60|~0-9a-z]+$/;

export class HeaderBlock {
  #fields = new Map();

  add(name, value) {
    const key = name.toLowerCase();
    if (!HEADER_NAME.test(key)) throw new SyntaxError("invalid header name: " + name);
    const current = this.#fields.get(key);
    if (current === undefined) {
      this.#fields.set(key, value);
    } else if (key === "set-cookie") {
      const list = Array.isArray(current) ? current : [current];
      this.#fields.set(key, [...list, value]);
    } else {
      this.#fields.set(key, current + ", " + value);
    }
  }

  append(name, value) {
    const key = name.toLowerCase();
    const current = this.#fields.get(key);
    if (current === undefined) throw new SyntaxError("continuation line without a header");
    if (Array.isArray(current)) {
      const last = current[current.length - 1];
      this.#fields.set(key, [...current.slice(0, -1), last + " " + value]);
    } else {
      this.#fields.set(key, current + " " + value);
    }
  }

  get(name) {
    const value = this.#fields.get(name.toLowerCase());
    if (value === undefined) return null;
    return Array.isArray(value) ? value.join(", ") : value;
  }

  getAll(name) {
    const value = this.#fields.get(name.toLowerCase());
    if (value === undefined) return [];
    return Array.isArray(value) ? [...value] : [value];
  }

  has(name) {
    return this.#fields.has(name.toLowerCase());
  }

  names() {
    return [...this.#fields.keys()];
  }

  toObject() {
    const output = Object.create(null);
    for (const [name, value] of this.#fields) {
      output[name] = Array.isArray(value) ? [...value] : value;
    }
    return output;
  }
}

export function parseHeaderBlock(raw, options = {}) {
  if (typeof raw !== "string") throw new TypeError("raw headers must be a string");
  const { maxLines = 200, maxBytes = 64 * 1024 } = options;
  if (!Number.isInteger(maxLines) || maxLines < 1) {
    throw new RangeError("maxLines must be a positive integer");
  }
  if (!Number.isInteger(maxBytes) || maxBytes < 1) {
    throw new RangeError("maxBytes must be a positive integer");
  }
  const byteLength = new TextEncoder().encode(raw).length;
  if (byteLength > maxBytes) throw new RangeError("header block exceeds " + maxBytes + " bytes");

  const lines = raw.replace(/\r\n?/g, "\n").split("\n");
  const headers = new HeaderBlock();
  let lastName = null;
  let lineCount = 0;
  for (const line of lines) {
    if (line === "") break;
    lineCount += 1;
    if (lineCount > maxLines) throw new RangeError("header block exceeds " + maxLines + " lines");
    if (/^[ \t]/.test(line)) {
      if (lastName === null) throw new SyntaxError("continuation line without a header");
      headers.append(lastName, line.trim());
      continue;
    }
    const colon = line.indexOf(":");
    if (colon <= 0) throw new SyntaxError("invalid header line: " + line);
    const name = line.slice(0, colon).trim();
    const value = line.slice(colon + 1).trim();
    headers.add(name, value);
    lastName = name.toLowerCase();
  }
  return headers;
}