@fedify/vocab-runtime 2.3.15-dev.2482 → 2.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/deno.json +5 -2
  2. package/dist/internal.cjs +6 -0
  3. package/dist/internal.d.cts +3 -0
  4. package/dist/internal.d.ts +3 -0
  5. package/dist/internal.js +3 -0
  6. package/dist/mod.cjs +105 -253
  7. package/dist/mod.d.cts +5 -28
  8. package/dist/mod.d.ts +6 -28
  9. package/dist/mod.js +98 -245
  10. package/dist/public-fetch-BC-nWJNM.d.ts +143 -0
  11. package/dist/public-fetch-Bk32IaYB.cjs +821 -0
  12. package/dist/public-fetch-DBpt3xxf.d.cts +143 -0
  13. package/dist/public-fetch-Dfmc_Kfy.js +772 -0
  14. package/dist/tests/{body-BmS3KJE1.cjs → body-BtW9TZGZ.cjs} +1 -1
  15. package/dist/tests/{body-xrlsEUPK.mjs → body-CmdrGt1w.mjs} +1 -1
  16. package/dist/tests/body.test.cjs +1 -1
  17. package/dist/tests/body.test.mjs +1 -1
  18. package/dist/tests/decimal.test.cjs +6 -6
  19. package/dist/tests/decimal.test.mjs +6 -6
  20. package/dist/tests/{docloader-CETMi5su.cjs → docloader-D34pSxKd.cjs} +106 -35
  21. package/dist/tests/{docloader-BJXlwNXs.mjs → docloader-zoX3z1bj.mjs} +102 -37
  22. package/dist/tests/docloader.test.cjs +523 -1273
  23. package/dist/tests/docloader.test.mjs +469 -1238
  24. package/dist/tests/esm-C_ftyt-G.cjs +1208 -0
  25. package/dist/tests/esm-LaZ1p80h.mjs +1222 -0
  26. package/dist/tests/internal/multicodec.test.cjs +1 -1
  27. package/dist/tests/internal/multicodec.test.mjs +1 -1
  28. package/dist/tests/{key-Qj7u1qmH.mjs → key-2lZR0Cht.mjs} +2 -2
  29. package/dist/tests/{key-CWDxavIR.cjs → key-CnsHrQSU.cjs} +2 -2
  30. package/dist/tests/key.test.cjs +2 -2
  31. package/dist/tests/key.test.mjs +2 -2
  32. package/dist/tests/{langstr-BsqFJupv.cjs → langstr-CZpWmpo5.cjs} +1 -1
  33. package/dist/tests/{langstr-ABdAOKlF.mjs → langstr-DTB4sl0a.mjs} +1 -1
  34. package/dist/tests/langstr.test.cjs +1 -1
  35. package/dist/tests/langstr.test.mjs +1 -1
  36. package/dist/tests/langtag.test.cjs +1 -1
  37. package/dist/tests/langtag.test.mjs +1 -1
  38. package/dist/tests/link.test.mjs +1 -1
  39. package/dist/tests/multibase/multibase.test.cjs +1 -1
  40. package/dist/tests/multibase/multibase.test.mjs +1 -1
  41. package/dist/tests/node-C0iftNaR.cjs +90 -0
  42. package/dist/tests/node-C3bjFjL1.mjs +84 -0
  43. package/dist/tests/pin-grant-CU-o367k.cjs +70 -0
  44. package/dist/tests/pin-grant-HW3B6SKv.mjs +53 -0
  45. package/dist/tests/pin-grant.test.cjs +88 -0
  46. package/dist/tests/pin-grant.test.d.cts +1 -0
  47. package/dist/tests/pin-grant.test.d.mts +1 -0
  48. package/dist/tests/pin-grant.test.mjs +88 -0
  49. package/dist/tests/pinners/bun.test.cjs +167 -0
  50. package/dist/tests/pinners/bun.test.d.cts +1 -0
  51. package/dist/tests/pinners/bun.test.d.mts +1 -0
  52. package/dist/tests/pinners/bun.test.mjs +167 -0
  53. package/dist/tests/pinners/node.test.cjs +269 -0
  54. package/dist/tests/pinners/node.test.d.cts +1 -0
  55. package/dist/tests/pinners/node.test.d.mts +1 -0
  56. package/dist/tests/pinners/node.test.mjs +268 -0
  57. package/dist/tests/pinners/pinners.test.cjs +197 -0
  58. package/dist/tests/pinners/pinners.test.d.cts +1 -0
  59. package/dist/tests/pinners/pinners.test.d.mts +1 -0
  60. package/dist/tests/pinners/pinners.test.mjs +197 -0
  61. package/dist/tests/pinners/testdata/node-child.cjs +135 -0
  62. package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
  63. package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
  64. package/dist/tests/pinners/testdata/node-child.mjs +134 -0
  65. package/dist/tests/pinners-D4UQ-HXm.cjs +221 -0
  66. package/dist/tests/pinners-ZgZDx9y6.mjs +203 -0
  67. package/dist/tests/public-fetch-BtiBjQPL.cjs +187 -0
  68. package/dist/tests/public-fetch-Yi7Vks9s.mjs +170 -0
  69. package/dist/tests/public-fetch.test.cjs +1074 -0
  70. package/dist/tests/public-fetch.test.d.cts +1 -0
  71. package/dist/tests/public-fetch.test.d.mts +1 -0
  72. package/dist/tests/public-fetch.test.mjs +1074 -0
  73. package/dist/tests/{request-rtQTKXfB.mjs → request-BKFirRrO.mjs} +1 -1
  74. package/dist/tests/{request-pbEw07NE.cjs → request-CqKju-Ll.cjs} +1 -1
  75. package/dist/tests/request.test.cjs +1 -1
  76. package/dist/tests/request.test.mjs +1 -1
  77. package/dist/tests/support-Cj0T6TP_.cjs +363 -0
  78. package/dist/tests/support-M5ClN-no.mjs +272 -0
  79. package/dist/tests/{url-4PAkYhZC.cjs → url-BR7UVTkW.cjs} +88 -12
  80. package/dist/tests/{url-3fyJuqC0.mjs → url-DQWrMGhu.mjs} +65 -13
  81. package/dist/tests/url.test.cjs +4 -1
  82. package/dist/tests/url.test.mjs +4 -1
  83. package/package.json +14 -3
  84. package/src/docloader.test.ts +700 -32
  85. package/src/docloader.ts +174 -67
  86. package/src/internal.ts +13 -0
  87. package/src/pin-grant.test.ts +118 -0
  88. package/src/pin-grant.ts +99 -0
  89. package/src/pinners/bun.test.ts +278 -0
  90. package/src/pinners/bun.ts +223 -0
  91. package/src/pinners/mod.ts +59 -0
  92. package/src/pinners/node.test.ts +415 -0
  93. package/src/pinners/node.ts +144 -0
  94. package/src/pinners/pinners.test.ts +316 -0
  95. package/src/pinners/testdata/node-child.ts +147 -0
  96. package/src/pinners/testdata/proxy-child.ts +82 -0
  97. package/src/pinners/testdata/support.ts +343 -0
  98. package/src/pinners/testdata/tls-cert.pem +12 -0
  99. package/src/pinners/testdata/tls-child.ts +88 -0
  100. package/src/pinners/testdata/tls-key.pem +5 -0
  101. package/src/pinners/types.ts +48 -0
  102. package/src/public-fetch.test.ts +1286 -0
  103. package/src/public-fetch.ts +338 -0
  104. package/src/testdata/public-fetch-deno-worker.ts +74 -0
  105. package/src/url.test.ts +11 -0
  106. package/src/url.ts +113 -22
  107. package/tsdown.config.ts +12 -3
  108. /package/dist/tests/{langtag-LDBqlxHv.mjs → langtag-D8FWxjv8.mjs} +0 -0
  109. /package/dist/tests/{langtag-DgySESmk.cjs → langtag-a2-s9uyi.cjs} +0 -0
  110. /package/dist/tests/{link-CYszIegt.mjs → link-CHMtH2-k.mjs} +0 -0
  111. /package/dist/tests/{multibase-BL45eko3.cjs → multibase-CPwWBzL3.cjs} +0 -0
  112. /package/dist/tests/{multibase-Da9VJCw8.mjs → multibase-D85wf3ZR.mjs} +0 -0
  113. /package/dist/tests/{multicodec-ByS9XLsw.cjs → multicodec-B-_CSt1M.cjs} +0 -0
  114. /package/dist/tests/{multicodec-BBPZjRfg.mjs → multicodec-caWdG98t.mjs} +0 -0
