Link Header Parser For RFC 8288 Relations

Parse Link headers into URLs, relation arrays and typed parameters, handling quoted commas, relative references and multiple links.

TypescriptData Fetching

Code

snippet
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;
}