← 中文资源

简体中文版本

RFC 8288 Link 头解析器

把 Link 头解析为 URL、关系数组与参数对象,正确处理引号内逗号、相对地址与多条链接。

阅读英文原文

功能说明

该示例解析 HTTP Link 头,输出每条链接的目标 URL、rel 关系列表与全部参数。常用于读取分页的 next、prev、first、last,发现 API 文档或预加载资源。解析器支持尖括号目标、分号分隔参数、带引号的值与引号内的逗号,并可传入 baseUrl 把相对引用解析为绝对地址。

关键代码

主循环跳过空白与逗号,要求目标以左尖括号开始,并查找匹配的右尖括号。参数解析每次读取分号、参数名、等号与值;引号值逐字符处理反斜杠转义,普通值读取到下一个分号或逗号。rel 参数按空白拆分,允许一个链接声明多个关系。参数名统一小写,重复参数以后出现的为准。findLink 按关系查找第一个匹配 URL。

使用注意

解析器不校验 rel 是否属于 IANA 注册关系,也不要求 rel 必须存在;缺少 rel 时返回空数组。目标 URL 只在提供 baseUrl 时用 URL 构造函数校验,否则按原样返回,可能包含相对路径或非法字符。引号内支持反斜杠转义,但非引号值中的空白会被裁剪。该实现面向单行头值,若上游已把多个 Link 头用逗号合并,也能一次解析。

验证方式

解析包含三条链接且 rel 有多个值的头,断言数量与关系数组正确;测试引号值中含逗号与分号,确认不会被提前截断;用 baseUrl 解析相对路径并断言绝对 URL;缺少 rel 时返回空数组;测试未闭合尖括号、未闭合引号、缺少等号与空参数值分别抛 SyntaxError;findLink 在不存在时返回 undefined。

代码示例

export interface ParsedLink {
  url: string;
  rel: string[];
  params: Record<string, string>;
}

function skipWhitespace(header: string, index: number): number {
  let cursor = index;
  while (cursor < header.length && /\s/.test(header[cursor])) cursor += 1;
  return cursor;
}

function parseParameters(
  header: string,
  start: number,
): { params: Record<string, string>; next: number } {
  const params: Record<string, string> = {};
  let index = start;
  while (index < header.length && header[index] !== ",") {
    index = skipWhitespace(header, index);
    if (index >= header.length || header[index] === ",") break;
    if (header[index] !== ";") throw new SyntaxError("expected ';' at index " + index);
    index = skipWhitespace(header, index + 1);

    const keyStart = index;
    while (index < header.length && /[A-Za-z0-9!#$%&'*+\-.^_\x60|~]/.test(header[index])) {
      index += 1;
    }
    if (index === keyStart) throw new SyntaxError("missing link parameter name");
    const key = header.slice(keyStart, index).toLowerCase();
    index = skipWhitespace(header, index);
    if (header[index] !== "=") throw new SyntaxError("missing '=' for " + key);
    index = skipWhitespace(header, index + 1);

    let value = "";
    if (header[index] === '"') {
      index += 1;
      let closed = false;
      while (index < header.length) {
        const character = header[index];
        if (character === "\\") {
          index += 1;
          if (index >= header.length) throw new SyntaxError("unterminated escape");
          value += header[index];
          index += 1;
        } else if (character === '"') {
          index += 1;
          closed = true;
          break;
        } else {
          value += character;
          index += 1;
        }
      }
      if (!closed) throw new SyntaxError("unterminated quoted parameter");
    } else {
      const valueStart = index;
      while (index < header.length && header[index] !== ";" && header[index] !== ",") {
        index += 1;
      }
      value = header.slice(valueStart, index).trim();
      if (value === "") throw new SyntaxError("empty link parameter value");
    }
    params[key] = value;
  }
  return { params, next: index };
}

export function parseLinkHeader(header: string, baseUrl?: string): ParsedLink[] {
  if (typeof header !== "string") throw new TypeError("header must be a string");
  const links: ParsedLink[] = [];
  let index = 0;
  while (index < header.length) {
    index = skipWhitespace(header, index);
    while (header[index] === ",") index = skipWhitespace(header, index + 1);
    if (index >= header.length) break;
    if (header[index] !== "<") throw new SyntaxError("expected '<' at index " + index);
    const end = header.indexOf(">", index + 1);
    if (end === -1) throw new SyntaxError("unterminated link target");
    const target = header.slice(index + 1, end).trim();
    if (target === "") throw new SyntaxError("empty link target");
    index = end + 1;

    const { params, next } = parseParameters(header, index);
    index = next;
    if (header[index] === ",") index += 1;

    let url = target;
    if (baseUrl !== undefined) {
      try {
        url = new URL(target, baseUrl).toString();
      } catch {
        throw new SyntaxError("invalid link URL: " + target);
      }
    }
    links.push({
      url,
      rel: (params.rel ?? "").split(/\s+/).filter((entry) => entry.length > 0),
      params,
    });
  }
  return links;
}

export function findLink(links: readonly ParsedLink[], rel: string): string | undefined {
  return links.find((link) => link.rel.includes(rel))?.url;
}