@fedify/relay 2.5.0-dev.2446 → 2.5.0-dev.2492

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.
@@ -1,36 +1,12 @@
1
1
  import "temporal-polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { createRequire } from "node:module";
4
+ import { n as __require, r as __toESM, t as __commonJSMin } from "./rolldown-runtime-BoYiifaO.js";
5
5
  import "@fedify/vocab";
6
6
  import dns from "node:dns/promises";
7
7
  import { isIP } from "node:net";
8
8
  import { getLogger } from "@logtape/logtape";
9
9
  import process$1 from "node:process";
10
- //#region \0rolldown/runtime.js
11
- var __create = Object.create;
12
- var __defProp = Object.defineProperty;
13
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
14
- var __getOwnPropNames = Object.getOwnPropertyNames;
15
- var __getProtoOf = Object.getPrototypeOf;
16
- var __hasOwnProp = Object.prototype.hasOwnProperty;
17
- var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
18
- var __copyProps = (to, from, except, desc) => {
19
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
20
- key = keys[i];
21
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
22
- get: ((k) => from[k]).bind(null, key),
23
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
24
- });
25
- }
26
- return to;
27
- };
28
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
29
- value: mod,
30
- enumerable: true
31
- }) : target, mod));
32
- var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
33
- //#endregion
34
10
  //#region ../vocab-runtime/dist/contexts-DWrj4AGR.js
