Unverified Commit e7cab6d3 authored by Hong Minhee's avatar Hong Minhee
Browse files

Vendor @hugoalh/http-header-link for CJS compat

The @hugoalh/http-header-link package only supports ESM, which causes
issues with CommonJS builds. This commit vendors the package by:

- Removing external dependency from deno.json and package.json
- Adding vendored implementation in src/runtime/link.ts
- Including comprehensive tests in src/runtime/link.test.ts
- Updating docloader.ts to use the vendored module
- Renaming HTTPHeaderLink to HttpHeaderLink for consistency

All existing functionality is preserved and tests pass.
parent 05ba2f1c
Loading
Loading
Loading
Loading
+0 −1
Original line number Diff line number Diff line
@@ -20,7 +20,6 @@
  },
  "imports": {
    "@cfworker/json-schema": "npm:@cfworker/json-schema@^4.1.1",
    "@hugoalh/http-header-link": "jsr:@hugoalh/http-header-link@^1.0.2",
    "@multiformats/base-x": "npm:@multiformats/base-x@^4.0.1",
    "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0",
    "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.27.0",
+0 −1
Original line number Diff line number Diff line
@@ -151,7 +151,6 @@
  },
  "dependencies": {
    "@cfworker/json-schema": "^4.1.1",
    "@hugoalh/http-header-link": "^1.0.2",
    "@js-temporal/polyfill": "^0.5.1",
    "@logtape/logtape": "catalog:",
    "@multiformats/base-x": "^4.0.1",
+4 −4
Original line number Diff line number Diff line
import { HTTPHeaderLink } from "@hugoalh/http-header-link";
import { getLogger } from "@logtape/logtape";
import process from "node:process";
import metadata from "../../deno.json" with { type: "json" };
import type { KvKey, KvStore } from "../federation/kv.ts";
import preloadedContexts from "./contexts.ts";
import { HttpHeaderLink } from "./link.ts";
import { UrlError, validatePublicUrl } from "./url.ts";

const logger = getLogger(["fedify", "runtime", "docloader"]);
@@ -209,12 +209,12 @@ export async function getRemoteDocument(
  const linkHeader = response.headers.get("Link");
  let contextUrl: string | null = null;
  if (linkHeader != null) {
    let link: HTTPHeaderLink;
    let link: HttpHeaderLink;
    try {
      link = new HTTPHeaderLink(linkHeader);
      link = new HttpHeaderLink(linkHeader);
    } catch (e) {
      if (e instanceof SyntaxError) {
        link = new HTTPHeaderLink();
        link = new HttpHeaderLink();
      } else {
        throw e;
      }
+82 −0
Original line number Diff line number Diff line
// Borrowed from https://github.com/hugoalh/http-header-link-es
import { deepStrictEqual, throws } from "node:assert";
import { test } from "../testing/mod.ts";
import { HttpHeaderLink } from "./link.ts";

test("String Good 1", () => {
  const instance = new HttpHeaderLink(
    `<https://example.com>; rel="preconnect"`,
  );
  deepStrictEqual(instance.hasParameter("rel", "preconnect"), true);
  deepStrictEqual(instance.hasParameter("rel", "connect"), false);
  deepStrictEqual(instance.hasParameter("rel", "postconnect"), false);
  deepStrictEqual(instance.getByRel("preconnect")[0][0], "https://example.com");
});

test("String Good 2", () => {
  const instance = new HttpHeaderLink(`<https://example.com>; rel=preconnect`);
  deepStrictEqual(instance.hasParameter("rel", "preconnect"), true);
  deepStrictEqual(instance.hasParameter("rel", "connect"), false);
  deepStrictEqual(instance.hasParameter("rel", "postconnect"), false);
  deepStrictEqual(instance.getByRel("preconnect")[0][0], "https://example.com");
});

test("String Good 3", () => {
  const instance = new HttpHeaderLink(
    `<https://example.com/%E8%8B%97%E6%9D%A1>; rel="preconnect"`,
  );
  deepStrictEqual(instance.hasParameter("rel", "preconnect"), true);
  deepStrictEqual(instance.hasParameter("rel", "connect"), false);
  deepStrictEqual(instance.hasParameter("rel", "postconnect"), false);
  deepStrictEqual(
    instance.getByRel("preconnect")[0][0],
    "https://example.com/苗条",
  );
});

test("String Good 4", () => {
  const instance = new HttpHeaderLink(
    `<https://one.example.com>; rel="preconnect", <https://two.example.com>; rel="preconnect", <https://three.example.com>; rel="preconnect"`,
  );
  deepStrictEqual(instance.hasParameter("rel", "preconnect"), true);
  deepStrictEqual(instance.hasParameter("rel", "connect"), false);
  deepStrictEqual(instance.hasParameter("rel", "postconnect"), false);
  deepStrictEqual(
    instance.getByRel("preconnect")[0][0],
    "https://one.example.com",
  );
  deepStrictEqual(
    instance.getByRel("preconnect")[1][0],
    "https://two.example.com",
  );
  deepStrictEqual(
    instance.getByRel("preconnect")[2][0],
    "https://three.example.com",
  );
});

test("String Good 5", () => {
  const instance = new HttpHeaderLink();
  deepStrictEqual(instance.hasParameter("rel", "preconnect"), false);
  deepStrictEqual(instance.hasParameter("rel", "connect"), false);
  deepStrictEqual(instance.hasParameter("rel", "postconnect"), false);
  deepStrictEqual(instance.entries().length, 0);
});

test("Entries Good 1", () => {
  const instance = new HttpHeaderLink([["https://one.example.com", {
    rel: "preconnect",
  }]]);
  deepStrictEqual(instance.hasParameter("rel", "preconnect"), true);
  deepStrictEqual(instance.entries().length, 1);
  deepStrictEqual(
    instance.toString(),
    `<https://one.example.com>; rel="preconnect"`,
  );
});

test("String Bad 1", () => {
  throws(() => {
    new HttpHeaderLink(`https://bad.example; rel="preconnect"`);
  });
});
+345 −0
Original line number Diff line number Diff line
// Borrowed from https://github.com/hugoalh/http-header-link-es
const parametersNeedLowerCase: readonly string[] = ["rel", "type"];

const regexpLinkWhitespace = /[\n\r\s\t]/;

/**
 * HTTP header `Link` entry.
 */
export type HttpHeaderLinkEntry = [
  uri: string,
  parameters: { [key: string]: string },
];

function validateURI(uri: string): void {
  if (uri.includes("\n") || regexpLinkWhitespace.test(uri)) {
    throw new SyntaxError(`\`${uri}\` is not a valid URI!`);
  }
}

function* parseLinkFromString(input: string): Generator<HttpHeaderLinkEntry> {
  // Remove Unicode characters of BOM (Byte Order Mark) and no-break space.
  const inputFmt: string = input.replaceAll("\u00A0", "").replaceAll(
    "\uFEFF",
    "",
  );
  for (let cursor: number = 0; cursor < inputFmt.length; cursor += 1) {
    while (regexpLinkWhitespace.test(inputFmt.charAt(cursor))) {
      cursor += 1;
    }
    if (inputFmt.charAt(cursor) !== "<") {
      throw new SyntaxError(
        `Unexpected character \`${
          inputFmt.charAt(cursor)
        }\` at position ${cursor}; Expect character \`<\`!`,
      );
    }
    cursor += 1;
    const cursorEndUri: number = inputFmt.indexOf(">", cursor);
    if (cursorEndUri === -1) {
      throw new SyntaxError(
        `Missing end of URI delimiter character \`>\` after position ${cursor}!`,
      );
    }
    if (cursorEndUri === cursor) {
      throw new SyntaxError(`Missing URI at position ${cursor}!`);
    }
    const uriSlice: string = inputFmt.slice(cursor, cursorEndUri);
    validateURI(uriSlice);
    const uri: HttpHeaderLinkEntry[0] = decodeURI(uriSlice);
    const parameters: HttpHeaderLinkEntry[1] = {};
    cursor = cursorEndUri + 1;
    while (regexpLinkWhitespace.test(inputFmt.charAt(cursor))) {
      cursor += 1;
    }
    if (
      cursor === inputFmt.length ||
      inputFmt.charAt(cursor) === ","
    ) {
      yield [uri, parameters];
      continue;
    }
    if (inputFmt.charAt(cursor) !== ";") {
      throw new SyntaxError(
        `Unexpected character \`${
          inputFmt.charAt(cursor)
        }\` at position ${cursor}; Expect character \`;\`!`,
      );
    }
    cursor += 1;
    while (cursor < inputFmt.length) {
      while (regexpLinkWhitespace.test(inputFmt.charAt(cursor))) {
        cursor += 1;
      }
      const parameterKey: string | undefined = inputFmt.slice(cursor).match(
        /^[\w-]+\*?/,
      )?.[0].toLowerCase();
      if (typeof parameterKey === "undefined") {
        throw new SyntaxError(
          `Unexpected character \`${
            inputFmt.charAt(cursor)
          }\` at position ${cursor}; Expect a valid parameter key!`,
        );
      }
      cursor += parameterKey.length;
      while (regexpLinkWhitespace.test(inputFmt.charAt(cursor))) {
        cursor += 1;
      }
      if (
        cursor === inputFmt.length ||
        inputFmt.charAt(cursor) === ","
      ) {
        parameters[parameterKey] = "";
        break;
      }
      if (inputFmt.charAt(cursor) === ";") {
        parameters[parameterKey] = "";
        cursor += 1;
        continue;
      }
      if (inputFmt.charAt(cursor) !== "=") {
        throw new SyntaxError(
          `Unexpected character \`${
            inputFmt.charAt(cursor)
          }\` at position ${cursor}; Expect character \`=\`!`,
        );
      }
      cursor += 1;
      while (regexpLinkWhitespace.test(inputFmt.charAt(cursor))) {
        cursor += 1;
      }
      let parameterValue: string = "";
      if (inputFmt.charAt(cursor) === '"') {
        cursor += 1;
        while (cursor < inputFmt.length) {
          if (inputFmt.charAt(cursor) === '"') {
            cursor += 1;
            break;
          }
          if (inputFmt.charAt(cursor) === "\\") {
            cursor += 1;
          }
          parameterValue += inputFmt.charAt(cursor);
          cursor += 1;
        }
      } else {
        const cursorDiffParameterValue: number = inputFmt.slice(cursor).search(
          /[\s;,]/,
        );
        if (cursorDiffParameterValue === -1) {
          parameterValue += inputFmt.slice(cursor);
          cursor += parameterValue.length;
        } else {
          parameterValue += inputFmt.slice(cursor, cursorDiffParameterValue);
          cursor += cursorDiffParameterValue;
        }
      }
      parameters[parameterKey] = parametersNeedLowerCase.includes(parameterKey)
        ? parameterValue.toLowerCase()
        : parameterValue;
      while (regexpLinkWhitespace.test(inputFmt.charAt(cursor))) {
        cursor += 1;
      }
      if (
        cursor === inputFmt.length ||
        inputFmt.charAt(cursor) === ","
      ) {
        break;
      }
      if (inputFmt.charAt(cursor) === ";") {
        cursor += 1;
        continue;
      }
      throw new SyntaxError(
        `Unexpected character \`${
          inputFmt.charAt(cursor)
        }\` at position ${cursor}; Expect character \`,\`, character \`;\`, or end of the string!`,
      );
    }
    yield [uri, parameters];
  }
}

/**
 * Handle the HTTP header `Link` according to the specification RFC 8288.
 */
export class HttpHeaderLink {
  get [Symbol.toStringTag](): string {
    return "HTTPHeaderLink";
  }

  #entries: HttpHeaderLinkEntry[] = [];

  /**
   * Handle the HTTP header `Link` according to the specification RFC 8288.
   * @param {...(string | Headers | HttpHeaderLink | HttpHeaderLinkEntry[] | Response)} inputs Input.
   */
  constructor(
    ...inputs:
      (string | Headers | HttpHeaderLink | HttpHeaderLinkEntry[] | Response)[]
  ) {
    if (inputs.length > 0) {
      this.add(...inputs);
    }
  }

  /**
   * Add entries.
   * @param {...(string | Headers | HttpHeaderLink | HttpHeaderLinkEntry[] | Response)} inputs Input.
   * @returns {this}
   */
  add(
    ...inputs:
      (string | Headers | HttpHeaderLink | HttpHeaderLinkEntry[] | Response)[]
  ): this {
    for (const input of inputs) {
      if (input instanceof HttpHeaderLink) {
        this.#entries.push(...structuredClone(input.#entries));
      } else if (Array.isArray(input)) {
        this.#entries.push(
          ...input.map(
            ([uri, parameters]: HttpHeaderLinkEntry): HttpHeaderLinkEntry => {
              validateURI(uri);
              Object.entries(parameters).forEach(
                ([key, value]: [string, string]): void => {
                  if (
                    key !== key.toLowerCase() ||
                    !(/^[\w-]+\*?$/.test(key))
                  ) {
                    throw new SyntaxError(
                      `\`${key}\` is not a valid parameter key!`,
                    );
                  }
                  if (
                    parametersNeedLowerCase.includes(key) &&
                    value !== value.toLowerCase()
                  ) {
                    throw new SyntaxError(
                      `\`${value}\` is not a valid parameter value!`,
                    );
                  }
                },
              );
              return [uri, structuredClone(parameters)];
            },
          ),
        );
      } else {
        for (
          const entry of parseLinkFromString(
            ((
                input instanceof Headers ||
                input instanceof Response
              )
              ? ((input instanceof Headers) ? input : input.headers).get("Link")
              : input) ?? "",
          )
        ) {
          this.#entries.push(entry);
        }
      }
    }
    return this;
  }

  /**
   * Return all of the entries.
   * @returns {HttpHeaderLinkEntry[]} Entries.
   */
  entries(): HttpHeaderLinkEntry[] {
    return structuredClone(this.#entries);
  }

  /**
   * Get entries by parameter.
   * @param {string} key Key of the parameter.
   * @param {string} value Value of the parameter.
   * @returns {HttpHeaderLinkEntry[]} Entries which match the parameter.
   */
  getByParameter(key: string, value: string): HttpHeaderLinkEntry[] {
    if (key !== key.toLowerCase()) {
      throw new SyntaxError(`\`${key}\` is not a valid parameter key!`);
    }
    if (key === "rel") {
      return this.getByRel(value);
    }
    return structuredClone(
      this.#entries.filter((entry: HttpHeaderLinkEntry): boolean => {
        return (entry[1][key] === value);
      }),
    );
  }

  /**
   * Get entries by parameter `rel`.
   * @param {string} value Value of the parameter `rel`.
   * @returns {HttpHeaderLinkEntry[]} Entries which match the parameter.
   */
  getByRel(value: string): HttpHeaderLinkEntry[] {
    if (value !== value.toLowerCase()) {
      throw new SyntaxError(
        `\`${value}\` is not a valid parameter \`rel\` value!`,
      );
    }
    return structuredClone(
      this.#entries.filter((entity: HttpHeaderLinkEntry): boolean => {
        return (entity[1].rel?.toLowerCase() === value);
      }),
    );
  }

  /**
   * Whether have entries that match parameter.
   * @param {string} key Key of the parameter.
   * @param {string} value Value of the parameter.
   * @returns {boolean} Determine result.
   */
  hasParameter(key: string, value: string): boolean {
    return (this.getByParameter(key, value).length > 0);
  }

  /**
   * Stringify entries.
   * @returns {string} Stringified entries.
   */
  toString(): string {
    return this.#entries.map(
      ([uri, parameters]: HttpHeaderLinkEntry): string => {
        return [
          `<${encodeURI(uri)}>`,
          ...Object.entries(parameters).map(
            ([key, value]: [string, string]): string => {
              return ((value.length > 0)
                ? `${key}="${value.replaceAll('"', '\\"')}"`
                : key);
            },
          ),
        ].join("; ");
      },
    ).join(", ");
  }

  /**
   * Parse the HTTP header `Link` according to the specification RFC 8288.
   * @param {...(string | Headers | HttpHeaderLink | HttpHeaderLinkEntry[] | Response)} inputs Input.
   * @returns {HttpHeaderLink}
   */
  static parse(
    ...inputs:
      (string | Headers | HttpHeaderLink | HttpHeaderLinkEntry[] | Response)[]
  ): HttpHeaderLink {
    return new this(...inputs);
  }

  /**
   * Stringify as the HTTP header `Link` according to the specification RFC 8288.
   * @param {...(string | Headers | HttpHeaderLink | HttpHeaderLinkEntry[] | Response)} inputs Input.
   * @returns {string}
   */
  static stringify(
    ...inputs:
      (string | Headers | HttpHeaderLink | HttpHeaderLinkEntry[] | Response)[]
  ): string {
    return new this(...inputs).toString();
  }
}
Loading