package/dist/mod.js CHANGED
@@ -1,9 +1,8 @@
1
1
 
2
+ import { a as expandIPv6Address, c as validatePublicUrl, i as UrlError, n as openPublicFetch, o as isValidPublicIPv4Address, s as isValidPublicIPv6Address, t as openDirectFetch } from "./public-fetch-Dfmc_Kfy.js";
2
3
  import { getLogger } from "@logtape/logtape";
3
4
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
4
5
  import process from "node:process";
5
- import dns from "node:dns/promises";
6
- import { isIP } from "node:net";
7
6
  import { Integer, Sequence } from "asn1js";
8
7
  import { decodeBase64, encodeBase64 } from "byte-encodings/base64";
9
8
  import { decodeBase64Url } from "byte-encodings/base64url";
@@ -4532,7 +4531,7 @@ const preloadedContexts = {
4532
4531
  //#endregion
4533
4532
  //#region deno.json
4534
4533
  var name = "@fedify/vocab-runtime";
4535
- var version = "2.3.15-dev.2482+41d364a2";
4534
+ var version = "2.3.15";
4536
4535
  //#endregion
4537
4536
  //#region src/request.ts
4538
4537
  /**
@@ -4859,216 +4858,6 @@ var HttpHeaderLink = class HttpHeaderLink {
4859
4858
  }
4860
4859
  };
4861
4860
  //#endregion
4862
- //#region src/url.ts
4863
- var UrlError = class extends Error {
4864
- /**
4865
- * The failure category. Defaults to `"disallowed"`.
4866
- * `"dns"` includes lookup errors and results with no usable IP addresses.
4867
- * @since 2.0.29
4868
- */
4869
- reason;
4870
- constructor(message, options) {
4871
- super(message, options);
4872
- this.name = "UrlError";
4873
- this.reason = options?.reason ?? "disallowed";
4874
- }
4875
- };
4876
- /**
4877
- * Validates a URL to prevent SSRF attacks.
4878
- */
4879
- async function validatePublicUrl(url) {
4880
- const parsed = new URL(url);
4881
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
4882
- let hostname = parsed.hostname;
4883
- if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
4884
- if (hostname === "localhost") throw new UrlError("Localhost is not allowed");
4885
- const hostnameFamily = isIP(hostname);
4886
- if (hostnameFamily !== 0) {
4887
- validatePublicIpAddress(hostname, hostnameFamily);
4888
- return;
4889
- }
4890
- if ("Deno" in globalThis && !isIP(hostname)) {
4891
- if ((await Deno.permissions.query({ name: "net" })).state !== "granted") return;
4892
- }
4893
- if ("Bun" in globalThis) {
4894
- if (hostname === "example.com" || hostname.endsWith(".example.com")) return;
4895
- else if (hostname === "fedify-test.internal") throw new UrlError("Invalid or private address: fedify-test.internal");
4896
- }
4897
- let addresses;
4898
- try {
4899
- addresses = await dns.lookup(hostname, { all: true });
4900
- } catch (error) {
4901
- throw new UrlError("DNS lookup failed", {
4902
- cause: error,
4903
- reason: "dns"
4904
- });
4905
- }
4906
- validateLookupAddresses(addresses);
4907
- }
4908
- /**
4909
- * Validates the IP addresses returned by `node:dns.lookup()`.
4910
- *
4911
- * Cloudflare Workers' `node:dns` implementation currently maps every record
4912
- * in a DNS-over-HTTPS `Answer` array—including CNAME records—to a
4913
- * `LookupAddress`, even though Node.js specifies that the `address` field must
4914
- * contain an IPv4 or IPv6 literal. See:
4915
- * https://github.com/cloudflare/workerd/issues/6886
4916
- *
4917
- * Work around that bug by ignoring non-IP entries only when the lookup also
4918
- * returns at least one actual IP address. This remains fail-closed: a result
4919
- * containing no IP addresses is rejected, and every returned IP address is
4920
- * still validated and must be public. This workaround can be revisited once
4921
- * the Workerd issue is fixed in supported Cloudflare Workers runtimes.
4922
- *
4923
- * @internal
4924
- */
4925
- function validateLookupAddresses(addresses) {
4926
- let ipAddressCount = 0;
4927
- for (const { address } of addresses) {
4928
- const family = isIP(address);
4929
- if (family === 0) continue;
4930
- ipAddressCount++;
4931
- validatePublicIpAddress(address, family);
4932
- }
4933
- if (ipAddressCount === 0) throw new UrlError("DNS lookup did not return any IP address", { reason: "dns" });
4934
- }
4935
- function validatePublicIpAddress(address, family) {
4936
- if (family === 4 && isValidPublicIPv4Address(address) || family === 6 && isValidPublicIPv6Address(address)) return;
4937
- throw new UrlError(`Invalid or private address: ${address}`);
4938
- }
4939
- function isValidPublicIPv4Address(address) {
4940
- const parts = parseIPv4Address(address);
4941
- if (parts == null) return false;
4942
- const value = ipv4PartsToNumber(parts);
4943
- return !nonPublicIPv4Prefixes.some(({ base, prefix }) => matchesIPv4Prefix(value, base, prefix));
4944
- }
4945
- function isValidPublicIPv6Address(address) {
4946
- const words = parseIPv6Address(address);
4947
- if (words == null) return false;
4948
- if (nonPublicIPv6Prefixes.some(({ words: prefixWords, prefix }) => matchesIPv6Prefix(words, prefixWords, prefix))) return false;
4949
- for (const { extractIPv4, prefix, words: prefixWords } of ipv6WithIPv4Prefixes) {
4950
- if (!matchesIPv6Prefix(words, prefixWords, prefix)) continue;
4951
- const ipv4Address = extractIPv4(words);
4952
- if (ipv4Address != null && !isValidPublicIPv4Address(ipv4Address)) return false;
4953
- }
4954
- return true;
4955
- }
4956
- function expandIPv6Address(address) {
4957
- address = address.toLowerCase();
4958
- const ipv4Delimiter = address.lastIndexOf(":");
4959
- if (address.includes(".") && ipv4Delimiter >= 0) {
4960
- const ipv4Parts = parseIPv4Address(address.substring(ipv4Delimiter + 1));
4961
- if (ipv4Parts == null) return address;
4962
- const high = (ipv4Parts[0] << 8) + ipv4Parts[1];
4963
- const low = (ipv4Parts[2] << 8) + ipv4Parts[3];
4964
- address = address.substring(0, ipv4Delimiter + 1) + high.toString(16) + ":" + low.toString(16);
4965
- }
4966
- if (address === "::") return "0000:0000:0000:0000:0000:0000:0000:0000";
4967
- if (address.startsWith("::")) address = "0000" + address;
4968
- if (address.endsWith("::")) address = address + "0000";
4969
- address = address.replace("::", ":0000".repeat(8 - (address.match(/:/g) || []).length) + ":");
4970
- return address.split(":").map((part) => part.padStart(4, "0")).join(":");
4971
- }
4972
- const nonPublicIPv4Prefixes = [
4973
- ipv4Prefix("0.0.0.0/8", "RFC 6890"),
4974
- ipv4Prefix("10.0.0.0/8", "RFC 1918"),
4975
- ipv4Prefix("100.64.0.0/10", "RFC 6598"),
4976
- ipv4Prefix("127.0.0.0/8", "RFC 1122"),
4977
- ipv4Prefix("169.254.0.0/16", "RFC 3927"),
4978
- ipv4Prefix("172.16.0.0/12", "RFC 1918"),
4979
- ipv4Prefix("192.0.0.0/24", "RFC 6890"),
4980
- ipv4Prefix("192.0.2.0/24", "RFC 5737"),
4981
- ipv4Prefix("192.88.99.0/24", "RFC 7526"),
4982
- ipv4Prefix("192.168.0.0/16", "RFC 1918"),
4983
- ipv4Prefix("198.18.0.0/15", "RFC 2544"),
4984
- ipv4Prefix("198.51.100.0/24", "RFC 5737"),
4985
- ipv4Prefix("203.0.113.0/24", "RFC 5737"),
4986
- ipv4Prefix("224.0.0.0/4", "RFC 5771"),
4987
- ipv4Prefix("240.0.0.0/4", "RFC 1112")
4988
- ];
4989
- const nonPublicIPv6Prefixes = [
4990
- ipv6Prefix("::/16", "RFC 4291"),
4991
- ipv6Prefix("2001::/32", "RFC 4380"),
4992
- ipv6Prefix("2002::/16", "RFC 3056"),
4993
- ipv6Prefix("64:ff9b:1::/48", "RFC 8215"),
4994
- ipv6Prefix("fc00::/7", "RFC 4193"),
4995
- ipv6Prefix("fe80::/10", "RFC 4291"),
4996
- ipv6Prefix("ff00::/8", "RFC 4291")
4997
- ];
4998
- const ipv6WithIPv4Prefixes = [{
4999
- ...ipv6Prefix("64:ff9b::/96", "RFC 6052"),
5000
- extractIPv4: (words) => ipv4FromWords(words[6], words[7])
5001
- }];
5002
- function ipv4Prefix(cidr, rfc) {
5003
- const [address, prefixText] = cidr.split("/");
5004
- const prefix = parseInt(prefixText, 10);
5005
- const parts = parseIPv4Address(address);
5006
- if (parts == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) throw new Error(`Invalid IPv4 prefix: ${cidr}`);
5007
- return {
5008
- cidr,
5009
- base: ipv4PartsToNumber(parts),
5010
- prefix,
5011
- rfc
5012
- };
5013
- }
5014
- function ipv6Prefix(cidr, rfc) {
5015
- const [address, prefixText] = cidr.split("/");
5016
- const prefix = parseInt(prefixText, 10);
5017
- const words = parseIPv6Address(address);
5018
- if (words == null || !Number.isInteger(prefix) || prefix < 0 || prefix > 128) throw new Error(`Invalid IPv6 prefix: ${cidr}`);
5019
- return {
5020
- cidr,
5021
- words,
5022
- prefix,
5023
- rfc
5024
- };
5025
- }
5026
- function parseIPv4Address(address) {
5027
- const parts = address.split(".").map((part) => {
5028
- if (!/^\d+$/.test(part)) return NaN;
5029
- return parseInt(part, 10);
5030
- });
5031
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
5032
- return parts;
5033
- }
5034
- function parseIPv6Address(address) {
5035
- const parts = expandIPv6Address(address).split(":");
5036
- if (parts.length !== 8) return null;
5037
- const words = parts.map((part) => {
5038
- if (!/^[0-9a-f]{1,4}$/i.test(part)) return NaN;
5039
- return parseInt(part, 16);
5040
- });
5041
- if (words.some((word) => !Number.isInteger(word) || word < 0 || word > 65535)) return null;
5042
- return words;
5043
- }
5044
- function ipv4PartsToNumber(parts) {
5045
- return parts[0] * 2 ** 24 + parts[1] * 2 ** 16 + parts[2] * 2 ** 8 + parts[3];
5046
- }
5047
- function ipv4FromWords(highWord, lowWord) {
5048
- return [
5049
- highWord >> 8,
5050
- highWord & 255,
5051
- lowWord >> 8,
5052
- lowWord & 255
5053
- ].join(".");
5054
- }
5055
- function matchesIPv4Prefix(address, prefixBase, prefixLength) {
5056
- const blockSize = 2 ** (32 - prefixLength);
5057
- return Math.floor(address / blockSize) === Math.floor(prefixBase / blockSize);
5058
- }
5059
- function matchesIPv6Prefix(address, prefixWords, prefixLength) {
5060
- let remaining = prefixLength;
5061
- for (let i = 0; i < 8 && remaining > 0; i++) if (remaining >= 16) {
5062
- if (address[i] !== prefixWords[i]) return false;
5063
- remaining -= 16;
5064
- } else {
5065
- const mask = 65535 << 16 - remaining & 65535;
5066
- if ((address[i] & mask) !== (prefixWords[i] & mask)) return false;
5067
- remaining = 0;
5068
- }
5069
- return true;
5070
- }
5071
- //#endregion
5072
4861
  //#region src/docloader.ts