35
11
  const preloadedContexts = {
36
12
  "https://www.w3.org/ns/activitystreams": { "@context": {
@@ -4546,7 +4522,7 @@ const preloadedContexts = {
4546
4522
  } }
4547
4523
  };
4548
4524
  //#endregion
4549
- //#region ../vocab-runtime/dist/url-Dzyp-NsC.js
4525
+ //#region ../vocab-runtime/dist/url-mWveR5GA.js
4550
4526
  var UrlError = class extends Error {
4551
4527
  /**
4552
4528
  * The failure category. Defaults to `"disallowed"`.
@@ -4561,29 +4537,46 @@ var UrlError = class extends Error {
4561
4537
  }
4562
4538
  };
4563
4539
  /**
4564
- * Validates a URL to prevent SSRF attacks.
4540
+ * Resolves all addresses of the given hostname through `node:dns/promises`.
4541
+ * The default `dns` object is looked up on every call so that tests can still
4542
+ * stub `dns.lookup()`.
4543
+ * @internal
4544
+ */
4545
+ function lookupAllAddresses(hostname) {
4546
+ return dns.lookup(hostname, { all: true });
4547
+ }
4548
+ /**
4549
+ * Checks the parts of a URL that can be validated without resolving it:
4550
+ * the protocol, the `localhost` hostname, and IP literal hostnames.
4551
+ * @throws {UrlError} If the URL is not allowed.
4552
+ * @internal
4565
4553
  */
4566
- async function validatePublicUrl(url) {
4554
+ function parsePublicUrl(url) {
4567
4555
  const parsed = new URL(url);
4568
4556
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new UrlError(`Unsupported protocol: ${parsed.protocol}`);
4569
4557
  let hostname = parsed.hostname;
4570
4558
  if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
4571
4559
  if (hostname === "localhost") throw new UrlError("Localhost is not allowed");
4572
- const hostnameFamily = isIP(hostname);
4573
- if (hostnameFamily !== 0) {
4574
- validatePublicIpAddress(hostname, hostnameFamily);
4575
- return;
4576
- }
4577
- if ("Deno" in globalThis && !isIP(hostname)) {
4578
- if ((await Deno.permissions.query({ name: "net" })).state !== "granted") return;
4579
- }
4580
- if ("Bun" in globalThis) {
4581
- if (hostname === "example.com" || hostname.endsWith(".example.com")) return;
4582
- else if (hostname === "fedify-test.internal") throw new UrlError("Invalid or private address: fedify-test.internal");
4583
- }
4560
+ const family = isIP(hostname);
4561
+ if (family !== 0) validatePublicIpAddress(hostname, family);
4562
+ return {
4563
+ url: parsed,
4564
+ hostname,
4565
+ family
4566
+ };
4567
+ }
4568
+ /**
4569
+ * Resolves the given hostname and checks that every returned IP address is
4570
+ * public.
4571
+ * @returns The IP addresses of the hostname, without non-IP entries.
4572
+ * @throws {UrlError} If the lookup fails, returns no IP address, or returns
4573
+ * any non-public address.
4574
+ * @internal
4575
+ */
4576
+ async function lookupPublicAddresses(hostname, lookup) {
4584
4577
  let addresses;
4585
4578
  try {
4586
- addresses = await dns.lookup(hostname, { all: true });
4579
+ addresses = await lookup(hostname);
4587
4580
  } catch (error) {
4588
4581
  throw new UrlError("DNS lookup failed", {
4589
4582
  cause: error,
@@ -4591,6 +4584,21 @@ async function validatePublicUrl(url) {
4591
4584
  });
4592
4585
  }
4593
4586
  validateLookupAddresses(addresses);
4587
+ return addresses.filter(({ address }) => isIP(address) !== 0).map(({ address }) => ({
4588
+ address,
4589
+ family: isIP(address)
4590
+ }));
4591
+ }
4592
+ /**
4593
+ * Checks that the given IP address is public. This is used right before
4594
+ * connecting to an address that passed {@link lookupPublicAddresses}.
4595
+ * @throws {UrlError} If the address is not a public IP address.
4596
+ * @internal
4597
+ */
4598
+ function assertPublicAddress(address) {
4599
+ const family = isIP(address.address);
4600
+ if (family === 0 || family !== address.family) throw new UrlError(`Invalid or private address: ${address.address}`);
4601
+ validatePublicIpAddress(address.address, family);
4594
4602
  }
4595
4603
  /**
4596
4604
  * Validates the IP addresses returned by `node:dns.lookup()`.
@@ -4756,6 +4764,492 @@ function matchesIPv6Prefix(address, prefixWords, prefixLength) {
4756
4764
  return true;
4757
4765
  }
4758
4766
  //#endregion
4767
+ //#region ../vocab-runtime/dist/public-fetch-ziZ39L5I.js
4768
+ const grants = /* @__PURE__ */ new WeakMap();
4769
+ /**
4770
+ * Issues a grant for the given URL's origin and addresses. This does not
4771
+ * validate the addresses; callers validate them before issuing, and pinners
4772
+ * validate them again right before connecting.
4773
+ * @param url The URL whose origin the grant is bound to.
4774
+ * @param addresses The addresses to connect to.
4775
+ * @returns A new grant.
4776
+ */
4777
+ function issuePinGrant(url, addresses) {
4778
+ const grant = Object.freeze(Object.create(null));
4779
+ let hostname = url.hostname;
4780
+ if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1);
4781
+ const record = Object.freeze({
4782
+ origin: url.origin,
4783
+ hostname,
4784
+ addresses: Object.freeze(addresses.map(({ address, family }) => Object.freeze({
4785
+ address,
4786
+ family
4787
+ })))
4788
+ });
4789
+ grants.set(grant, {
4790
+ record,
4791
+ revoked: false
4792
+ });
4793
+ return grant;
4794
+ }
4795
+ /**
4796
+ * Checks that the given grant was issued by {@link issuePinGrant}, has not
4797
+ * been revoked, and was issued for the origin of the given URL.
4798
+ * @param grant The grant to redeem.
4799
+ * @param url The URL to connect to.
4800
+ * @returns What the grant stands for.
4801
+ * @throws {TypeError} If the grant cannot be used for the URL.
4802
+ */
4803
+ function redeemPinGrant(grant, url) {
4804
+ const state = typeof grant === "object" && grant !== null ? grants.get(grant) : void 0;
4805
+ if (state == null) throw new TypeError("Invalid pin grant.");
4806
+ if (state.revoked) throw new TypeError("The pin grant has been revoked.");
4807
+ if (url.origin !== state.record.origin) throw new TypeError(`The pin grant for ${state.record.origin} cannot be used for ${url.origin}.`);
4808
+ return state.record;
4809
+ }
4810
+ /**
4811
+ * Revokes the given grant so that it can no longer be redeemed.
4812
+ * @param grant The grant to revoke.
4813
+ */
4814
+ function revokePinGrant(grant) {
4815
+ const state = grants.get(grant);
4816
+ if (state != null) state.revoked = true;
4817
+ }
4818
+ const logger$1 = getLogger([
4819
+ "fedify",
4820
+ "runtime",
4821
+ "fetch"
4822
+ ]);
4823
+ /**
4824
+ * The first Bun version that honors `proxy: false` when proxy environment
4825
+ * variables are set at startup. Earlier versions send pinned requests
4826
+ * through the proxy, with the validated IP address as the target.
4827
+ */
4828
+ const PROXY_FALSE_MIN_VERSION = "1.4.3";
4829
+ const PROXY_VARIABLES = [
4830
+ "HTTP_PROXY",
4831
+ "http_proxy",
4832
+ "HTTPS_PROXY",
4833
+ "https_proxy",
4834
+ "ALL_PROXY",
4835
+ "all_proxy"
4836
+ ];
4837
+ const FALLBACK_ERROR_CODES = /* @__PURE__ */ new Set([
4838
+ "ConnectionRefused",
4839
+ "ECONNREFUSED",
4840
+ "EHOSTUNREACH",
4841
+ "ENETUNREACH",
4842
+ "ETIMEDOUT"
4843
+ ]);
4844
+ /**
4845
+ * Bun versions before 1.3.11 verify the server certificate against the `Host`
4846
+ * header, including a non-default port, instead of `tls.serverName`, so
4847
+ * pinned requests to HTTPS servers on non-default ports always fail the check
4848
+ * there. A custom `checkServerIdentity` cannot stand in for it: these
4849
+ * versions send the whole request before a failing custom check aborts it
4850
+ * (and 1.3.10 even reuses the connection for the next request), so such
4851
+ * requests are refused instead.
4852
+ */
4853
+ const HOST_HEADER_IDENTITY_RANGE = "<1.3.11";
4854
+ /**
4855
+ * Creates a pinner for Bun. Every request is sent to an IP literal URL with
4856
+ * the original `Host` header, `tls.serverName` set to the original hostname
4857
+ * (so that the certificate is still verified against it), and `proxy: false`.
4858
+ *
4859
+ * GET and HEAD requests fall back to the next address on transport errors
4860
+ * raised before a response arrives. Other methods use only the first
4861
+ * address, since a transport error does not guarantee that the request did
4862
+ * not reach the server, and retrying it could deliver an activity twice.
4863
+ * Together with the transport retries of `doubleKnock()`, a GET request is
4864
+ * attempted at most twice per address. Note that Bun 1.4 also reports
4865
+ * a connection reset after the request was sent as a `TypeError`, so GET and
4866
+ * HEAD requests may be repeated against the next address in that case, which
4867
+ * is acceptable for these idempotent methods.
4868
+ *
4869
+ * Bun versions before 1.3.11 verify certificates against the `Host` header,
4870
+ * which includes a non-default port, and cannot safely check them against
4871
+ * the original hostname in any other way, so requests to HTTPS servers on
4872
+ * non-default ports fail there with a `TypeError` before connecting.
4873
+ *
4874
+ * Bun versions before 1.4.3 ignore `proxy: false` when proxy environment
4875
+ * variables are set, so requests go through the proxy with the validated IP
4876
+ * address as the target. In that case a warning is logged once.
4877
+ * @param options The pinner options.
4878
+ * @returns The pinner.
4879
+ */
4880
+ function createBunPinner({ verifyAddress }) {
4881
+ let proxyWarningPending = ignoresProxyFalse() && hasProxyVariables();
4882
+ return {
4883
+ supports(url) {
4884
+ return url.protocol === "http:" || url.protocol === "https:";
4885
+ },
4886
+ async fetch(grant, request, signal) {
4887
+ const url = new URL(request.url);
4888
+ const { hostname, addresses } = redeemPinGrant(grant, url);
4889
+ if (proxyWarningPending) {
4890
+ proxyWarningPending = false;
4891
+ logger$1.warn("Bun {version} ignores proxy: false while proxy environment variables are set, so outbound requests go through the proxy instead of connecting directly to the validated addresses. Upgrade to Bun {minVersion} or later, or unset the proxy environment variables; see {url}.", {
4892
+ version: getBun()?.version,
4893
+ minVersion: PROXY_FALSE_MIN_VERSION,
4894
+ url: "https://fedify.dev/manual/deploy#bun-behind-an-http-proxy"
4895
+ });
4896
+ }
4897
+ const fallback = request.method === "GET" || request.method === "HEAD";
4898
+ const candidates = fallback ? addresses : addresses.slice(0, 1);
4899
+ const body = request.body == null ? null : await request.arrayBuffer();
4900
+ let lastError = /* @__PURE__ */ new TypeError(`No address to connect to for ${url.host}.`);
4901
+ for (const address of candidates) {
4902
+ throwIfAborted(signal, request.signal);
4903
+ verifyAddress(address);
4904
+ const headers = new Headers(request.headers);
4905
+ headers.set("Host", url.host);
4906
+ const init = {
4907
+ method: request.method,
4908
+ headers,
4909
+ body,
4910
+ redirect: "manual",
4911
+ signal: signal == null || signal === request.signal ? request.signal : anySignal(signal, request.signal),
4912
+ proxy: false
4913
+ };
4914
+ if (url.protocol === "https:") init.tls = getTlsOptions(url, hostname);
4915
+ try {
4916
+ return await fetch(toAddressUrl(url, address), init);
4917
+ } catch (error) {
4918
+ throwIfAborted(signal, request.signal);
4919
+ if (!fallback || !isFallbackError(error)) throw error;
4920
+ lastError = error;
4921
+ }
4922
+ }
4923
+ throw lastError;
4924
+ },
4925
+ release() {}
4926
+ };
4927
+ }
4928
+ function getTlsOptions(url, hostname) {
4929
+ const bun = getBun();
4930
+ if (url.port !== "" && bun != null && bun.semver.satisfies(bun.version, HOST_HEADER_IDENTITY_RANGE)) throw new TypeError(`Bun ${bun.version} cannot verify the certificate of ${url.host} when connecting to a validated address; upgrade to Bun 1.3.11 or later to fetch from HTTPS servers on non-default ports.`);
4931
+ return isIP(hostname) === 0 ? { serverName: hostname } : {};
4932
+ }
4933
+ /**
4934
+ * Combines two signals without `AbortSignal.any()`, which Bun 1.1.0 lacks.
4935
+ */
4936
+ function anySignal(a, b) {
4937
+ if (a.aborted) return a;
4938
+ if (b.aborted) return b;
4939
+ const controller = new AbortController();
4940
+ const abort = (event) => {
4941
+ controller.abort(event.target.reason);
4942
+ a.removeEventListener("abort", abort);
4943
+ b.removeEventListener("abort", abort);
4944
+ };
4945
+ a.addEventListener("abort", abort);
4946
+ b.addEventListener("abort", abort);
4947
+ return controller.signal;
4948
+ }
4949
+ function toAddressUrl(url, address) {
4950
+ const result = new URL(url);
4951
+ result.hostname = address.family === 6 ? `[${address.address}]` : address.address;
4952
+ return result;
4953
+ }
4954
+ function isFallbackError(error) {
4955
+ if (error instanceof Error && error.name === "AbortError") return false;
4956
+ if (error instanceof TypeError) return true;
4957
+ const code = error?.code;
4958
+ return typeof code === "string" && FALLBACK_ERROR_CODES.has(code);
4959
+ }
4960
+ function throwIfAborted(...signals) {
4961
+ for (const signal of signals) signal?.throwIfAborted();
4962
+ }
4963
+ function getBun() {
4964
+ return globalThis.Bun;
4965
+ }
4966
+ function ignoresProxyFalse() {
4967
+ const bun = getBun();
4968
+ if (bun == null) return false;
4969
+ return !bun.semver.satisfies(bun.version, `>=${PROXY_FALSE_MIN_VERSION}`);
4970
+ }
4971
+ function hasProxyVariables() {
4972
+ const env = globalThis.process?.env;
4973
+ if (env == null) return false;
4974
+ return PROXY_VARIABLES.some((name) => (env[name] ?? "") !== "");
4975
+ }
4976
+ /**
4977
+ * Creates a pinner for Node.js. Every request is sent through the global
4978
+ * `fetch()` with an undici `Agent` as the `dispatcher`. The agent's
4979
+ * `connect.lookup` answers only with the addresses of live grants for
4980
+ * the hostname, checking each of them again right before connecting, and
4981
+ * fails when there are none, so a connection can never be made to an address
4982
+ * that was not validated.
4983
+ *
4984
+ * The agent is shared by the requests of all grants, so keep-alive
4985
+ * connections are reused. A pooled connection was made to an address of
4986
+ * a grant that was valid when it was connected, so reusing it from another
4987
+ * session of the same origin still reaches a validated address. Response
4988
+ * bodies have to be consumed or cancelled for their connections to return to
4989
+ * the pool, which sessions do when they are closed.
4990
+ *
4991
+ * Since the dispatcher is passed explicitly, neither proxy environment
4992
+ * variables nor the global dispatcher (e.g., `NODE_USE_ENV_PROXY=1`) apply.
4993
+ * @param options The pinner options.
4994
+ * @returns The pinner.
4995
+ */
4996
+ function createNodePinner({ verifyAddress, undici }) {
4997
+ const grants = /* @__PURE__ */ new Map();
4998
+ function lookup(hostname, options, callback) {
4999
+ let addresses;
5000
+ try {
5001
+ addresses = getGrantedAddresses(hostname, options.family);
5002
+ } catch (error) {
5003
+ callback(error, []);
5004
+ return;
5005
+ }
5006
+ if (options.all) callback(null, addresses);
5007
+ else callback(null, addresses[0].address, addresses[0].family);
5008
+ }
5009
+ function getGrantedAddresses(hostname, family) {
5010
+ const addresses = /* @__PURE__ */ new Map();
5011
+ for (const [grant, record] of grants.get(hostname) ?? []) {
5012
+ try {
5013
+ redeemPinGrant(grant, new URL(record.origin));
5014
+ } catch {
5015
+ continue;
5016
+ }
5017
+ for (const address of record.addresses) addresses.set(address.address, address);
5018
+ }
5019
+ const familyNumber = family === "IPv4" ? 4 : family === "IPv6" ? 6 : typeof family === "number" ? family : 0;
5020
+ const result = [...addresses.values()].filter((address) => familyNumber === 0 || address.family === familyNumber);
5021
+ if (result.length < 1) throw new UrlError(`No validated address to connect to: ${hostname}`);
5022
+ for (const address of result) verifyAddress(address);
5023
+ return result;
5024
+ }
5025
+ const agent = new undici.Agent({ connect: { lookup } });
5026
+ return {
5027
+ supports(url) {
5028
+ return url.protocol === "http:" || url.protocol === "https:";
5029
+ },
5030
+ async fetch(grant, request, signal) {
5031
+ const record = redeemPinGrant(grant, new URL(request.url));
5032
+ if (isIP(record.hostname) !== 0) for (const address of record.addresses) verifyAddress(address);
5033
+ let registered = grants.get(record.hostname);
5034
+ if (registered == null) {
5035
+ registered = /* @__PURE__ */ new Map();
5036
+ grants.set(record.hostname, registered);
5037
+ }
5038
+ registered.set(grant, record);
5039
+ return await fetch(request, {
5040
+ redirect: "manual",
5041
+ signal: signal == null || signal === request.signal ? request.signal : AbortSignal.any([signal, request.signal]),
5042
+ dispatcher: agent
5043
+ });
5044
+ },
5045
+ release(grant) {
5046
+ for (const [hostname, registered] of grants) {
5047
+ if (!registered.delete(grant)) continue;
5048
+ if (registered.size < 1) grants.delete(hostname);
5049
+ break;
5050
+ }
5051
+ }
5052
+ };
5053
+ }
5054
+ /**
5055
+ * Detects the current runtime. Cloudflare Workers is checked first, since
5056
+ * `process` can exist there with `nodejs_compat`, and Deno and Bun before
5057
+ * Node.js, since both provide `process.versions.node`.
5058
+ * @returns The current runtime.
5059
+ */
5060
+ function detectRuntime() {
5061
+ const g = globalThis;
5062
+ if (g.navigator?.userAgent === "Cloudflare-Workers") return "cfworkers";
5063
+ if ("Deno" in globalThis) return "deno";
5064
+ if ("Bun" in globalThis) return "bun";
5065
+ if (g.process?.versions?.node != null) return "node";
5066
+ return "other";
5067
+ }
5068
+ let runtimePinner;
5069
+ /**
5070
+ * Gets the production pinner of the current runtime, which rejects
5071
+ * non-public addresses right before connecting. Deno and Cloudflare Workers
5072
+ * have no pinner.
5073
+ * @returns The pinner, or `null` if the runtime has none.
5074
+ */
5075
+ function getRuntimePinner() {
5076
+ runtimePinner ??= createRuntimePinner(detectRuntime());
5077
+ return runtimePinner;
5078
+ }
5079
+ async function createRuntimePinner(runtime) {
5080
+ switch (runtime) {
5081
+ case "bun": return createBunPinner({ verifyAddress: assertPublicAddress });
5082
+ case "node": return createNodePinner({
5083
+ verifyAddress: assertPublicAddress,
5084
+ undici: await import("./undici-7BbNHW-y.js").then((m) => /* @__PURE__ */ __toESM(m.default, 1))
5085
+ });
5086
+ default: return null;
5087
+ }
5088
+ }
5089
+ const logger$2 = getLogger([
5090
+ "fedify",
5091
+ "runtime",
5092
+ "fetch"
5093
+ ]);
5094
+ /**
5095
+ * Creates {@link PublicFetch.openPublicFetch} and
5096
+ * {@link PublicFetch.withPublicFetch} bound to the given pinner and lookup.
5097
+ * This is exported only for tests; it is not exported from any entry point.
5098
+ * @param dependencies The pinner and the default lookup function.
5099
+ * @returns The functions.
5100
+ */
5101
+ function createPublicFetch(dependencies) {
5102
+ async function openPublicFetch(url, options = {}) {
5103
+ const { url: parsed, hostname, family } = parsePublicUrl(url);
5104
+ if (parsed.username !== "" || parsed.password !== "") throw new UrlError("URLs with credentials are not allowed");
5105
+ const lookup = options.lookup ?? dependencies.lookup;
5106
+ let addresses;
5107
+ if (family !== 0) addresses = [{
5108
+ address: hostname,
5109
+ family
5110
+ }];
5111
+ else if (lookup === lookupAllAddresses && !await canResolve()) return createSession(parsed, globalFetch);
5112
+ else {
5113
+ options.signal?.throwIfAborted();
5114
+ addresses = await lookupPublicAddresses(hostname, lookup);
5115
+ options.signal?.throwIfAborted();
5116
+ }
5117
+ const pinner = await dependencies.pinner();
5118
+ if (pinner == null || !pinner.supports(parsed)) {
5119
+ logUnpinned(parsed);
5120
+ return createSession(parsed, globalFetch);
5121
+ }
5122
+ const grant = issuePinGrant(parsed, addresses);
5123
+ return createSession(parsed, (input, init) => pinner.fetch(grant, new Request(input, init), init.signal ?? void 0), {
5124
+ grant,
5125
+ pinner
5126
+ });
5127
+ }
5128
+ async function withPublicFetch(url, fn, options) {
5129
+ const session = await openPublicFetch(url, options);
5130
+ try {
5131
+ const result = await fn(session.fetch);
5132
+ if (result instanceof Response) throw new TypeError("withPublicFetch() callbacks must not return a Response, since its body is cancelled when the session is closed.");
5133
+ return result;
5134
+ } catch (error) {
5135
+ logger$2.debug("Failed while fetching {url}; closing the session: {error}", {
5136
+ url: session.url.href,
5137
+ error
5138
+ });
5139
+ throw error;
5140
+ } finally {
5141
+ await session.close();
5142
+ }
5143
+ }
5144
+ return {
5145
+ openPublicFetch,
5146
+ withPublicFetch
5147
+ };
5148
+ }
5149
+ const runtimePublicFetch = createPublicFetch({
5150
+ pinner: getRuntimePinner,
5151
+ lookup: lookupAllAddresses
5152
+ });
5153
+ /**
5154
+ * Validates the URL, resolves its hostname, checks that every resolved
5155
+ * address is public, and opens a session that connects only to those
5156
+ * addresses. Connections are pinned on Node.js and Bun. On other runtimes,
5157
+ * the session uses the global `fetch()` after the check.
5158
+ * @param url The URL to open a session for.
5159
+ * @param options The options.
5160
+ * @returns The session. It must be closed.
5161
+ * @throws {UrlError} If the URL is not allowed, or its hostname cannot be
5162
+ * resolved (with `reason: "dns"`).
5163
+ */
5164
+ const openPublicFetch = runtimePublicFetch.openPublicFetch;
5165
+ runtimePublicFetch.withPublicFetch;
5166
+ /**
5167
+ * Opens a session that neither validates nor pins addresses, for callers that
5168
+ * allow private addresses. It has the same rules as {@link openPublicFetch}
5169
+ * sessions otherwise: requests are limited to the URL's origin, redirects are
5170
+ * never followed, and unconsumed response bodies are cancelled on close.
5171
+ * It grants nothing beyond calling the global `fetch()` directly.
5172
+ * @param url The URL to open a session for.
5173
+ * @returns The session. It must be closed.
5174
+ */
5175
+ function openDirectFetch(url) {
5176
+ return Promise.resolve(createSession(new URL(url), globalFetch));
5177
+ }
5178
+ /**
5179
+ * Starts cancelling the body of a response without waiting for it to settle.
5180
+ * The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
5181
+ * settles only after the other branch is cancelled too, per the Streams
5182
+ * standard, so awaiting it could block forever. Failures are only logged.
5183
+ */
5184
+ function cancelResponseBody$1(response, url) {
5185
+ response.body?.cancel().catch((error) => {
5186
+ logger$2.debug("Failed to cancel a response body from {url}: {error}", {
5187
+ url: url.href,
5188
+ error
5189
+ });
5190
+ });
5191
+ }
5192
+ function globalFetch(input, init) {
5193
+ return fetch(input, init);
5194
+ }
5195
+ function createSession(url, send, pin) {
5196
+ const origin = url.origin;
5197
+ const tracked = [];
5198
+ let closed = false;
5199
+ async function sessionFetch(input, init) {
5200
+ if (closed) throw new TypeError("The fetch session is closed.");
5201
+ const target = new URL(input instanceof Request ? input.url : input);
5202
+ if (target.origin !== origin) throw new TypeError(`A fetch session for ${origin} cannot fetch ${target.origin}.`);
5203
+ const response = await send(input, {
5204
+ ...init,
5205
+ redirect: "manual"
5206
+ });
5207
+ if (closed) {
5208
+ cancelResponseBody$1(response, url);
5209
+ throw new TypeError("The fetch session is closed.");
5210
+ }
5211
+ tracked.push(response);
5212
+ return response;
5213
+ }
5214
+ async function close() {
5215
+ if (closed) return;
5216
+ closed = true;
5217
+ for (const response of tracked) {
5218
+ if (response.body == null || response.bodyUsed) continue;
5219
+ if (response.body.locked) {
5220
+ logger$2.debug("Cannot cancel a locked response body from {url}.", { url: url.href });
5221
+ continue;
5222
+ }
5223
+ cancelResponseBody$1(response, url);
5224
+ }
5225
+ if (pin == null) return;
5226
+ revokePinGrant(pin.grant);
5227
+ await pin.pinner.release(pin.grant);
5228
+ }
5229
+ return {
5230
+ get url() {
5231
+ return new URL(url);
5232
+ },
5233
+ fetch: sessionFetch,
5234
+ close
5235
+ };
5236
+ }
5237
+ async function canResolve() {
5238
+ if (!("Deno" in globalThis)) return true;
5239
+ return (await Deno.permissions.query({ name: "net" })).state === "granted";
5240
+ }
5241
+ const loggedUnpinned = /* @__PURE__ */ new Set();
5242
+ function logUnpinned(url) {
5243
+ const runtime = detectRuntime();
5244
+ const key = `${runtime} ${url.protocol}`;
5245
+ if (loggedUnpinned.has(key)) return;
5246
+ loggedUnpinned.add(key);
5247
+ logger$2.debug("Outbound {protocol} connections are not pinned to validated addresses on {runtime}; the hostname is resolved again when connecting.", {
5248
+ runtime,
5249
+ protocol: url.protocol
5250
+ });
5251
+ }
5252
+ //#endregion
4759
5253
  //#region ../../node_modules/.pnpm/@opentelemetry+api@1.9.1/node_modules/@opentelemetry/api/build/esm/version.js
4760
5254
  const VERSION = "1.9.1";
4761
5255
  //#endregion
@@ -26124,7 +26618,7 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports, module) => {
26124
26618
  require_build();
26125
26619
  var import_src = /* @__PURE__ */ __toESM(require_src(), 1);
26126
26620
  var name = "@fedify/vocab-runtime";
26127
- var version = "2.5.0-dev.2446+2014cb03";
26621
+ var version = "2.5.0-dev.2492+4648c04e";
26128
26622
  /**
26129
26623
  * Error thrown when fetching a JSON-LD document failed.
26130
26624
  */
@@ -26541,7 +27035,7 @@ async function bufferErrorResponse(response, url, signal) {
26541
27035
  try {
26542
27036
  await readBoundedBytes(response, MAX_ERROR_BODY_SIZE, url);
26543
27037
  } catch (error) {
26544
- await clone.body?.cancel().catch(() => {});
27038
+ clone.body?.cancel().catch(() => {});
26545
27039
  if (signal?.aborted) throw error;
26546
27040
  logger.debug("Failed to read the error response body from {url}: {error}", {
26547
27041
  url,
@@ -26570,17 +27064,34 @@ async function bufferErrorResponse(response, url, signal) {
26570
27064
  });
26571
27065
  }
26572
27066
  /**
26573
- * Gets a {@link RemoteDocument} from the given response.
27067
+ * Starts cancelling the body of a response without waiting for it to settle.
27068
+ * The cancellation of a branch of a tee (e.g., a body cloned by a fetch mock)
27069
+ * settles only after the other branch is cancelled too, per the Streams
27070
+ * standard, so awaiting it could block forever. Failures are only logged.
27071
+ * @param response The response whose body is cancelled.
27072
+ * @param url The URL of the response, for logging.
27073
+ */
27074
+ function cancelResponseBody(response, url) {
27075
+ response.body?.cancel().catch((error) => {
27076
+ logger.debug("Failed to cancel the response body from {url}: {error}", {
27077
+ url,
27078
+ error
27079
+ });
27080
+ });
27081
+ }
27082
+ /**
27083
+ * Gets a {@link RemoteDocument} from the given response, or the URL of an
27084
+ * alternate document to load instead. When an alternate document is found,
27085
+ * the response body has been either read or its cancellation has been started
27086
+ * (without waiting for it), so that the alternate document can be loaded after
27087
+ * the response's session is closed.
26574
27088
  * @param url The URL of the document to load.
26575
27089
  * @param response The response to get the document from.
26576
- * @param fetch The function to fetch the document.
26577
- * @param options The options for loading the document.
26578
- * @returns The loaded remote document.
27090
+ * @returns The loaded remote document, or the alternate document URL.
26579
27091
  * @throws {FetchError} If the response is not OK.
26580
- * @internal
26581
27092
  */
26582
- async function getRemoteDocument(url, response, fetch, options) {
26583
- const documentUrl = response.url === "" ? url : response.url;
27093
+ async function parseRemoteDocument(url, response, options) {
27094
+ const documentUrl = url;
26584
27095
  const docUrl = new URL(documentUrl);
26585
27096
  if (!response.ok) {
26586
27097
  if (options?.suppressError) logger.warn("Failed to fetch document: {status} {url} {headers}", {
@@ -26622,7 +27133,8 @@ async function getRemoteDocument(url, response, fetch, options) {
26622
27133
  alternateUrl: altUri.href,
26623
27134
  url: documentUrl
26624
27135
  });
26625
- return await fetch(altUri.href, options);
27136
+ cancelResponseBody(response, documentUrl);
27137
+ return { alternate: altUri.href };
26626
27138
  }
26627
27139
  }
26628
27140
  }
@@ -26658,7 +27170,7 @@ async function getRemoteDocument(url, response, fetch, options) {
26658
27170
  alternateUrl: attribs.href,
26659
27171
  url: documentUrl
26660
27172
  });
26661
- return await fetch(new URL(attribs.href, docUrl).href, options);
27173
+ return { alternate: new URL(attribs.href, docUrl).href };
26662
27174
  }
26663
27175
  }
26664
27176
  try {
@@ -26673,11 +27185,11 @@ async function getRemoteDocument(url, response, fetch, options) {
26673
27185
  url: documentUrl,
26674
27186
  headers: Object.fromEntries(response.headers.entries())
26675
27187
  });
26676
- return {
27188
+ return { document: {
26677
27189
  contextUrl,
26678
27190
  document,
26679
27191
  documentUrl
26680
- };
27192
+ } };
26681
27193
  }
26682
27194
  /**
26683
27195
  * Resolves {@link DocumentLoaderFactoryOptions.timeout} into milliseconds.
@@ -26782,10 +27294,59 @@ function withDocumentLoaderTimeout(loader, timeout) {
26782
27294
  * @returns The document loader.
26783
27295
  * @since 1.3.0
26784
27296
  */
26785
- function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, timeout, userAgent } = {}) {
27297
+ function getDocumentLoader(options = {}) {
27298
+ return createDocumentLoader(options);
27299
+ }
27300
+ /**
27301
+ * The implementation of {@link getDocumentLoader}, with injectable
27302
+ * dependencies. This is not exported from any entry point.
27303
+ * @param options Options for the document loader.
27304
+ * @param deps The dependencies.
27305
+ * @returns The document loader.
27306
+ */
27307
+ function createDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedContexts, timeout, userAgent } = {}, deps = {}) {
26786
27308
  const resolvedTimeout = resolveDocumentLoaderTimeout(timeout);
26787
27309
  const tracer = trace.getTracerProvider().getTracer(name, version);
26788
27310
  const maximumRedirection = maxRedirection ?? DEFAULT_MAX_REDIRECTION;
27311
+ async function openHop(url, options) {
27312
+ try {
27313
+ const session = deps.openHop != null ? await deps.openHop(url) : allowPrivateAddress ? await openDirectFetch(url) : await openPublicFetch(url, { signal: options?.signal });
27314
+ if (options?.signal?.aborted) {
27315
+ await session.close();
27316
+ options.signal.throwIfAborted();
27317
+ }
27318
+ return session;
27319
+ } catch (error) {
27320
+ if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
27321
+ url,
27322
+ error
27323
+ });
27324
+ else logger[options?.suppressError ? "warn" : "error"]("Disallowed private URL: {url}", {
27325
+ url,
27326
+ error
27327
+ });
27328
+ throw error;
27329
+ }
27330
+ }
27331
+ async function fetchHop(session, url, span, options) {
27332
+ try {
27333
+ const request = createActivityPubRequest(url, { userAgent });
27334
+ logRequest(logger, request);
27335
+ const response = await session.fetch(request, {
27336
+ redirect: "manual",
27337
+ signal: options?.signal
27338
+ });
27339
+ span.setAttribute("http.response.status_code", response.status);
27340
+ if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
27341
+ const location = response.headers.get("Location");
27342
+ cancelResponseBody(response, url);
27343
+ return { location };
27344
+ }
27345
+ return await parseRemoteDocument(url, response, options);
27346
+ } finally {
27347
+ await session.close();
27348
+ }
27349
+ }
26789
27350
  async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
