功能说明
该示例解析 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;
}