5073
4862
  const logger = getLogger([
5074
4863
  "fedify",
@@ -5080,13 +4869,47 @@ const DEFAULT_MAX_REDIRECTION = 20;
5080
4869
  * Gets a {@link RemoteDocument} from the given response.
5081
4870
  * @param url The URL of the document to load.
5082
4871
  * @param response The response to get the document from.
5083
- * @param fetch The function to fetch the document.
4872
+ * @param fetch The function to fetch the document. It is called with the URL
4873
+ * of an alternate document, if any, after the response body has
4874
+ * been either read or its cancellation has been started.
5084
4875
  * @returns The loaded remote document.
5085
4876
  * @throws {FetchError} If the response is not OK.
5086
4877
  * @internal
5087
4878
  */
5088
4879
  async function getRemoteDocument(url, response, fetch) {
5089
- const documentUrl = response.url === "" ? url : response.url;
4880
+ const result = await parseRemoteDocument(url, response);
4881
+ if ("alternate" in result) return await fetch(result.alternate);
4882
+ return result.document;
4883
+ }
4884
+ /**
4885
+ * Starts cancelling the body of a response without waiting for it to settle.
4886
+ * The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
4887
+ * settles only after the other branch is cancelled too, per the Streams
4888
+ * standard, so awaiting it could block forever. Failures are only logged.
4889
+ * @param response The response whose body is cancelled.
4890
+ * @param url The URL of the response, for logging.
4891
+ */
4892
+ function cancelResponseBody(response, url) {
4893
+ response.body?.cancel().catch((error) => {
4894
+ logger.debug("Failed to cancel the response body from {url}: {error}", {
4895
+ url,
4896
+ error
4897
+ });
4898
+ });
4899
+ }
4900
+ /**
4901
+ * Gets a {@link RemoteDocument} from the given response, or the URL of an
4902
+ * alternate document to load instead. When an alternate document is found,
4903
+ * the response body has been either read or its cancellation has been started
4904
+ * (without waiting for it), so that the alternate document can be loaded after
4905
+ * the response's session is closed.
4906
+ * @param url The URL of the document to load.
4907
+ * @param response The response to get the document from.
4908
+ * @returns The loaded remote document, or the alternate document URL.
4909
+ * @throws {FetchError} If the response is not OK.
4910
+ */
4911
+ async function parseRemoteDocument(url, response) {
4912
+ const documentUrl = url;
5090
4913
  const docUrl = new URL(documentUrl);
5091
4914
  if (!response.ok) {
5092
4915
  logger.error("Failed to fetch document: {status} {url} {headers}", {
@@ -5123,7 +4946,8 @@ async function getRemoteDocument(url, response, fetch) {
5123
4946
  alternateUrl: altUri.href,
5124
4947
  url: documentUrl
5125
4948
  });
5126
- return await fetch(altUri.href);
4949
+ cancelResponseBody(response, documentUrl);
4950
+ return { alternate: altUri.href };
5127
4951
  }
5128
4952
  }
5129
4953
  }
@@ -5148,7 +4972,7 @@ async function getRemoteDocument(url, response, fetch) {
5148
4972
  alternateUrl: attribs.href,
5149
4973
  url: documentUrl
5150
4974
  });
5151
- return await fetch(new URL(attribs.href, docUrl).href);
4975
+ return { alternate: new URL(attribs.href, docUrl).href };
5152
4976
  }