26790
27351
  options?.signal?.throwIfAborted();
26791
27352
  const parsedUrl = new URL(url);
@@ -26800,36 +27361,15 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
26800
27361
  documentUrl: currentUrl
26801
27362
  };
26802
27363
  }
26803
- if (!allowPrivateAddress) {
26804
- try {
26805
- await validatePublicUrl(currentUrl);
26806
- } catch (error) {
26807
- if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
26808
- url: currentUrl,
26809
- error
26810
- });
26811
- else logger[options?.suppressError ? "warn" : "error"]("Disallowed private URL: {url}", {
26812
- url: currentUrl,
26813
- error
26814
- });
26815
- throw error;
26816
- }
26817
- options?.signal?.throwIfAborted();
26818
- }
27364
+ const session = await openHop(currentUrl, options);
26819
27365
  visited.add(currentUrl);
26820
27366
  return await tracer.startActiveSpan("activitypub.fetch_document", {
26821
27367
  kind: SpanKind.CLIENT,
26822
27368
  attributes: { "url.full": currentUrl }
26823
27369
  }, async (span) => {
26824
27370
  try {
26825
- const request = createActivityPubRequest(currentUrl, { userAgent });
26826
- logRequest(logger, request);
26827
- const response = await fetch(request, {
26828
- redirect: "manual",
26829
- signal: options?.signal
26830
- });
26831
- span.setAttribute("http.response.status_code", response.status);
26832
- if (response.status >= 300 && response.status < 400 && response.headers.has("Location")) {
27371
+ const outcome = await fetchHop(session, currentUrl, span, options);
27372
+ if ("location" in outcome) {
26833
27373
  if (redirected >= maximumRedirection) {
26834
27374
  logger[options?.suppressError ? "warn" : "error"]("Too many redirections ({redirections}) while fetching document.", {
26835
27375
  redirections: redirected + 1,
@@ -26837,7 +27377,7 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
26837
27377
  });
26838
27378
  throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
26839
27379
  }
26840
- const redirectUrl = new URL(response.headers.get("Location"), response.url === "" ? currentUrl : response.url).href;
27380
+ const redirectUrl = new URL(outcome.location, currentUrl).href;
26841
27381
  span.setAttribute("http.redirect.url", redirectUrl);
26842
27382
  if (visited.has(redirectUrl)) {
26843
27383
  logger[options?.suppressError ? "warn" : "error"]("Detected a redirect loop while fetching document: {url} -> {redirectUrl}", {
@@ -26848,12 +27388,14 @@ function getDocumentLoader({ allowPrivateAddress, maxRedirection, skipPreloadedC
26848
27388
  }
26849
27389
  return await load(redirectUrl, options, redirected + 1, visited);
26850
27390
  }
26851
- const result = await getRemoteDocument(currentUrl, response, async (alternateUrl) => {
27391
+ let result;
27392
+ if ("alternate" in outcome) {
27393
+ const alternateUrl = outcome.alternate;
26852
27394
  options?.signal?.throwIfAborted();
26853
27395
  if (redirected >= DEFAULT_MAX_REDIRECTION) throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
26854
27396
  if (visited.has(alternateUrl)) throw new FetchError(currentUrl, `Redirect loop detected: ${alternateUrl}`);
26855
- return await load(alternateUrl, options, redirected + 1, visited);
26856
- }, options);
27397
+ result = await load(alternateUrl, options, redirected + 1, visited);
27398
+ } else result = outcome.document;
26857
27399
  span.setAttribute("docloader.document_url", result.documentUrl);
26858
27400
  if (result.contextUrl != null) span.setAttribute("docloader.context_url", result.contextUrl);
26859
27401
  return result;