5153
4977
  }
5154
4978
  document = JSON.parse(html);
@@ -5158,11 +4982,11 @@ async function getRemoteDocument(url, response, fetch) {
5158
4982
  url: documentUrl,
5159
4983
  headers: Object.fromEntries(response.headers.entries())
5160
4984
  });
5161
- return {
4985
+ return { document: {
5162
4986
  contextUrl,
5163
4987
  document,
5164
4988
  documentUrl
5165
- };
4989
+ } };
5166
4990
  }
5167
4991
  /**
5168
4992
  * Creates a JSON-LD document loader that utilizes the browser's `fetch` API.
@@ -5188,9 +5012,54 @@ async function getRemoteDocument(url, response, fetch) {
5188
5012
  * @returns The document loader.
5189
5013
  * @since 1.3.0
5190
5014
  */
5191
- function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent } = {}) {
5015
+ function getDocumentLoader(options = {}) {
5016
+ return createDocumentLoader(options);
5017
+ }
5018
+ /**
5019
+ * The implementation of {@link getDocumentLoader}, with injectable
5020
+ * dependencies. This is not exported from any entry point.
5021
+ * @param options Options for the document loader.
5022
+ * @param deps The dependencies.
5023
+ * @returns The document loader.
5024
+ */
5025
+ function createDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, userAgent } = {}, deps = {}) {
5192
5026
  const tracer = trace.getTracerProvider().getTracer(name, version);
5193
5027
  const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
5028
+ async function openHop(url, signal) {
5029
+ try {
5030
+ if (deps.openHop != null) return await deps.openHop(url);
5031
+ return allowPrivateAddress ? await openDirectFetch(url) : await openPublicFetch(url, { signal });
5032
+ } catch (error) {
5033
+ if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
5034
+ url,
5035
+ error
5036
+ });
5037
+ else logger.error("Disallowed private URL: {url}", {
5038
+ url,
5039
+ error
5040
+ });
5041
+ throw error;
5042
+ }
5043
+ }
5044
+ async function fetchHop(session, url, span, options) {
5045
+ try {
5046
+ const request = createActivityPubRequest(url, { userAgent });
5047
+ logRequest(logger, request);
5048
+ const response = await session.fetch(request, {
5049
+ redirect: "manual",
5050
+ signal: options?.signal
5051
+ });
5052
+ span.setAttribute("http.response.status_code", response.status);
5053
+ if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
5054
+ const location = response.headers.get("Location");
5055
+ cancelResponseBody(response, url);
5056
+ return { location };
5057
+ }
5058
+ return await parseRemoteDocument(url, response);
5059
+ } finally {
5060
+ await session.close();
5061
+ }
5062
+ }
5194
5063
  async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
5195
5064
  options?.signal?.throwIfAborted();
5196
5065
  const parsedUrl = new URL(url);
@@ -5205,33 +5074,15 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
5205
5074
  documentUrl: currentUrl
5206
5075
  };
5207
5076
  }
5208
- if (!allowPrivateAddress) try {
5209
- await validatePublicUrl(currentUrl);
5210
- } catch (error) {
5211
- if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
5212
- url: currentUrl,
5213
- error
5214
- });
5215
- else logger.error("Disallowed private URL: {url}", {
5216
- url: currentUrl,
5217
- error
5218
- });
5219
- throw error;
5220
- }
5077
+ const session = await openHop(currentUrl, options?.signal);
5221
5078
  visited.add(currentUrl);
5222
5079
  return await tracer.startActiveSpan("activitypub.fetch_document", {
5223
5080
  kind: SpanKind.CLIENT,
5224
5081
  attributes: { "url.full": currentUrl }
5225
5082
  }, async (span) => {
5226
5083
  try {
5227
- const request = createActivityPubRequest(currentUrl, { userAgent });
5228
- logRequest(logger, request);
5229
- const response = await fetch(request, {
5230
- redirect: "manual",
5231
- signal: options?.signal
5232
- });
5233
- span.setAttribute("http.response.status_code", response.status);
5234
- if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
5084
+ const outcome = await fetchHop(session, currentUrl, span, options);
5085
+ if ("location" in outcome) {
5235
5086
  if (redirected >= maximumRedirection) {
5236
5087
  logger.error("Too many redirections ({redirections}) while fetching document.", {
5237
5088
  redirections: redirected + 1,
@@ -5239,7 +5090,7 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
5239
5090
  });
5240
5091
  throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
5241
5092
  }
5242
- const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
5093
+ const redirectUrl = new URL(outcome.location, currentUrl).href;
5243
5094
  span.setAttribute("http.redirect.url", redirectUrl);
5244
5095
  if (visited.has(redirectUrl)) {
5245
5096
  logger.error("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
@@ -5250,12 +5101,14 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
5250
5101
  }
5251
5102
  return await load(redirectUrl, options, redirected + 1, visited);
5252
5103
  }
5253
- const result = await getRemoteDocument(currentUrl, response, async (alternateUrl) => {
5104
+ let result;
5105
+ if ("alternate" in outcome) {
5106
+ const alternateUrl = outcome.alternate;
5254
5107
  options?.signal?.throwIfAborted();
5255
5108
  if (redirected >= DEFAULT_MAX_REDIRECTION) throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
5256
5109
  if (visited.has(alternateUrl)) throw new FetchError(currentUrl, `Redirect loop detected: ${alternateUrl}`);
5257
- return await load(alternateUrl, options, redirected + 1, visited);
5258
- });
5110
+ result = await load(alternateUrl, options, redirected + 1, visited);
5111
+ } else result = outcome.document;
5259
5112
  span.setAttribute("docloader.document_url", result.documentUrl);
5260
5113
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
5261
5114
  return result;
@@ -0,0 +1,143 @@
1
+ /// <reference lib="esnext.temporal" />
2
+ //#region src/url.d.ts
3
+ /**
4
+ * An address returned by a DNS lookup. It has the same shape as
5
+ * `ResolvedAddress` of `node:dns`, without depending on its type declarations.
6
+ * @internal
7
+ */
8
+ interface ResolvedAddress {
9
+ /** An IPv4 or IPv6 address. */
10
+ readonly address: string;
11
+ /** The IP family of the address: `4` or `6`. */
12
+ readonly family: number;
13
+ }
14
+ declare class UrlError extends Error {
15
+ /**
16
+ * The failure category. Defaults to `"disallowed"`.
17
+ * `"dns"` includes lookup errors and results with no usable IP addresses.
18
+ * @since 2.0.29
19
+ */
20
+ readonly reason: "dns" | "disallowed";
21
+ constructor(message: string, options?: {
22
+ cause?: unknown;
23
+ reason?: "dns" | "disallowed";
24
+ });
25
+ }
26
+ /**
27
+ * Validates a URL to prevent SSRF attacks.
28
+ *
29
+ * This function only checks the addresses that the hostname currently
30
+ * resolves to; it does not pin the connection to those addresses. A request
31
+ * made after this check resolves the hostname again, so this function alone
32
+ * does not prevent DNS rebinding.
33
+ */
34
+ declare function validatePublicUrl(url: string): Promise<void>;
35
+ declare function isValidPublicIPv4Address(address: string): boolean;
36
+ declare function isValidPublicIPv6Address(address: string): boolean;
37
+ declare function expandIPv6Address(address: string): string;
38
+ //#endregion
39
+ //#region src/public-fetch.d.ts
40
+ /**
41
+ * Options for {@link openPublicFetch} and {@link withPublicFetch}.
42
+ */
43
+ interface PublicFetchOptions {
44
+ /**
45
+ * An `AbortSignal` for cancelling the DNS lookup.
46
+ */
47
+ readonly signal?: AbortSignal;
48
+ /**
49
+ * A function that resolves all addresses of a hostname, used instead of
50
+ * `node:dns.lookup()`. The addresses it returns are validated in the same
51
+ * way as the default lookup's.
52
+ */
53
+ readonly lookup?: (hostname: string) => Promise<readonly ResolvedAddress[]>;
54
+ }
55
+ /**
56
+ * A session for fetching resources from a single origin. Its `fetch()`
57
+ * connects only to the addresses validated when the session was opened, on
58
+ * runtimes that support it.
59
+ */
60
+ interface PublicFetchSession {
61
+ /**
62
+ * A copy of the URL the session was opened for.
63
+ */
64
+ readonly url: URL;
65
+ /**
66
+ * Fetches a resource from the session's origin. Redirects are never
67
+ * followed; the redirect mode is always `"manual"`.
68
+ * @throws {TypeError} If the input's origin differs from the session's, or
69
+ * if the session is closed.
70
+ */
71
+ fetch(input: Request | URL | string, init?: RequestInit): Promise<Response>;
72
+ /**
73
+ * Closes the session. The cancellation of response bodies that have been
74
+ * neither consumed nor cancelled is started (without waiting for it), and
75
+ * the session can no longer fetch. Calling this more than once has no
76
+ * effect.
77
+ */
78
+ close(): Promise<void>;
79
+ }
80
+ /**
81
+ * The functions created by {@link createPublicFetch}.
82
+ */
83
+ interface PublicFetch {
84
+ /**
85
+ * Validates the URL, resolves its hostname, checks that every resolved
86
+ * address is public, and opens a session that connects only to those
87
+ * addresses.
88
+ * @param url The URL to open a session for.
89
+ * @param options The options.
90
+ * @returns The session. It must be closed.
91
+ * @throws {UrlError} If the URL is not allowed, or its hostname cannot be
92
+ * resolved (with `reason: "dns"`).
93
+ */
94
+ openPublicFetch(url: URL | string, options?: PublicFetchOptions): Promise<PublicFetchSession>;
95
+ /**
96
+ * Opens a session with {@link PublicFetch.openPublicFetch}, runs the given
97
+ * function with its `fetch()`, and closes the session afterwards.
98
+ * @param url The URL to open a session for.
99
+ * @param fn The function to run. It must not return a `Response`, since
100
+ * the response bodies are cancelled when the session is closed.
101
+ * @param options The options.
102
+ * @returns The value returned by `fn`.
103
+ * @throws {UrlError} If the URL is not allowed.
104
+ * @throws {TypeError} If `fn` returns a `Response`.
105
+ */
106
+ withPublicFetch<T>(url: URL | string, fn: (fetch: PublicFetchSession["fetch"]) => Promise<T>, options?: PublicFetchOptions): Promise<T>;
107
+ }
108
+ /**
109
+ * Validates the URL, resolves its hostname, checks that every resolved
110
+ * address is public, and opens a session that connects only to those
111
+ * addresses. Connections are pinned on Node.js and Bun. On other runtimes,
112
+ * the session uses the global `fetch()` after the check.
113
+ * @param url The URL to open a session for.
114
+ * @param options The options.
115
+ * @returns The session. It must be closed.
116
+ * @throws {UrlError} If the URL is not allowed, or its hostname cannot be
117
+ * resolved (with `reason: "dns"`).
118
+ */
119
+ declare const openPublicFetch: PublicFetch["openPublicFetch"];
120
+ /**
121
+ * Opens a session with {@link openPublicFetch}, runs the given function with
122
+ * its `fetch()`, and closes the session afterwards.
123
+ * @param url The URL to open a session for.
124
+ * @param fn The function to run. It must not return a `Response`, since
125
+ * the response bodies are cancelled when the session is closed.
126
+ * @param options The options.
127
+ * @returns The value returned by `fn`.
128
+ * @throws {UrlError} If the URL is not allowed.
129
+ * @throws {TypeError} If `fn` returns a `Response`.
130
+ */
131
+ declare const withPublicFetch: PublicFetch["withPublicFetch"];
132
+ /**
133
+ * Opens a session that neither validates nor pins addresses, for callers that
134
+ * allow private addresses. It has the same rules as {@link openPublicFetch}
135
+ * sessions otherwise: requests are limited to the URL's origin, redirects are
136
+ * never followed, and unconsumed response bodies are cancelled on close.
137
+ * It grants nothing beyond calling the global `fetch()` directly.
138
+ * @param url The URL to open a session for.
139
+ * @returns The session. It must be closed.
140
+ */
141
+ declare function openDirectFetch(url: URL | string): Promise<PublicFetchSession>;
142
+ //#endregion
143
+ export { withPublicFetch as a, isValidPublicIPv4Address as c, openPublicFetch as i, isValidPublicIPv6Address as l, PublicFetchSession as n, UrlError as o, openDirectFetch as r, expandIPv6Address as s, PublicFetchOptions as t, validatePublicUrl as u };