@fedify/vocab-runtime 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.
Files changed (121) hide show
  1. package/deno.json +5 -2
  2. package/dist/{docloader-BBnBpbsG.d.cts → docloader-DD6binpN.d.cts} +4 -8
  3. package/dist/{docloader-BBnBpbsG.d.ts → docloader-DD6binpN.d.ts} +4 -8
  4. package/dist/internal/jsonld-cache.cjs +1 -1
  5. package/dist/internal/jsonld-cache.d.cts +1 -1
  6. package/dist/internal/jsonld-cache.d.ts +1 -1
  7. package/dist/internal/jsonld-cache.js +1 -1
  8. package/dist/internal/portable-dereference.cjs +72 -7
  9. package/dist/internal/portable-dereference.d.cts +63 -3
  10. package/dist/internal/portable-dereference.d.ts +63 -3
  11. package/dist/internal/portable-dereference.js +72 -8
  12. package/dist/internal.cjs +6 -0
  13. package/dist/internal.d.cts +108 -0
  14. package/dist/internal.d.ts +108 -0
  15. package/dist/internal.js +3 -0
  16. package/dist/mod.cjs +104 -38
  17. package/dist/mod.d.cts +3 -336
  18. package/dist/mod.d.ts +4 -336
  19. package/dist/mod.js +104 -38
  20. package/dist/{portable-BDydjp5H.d.ts → portable-1VSnSJFj.d.ts} +1 -1
  21. package/dist/{portable-Dfvcg_Wy.d.cts → portable-Cr7NwZwb.d.cts} +1 -1
  22. package/dist/public-fetch-Dvfvawei.cjs +527 -0
  23. package/dist/public-fetch-ziZ39L5I.js +510 -0
  24. package/dist/tests/{body-Ddx6WgR5.cjs → body-C8d1PmDa.cjs} +1 -1
  25. package/dist/tests/{body-CQi2bVi5.mjs → body-HSLxhexN.mjs} +1 -1
  26. package/dist/tests/body.test.cjs +1 -1
  27. package/dist/tests/body.test.mjs +1 -1
  28. package/dist/tests/decimal.test.cjs +4 -4
  29. package/dist/tests/decimal.test.mjs +4 -4
  30. package/dist/tests/{docloader-C3vDXwn1.cjs → docloader-D1fNCGam.cjs} +111 -39
  31. package/dist/tests/{docloader-CrVannnU.mjs → docloader-DkEUFJui.mjs} +106 -40
  32. package/dist/tests/docloader.test.cjs +531 -1293
  33. package/dist/tests/docloader.test.mjs +472 -1252
  34. package/dist/tests/esm-Ds-eHTfY.mjs +1229 -0
  35. package/dist/tests/esm-Q2tdh5f3.cjs +1215 -0
  36. package/dist/tests/internal/portable-dereference.test.cjs +3 -3
  37. package/dist/tests/internal/portable-dereference.test.mjs +3 -3
  38. package/dist/tests/{jsonld-cache-BEXiiM-V.cjs → jsonld-cache-DKIWKxEX.cjs} +1 -1
  39. package/dist/tests/{jsonld-cache-DFnWsLuZ.mjs → jsonld-cache-KH40z9_7.mjs} +1 -1
  40. package/dist/tests/jsonld-cache.test.cjs +2 -2
  41. package/dist/tests/jsonld-cache.test.mjs +2 -2
  42. package/dist/tests/node-CbBR-th-.mjs +84 -0
  43. package/dist/tests/node-Ch29PDXR.cjs +89 -0
  44. package/dist/tests/pin-grant-CPrpk1l4.cjs +70 -0
  45. package/dist/tests/pin-grant-H5XhM1Xs.mjs +53 -0
  46. package/dist/tests/pin-grant.test.cjs +87 -0
  47. package/dist/tests/pin-grant.test.d.cts +1 -0
  48. package/dist/tests/pin-grant.test.d.mts +1 -0
  49. package/dist/tests/pin-grant.test.mjs +88 -0
  50. package/dist/tests/pinners/bun.test.cjs +166 -0
  51. package/dist/tests/pinners/bun.test.d.cts +1 -0
  52. package/dist/tests/pinners/bun.test.d.mts +1 -0
  53. package/dist/tests/pinners/bun.test.mjs +167 -0
  54. package/dist/tests/pinners/node.test.cjs +269 -0
  55. package/dist/tests/pinners/node.test.d.cts +1 -0
  56. package/dist/tests/pinners/node.test.d.mts +1 -0
  57. package/dist/tests/pinners/node.test.mjs +268 -0
  58. package/dist/tests/pinners/pinners.test.cjs +196 -0
  59. package/dist/tests/pinners/pinners.test.d.cts +1 -0
  60. package/dist/tests/pinners/pinners.test.d.mts +1 -0
  61. package/dist/tests/pinners/pinners.test.mjs +197 -0
  62. package/dist/tests/pinners/testdata/node-child.cjs +135 -0
  63. package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
  64. package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
  65. package/dist/tests/pinners/testdata/node-child.mjs +134 -0
  66. package/dist/tests/pinners-DLH_npLf.cjs +220 -0
  67. package/dist/tests/pinners-tGcl49uF.mjs +203 -0
  68. package/dist/tests/{portable-dereference-DaTBBvCj.cjs → portable-dereference-BUoDzMxk.cjs} +2 -2
  69. package/dist/tests/{portable-dereference-sGRs_qdT.mjs → portable-dereference-Du2k3ngE.mjs} +2 -2
  70. package/dist/tests/{portable-media-BJf_gGQz.cjs → portable-media-BM9zgj9g.cjs} +3 -3
  71. package/dist/tests/{portable-media-Cc58dUeE.mjs → portable-media-CTxGibcj.mjs} +3 -3
  72. package/dist/tests/portable-media.test.cjs +1 -1
  73. package/dist/tests/portable-media.test.mjs +1 -1
  74. package/dist/tests/portable-workers.test.cjs +2 -2
  75. package/dist/tests/portable-workers.test.mjs +2 -2
  76. package/dist/tests/public-fetch-BGj5pzUD.cjs +186 -0
  77. package/dist/tests/public-fetch-Cxo3Yzdf.mjs +170 -0
  78. package/dist/tests/public-fetch.test.cjs +1073 -0
  79. package/dist/tests/public-fetch.test.d.cts +1 -0
  80. package/dist/tests/public-fetch.test.d.mts +1 -0
  81. package/dist/tests/public-fetch.test.mjs +1074 -0
  82. package/dist/tests/{request-glvxfx2U.cjs → request-7q-Zfe1p.cjs} +2 -2
  83. package/dist/tests/{request-1pPyYz3e.mjs → request-D5uGtmmq.mjs} +2 -2
  84. package/dist/tests/request.test.cjs +2 -2
  85. package/dist/tests/request.test.mjs +2 -2
  86. package/dist/tests/support-Bj44xlQu.mjs +272 -0
  87. package/dist/tests/support-BrdlbLJk.cjs +363 -0
  88. package/dist/tests/{url-BNakuZ8k.cjs → url-Bdj-rTcC.cjs} +88 -12
  89. package/dist/tests/{url-DMxmp7ZG.mjs → url-COj5Q8mb.mjs} +65 -13
  90. package/dist/tests/url.test.cjs +4 -1
  91. package/dist/tests/url.test.mjs +4 -1
  92. package/dist/url-4EE56sNP.d.cts +352 -0
  93. package/dist/url-4EE56sNP.d.ts +352 -0
  94. package/dist/{url-DrGTR8yv.cjs → url-DIKZnaJv.cjs} +88 -12
  95. package/dist/{url-Dzyp-NsC.js → url-mWveR5GA.js} +65 -13
  96. package/package.json +14 -3
  97. package/src/docloader.test.ts +696 -48
  98. package/src/docloader.ts +186 -75
  99. package/src/internal/portable-dereference.ts +116 -6
  100. package/src/internal.ts +13 -0
  101. package/src/pin-grant.test.ts +118 -0
  102. package/src/pin-grant.ts +99 -0
  103. package/src/pinners/bun.test.ts +278 -0
  104. package/src/pinners/bun.ts +223 -0
  105. package/src/pinners/mod.ts +59 -0
  106. package/src/pinners/node.test.ts +415 -0
  107. package/src/pinners/node.ts +144 -0
  108. package/src/pinners/pinners.test.ts +316 -0
  109. package/src/pinners/testdata/node-child.ts +147 -0
  110. package/src/pinners/testdata/proxy-child.ts +82 -0
  111. package/src/pinners/testdata/support.ts +343 -0
  112. package/src/pinners/testdata/tls-cert.pem +12 -0
  113. package/src/pinners/testdata/tls-child.ts +88 -0
  114. package/src/pinners/testdata/tls-key.pem +5 -0
  115. package/src/pinners/types.ts +48 -0
  116. package/src/public-fetch.test.ts +1286 -0
  117. package/src/public-fetch.ts +338 -0
  118. package/src/testdata/public-fetch-deno-worker.ts +74 -0
  119. package/src/url.test.ts +11 -0
  120. package/src/url.ts +113 -22
  121. package/tsdown.config.ts +7 -2
@@ -1,7 +1,7 @@
1
1
  import { configure, type LogRecord, reset } from "@logtape/logtape";
2
2
  import fetchMock from "fetch-mock";
3
3
  import { deepStrictEqual, ok, rejects, throws } from "node:assert";
4
- import dns from "node:dns/promises";
4
+ import type { LookupAddress } from "node:dns";
5
5
  import { test } from "@fedify/fixture";
6
6
  import {
7
7
  createServer,
@@ -12,12 +12,16 @@ import { gzipSync } from "node:zlib";
12
12
  import preloadedContexts from "./contexts.ts";
13
13
  import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
14
14
  import {
15
+ createDocumentLoader,
15
16
  getDocumentLoader,
16
17
  getRemoteDocument,
18
+ type RemoteDocument,
17
19
  resolveDocumentLoaderTimeout,
18
20
  withDocumentLoaderTimeout,
19
21
  } from "./docloader.ts";
20
22
  import jsonld from "./jsonld.ts";
23
+ import type { ConnectionPinner } from "./pinners/mod.ts";
24
+ import { createPublicFetch, type PublicFetchSession } from "./public-fetch.ts";
21
25
  import { FetchError } from "./request.ts";
22
26
  import { UrlError } from "./url.ts";
23
27
 
@@ -93,6 +97,32 @@ test("getRemoteDocument() forwards options to alternate documents", async () =>
93
97
  }
94
98
  });
95
99
 
100
+ // Validates addresses like openPublicFetch() but sends requests through the
101
+ // global fetch() without pinning, so that fetch-mock's hostname routes match
102
+ // on every runtime (the Bun pinner would substitute IP addresses). The lookup
103
+ // is always injected, so tests do not depend on the network's DNS.
104
+ const unpinnedPublicFetch = createPublicFetch({
105
+ pinner: () => Promise.resolve(null),
106
+ lookup: () => Promise.reject(new Error("Unexpected default lookup")),
107
+ });
108
+
109
+ const PUBLIC_ADDRESS: LookupAddress = { address: "93.184.215.14", family: 4 };
110
+ const PRIVATE_ADDRESS: LookupAddress = { address: "10.0.0.1", family: 4 };
111
+
112
+ function lookupTestAddresses(
113
+ hostname: string,
114
+ ): Promise<readonly LookupAddress[]> {
115
+ return Promise.resolve([
116
+ hostname === "private.example" ? PRIVATE_ADDRESS : PUBLIC_ADDRESS,
117
+ ]);
118
+ }
119
+
120
+ function openUnpinnedHop(url: string): Promise<PublicFetchSession> {
121
+ return unpinnedPublicFetch.openPublicFetch(url, {
122
+ lookup: lookupTestAddresses,
123
+ });
124
+ }
125
+
96
126
  test("new FetchError()", () => {
97
127
  const e = new FetchError("https://example.com/", "An error message.");
98
128
  deepStrictEqual(e.name, "FetchError");
@@ -113,7 +143,9 @@ test("new FetchError()", () => {
113
143
  });
114
144
 
115
145
  test("getDocumentLoader()", async (t) => {
116
- const fetchDocumentLoader = getDocumentLoader();
146
+ const fetchDocumentLoader = createDocumentLoader({}, {
147
+ openHop: openUnpinnedHop,
148
+ });
117
149
 
118
150
  fetchMock.spyGlobal();
119
151
 
@@ -736,6 +768,19 @@ test("getDocumentLoader()", async (t) => {
736
768
  },
737
769
  });
738
770
 
771
+ fetchMock.get("https://example.com/private-redirect", {
772
+ status: 302,
773
+ headers: { Location: "https://private.example/object" },
774
+ });
775
+
776
+ fetchMock.get("https://private.example/object", {
777
+ body: {
778
+ "@context": "https://www.w3.org/ns/activitystreams",
779
+ id: "https://private.example/object",
780
+ type: "Object",
781
+ },
782
+ });
783
+
739
784
  await t.step("allowPrivateAddress: false", async () => {
740
785
  await rejects(
741
786
  () => fetchDocumentLoader("https://localhost/object"),
@@ -749,6 +794,26 @@ test("getDocumentLoader()", async (t) => {
749
794
  () => fetchDocumentLoader("https://example.com/localhost-link"),
750
795
  UrlError,
751
796
  );
797
+ await rejects(
798
+ () => fetchDocumentLoader("https://private.example/object"),
799
+ UrlError,
800
+ );
801
+ await rejects(
802
+ () => fetchDocumentLoader("https://example.com/private-redirect"),
803
+ UrlError,
804
+ );
805
+ deepStrictEqual(
806
+ fetchMock.callHistory.calls("https://private.example/object").length,
807
+ 0,
808
+ );
809
+ // The default loader rejects these without resolving any hostname:
810
+ const defaultLoader = getDocumentLoader();
811
+ await rejects(() => defaultLoader("https://localhost/object"), UrlError);
812
+ await rejects(() => defaultLoader("https://127.0.0.1/object"), UrlError);
813
+ deepStrictEqual(
814
+ fetchMock.callHistory.calls("https://localhost/object").length,
815
+ 0,
816
+ );
752
817
  });
753
818
 
754
819
  const fetchDocumentLoader2 = getDocumentLoader({ allowPrivateAddress: true });
@@ -802,7 +867,10 @@ test("getDocumentLoader()", async (t) => {
802
867
 
803
868
  await t.step("custom max redirection", async () => {
804
869
  redirectAttempts = 0;
805
- const loader = getDocumentLoader({ maxRedirection: 1 });
870
+ const loader = createDocumentLoader(
871
+ { maxRedirection: 1 },
872
+ { openHop: openUnpinnedHop },
873
+ );
806
874
  await rejects(
807
875
  () => loader("https://example.com/too-many-redirects/0"),
808
876
  FetchError,
@@ -1009,33 +1077,24 @@ test("getRemoteDocument() bounds JSON by default", async () => {
1009
1077
  });
1010
1078
 
1011
1079
  test("getDocumentLoader() logs DNS failures as such", async (t) => {
1012
- // The validator skips DNS when Deno has no network permission. Checked
1013
- // here rather than with top-level await, which the CommonJS build rejects.
1014
- if (
1015
- "Deno" in globalThis &&
1016
- (await Deno.permissions.query({ name: "net" })).state !== "granted"
1017
- ) {
1018
- throw new Error(
1019
- "This DNS test requires Deno network permission (--allow-net).",
1020
- );
1021
- }
1022
- const loader = getDocumentLoader();
1023
- for (const result of ["throws", "empty", "private"] as const) {
1080
+ for (const result of ["throws", "empty", "private", "default"] as const) {
1024
1081
  await t.step(result, async () => {
1025
- // Stubbing works only because url.ts uses the default node:dns/promises
1026
- // import; see the FIXME there.
1027
- const originalLookup = dns.lookup;
1028
- dns.lookup = (() =>
1082
+ const lookup = (): Promise<readonly LookupAddress[]> =>
1029
1083
  result === "throws"
1030
1084
  ? Promise.reject(new Error("Resolver unavailable"))
1031
- : Promise.resolve(
1032
- result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
1033
- )) as typeof dns.lookup;
1085
+ : Promise.resolve(result === "empty" ? [] : [PRIVATE_ADDRESS]);
1086
+ // The default loader rejects IP literals without resolving them, so
1087
+ // the "default" step checks that its logging matches the injected one:
1088
+ const loader = result === "default"
1089
+ ? getDocumentLoader()
1090
+ : createDocumentLoader({}, {
1091
+ openHop: (url) =>
1092
+ unpinnedPublicFetch.openPublicFetch(url, { lookup }),
1093
+ });
1034
1094
  const records: LogRecord[] = [];
1035
1095
  await configure({
1036
1096
  sinks: {
1037
- buffer: (record) =>
1038
- records.push(record),
1097
+ buffer: (record) => records.push(record),
1039
1098
  },
1040
1099
  loggers: [
1041
1100
  { category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
@@ -1044,21 +1103,21 @@ test("getDocumentLoader() logs DNS failures as such", async (t) => {
1044
1103
  reset: true,
1045
1104
  });
1046
1105
  try {
1047
- const url = "https://dns-failure.invalid/object";
1106
+ const url = result === "default"
1107
+ ? "https://10.0.0.1/object"
1108
+ : "https://dns-failure.invalid/object";
1048
1109
  let error: unknown;
1049
1110
  await rejects(() => loader(url), (e) => {
1050
1111
  error = e;
1051
1112
  return true;
1052
1113
  });
1053
1114
  ok(error instanceof UrlError);
1054
- deepStrictEqual(
1055
- error.reason,
1056
- result === "private" ? "disallowed" : "dns",
1057
- );
1115
+ const disallowed = result === "private" || result === "default";
1116
+ deepStrictEqual(error.reason, disallowed ? "disallowed" : "dns");
1058
1117
  deepStrictEqual(
1059
1118
  records.map((r) => [r.level, r.rawMessage, r.properties.url]),
1060
1119
  [
1061
- result === "private"
1120
+ disallowed
1062
1121
  ? ["error", "Disallowed private URL: {url}", url]
1063
1122
  : ["debug", "DNS lookup failed for {url}", url],
1064
1123
  ],
@@ -1066,7 +1125,6 @@ test("getDocumentLoader() logs DNS failures as such", async (t) => {
1066
1125
  ok(records[0].properties.error === error);
1067
1126
  } finally {
1068
1127
  await reset();
1069
- dns.lookup = originalLookup;
1070
1128
  }
1071
1129
  });
1072
1130
  }
@@ -1462,15 +1520,6 @@ test("getDocumentLoader() lets the caller's signal abort a request", async () =>
1462
1520
  });
1463
1521
 
1464
1522
  test("getDocumentLoader() bounds DNS lookups that ignore the timeout", async () => {
1465
- // The validator skips DNS when Deno has no network permission.
1466
- if (
1467
- "Deno" in globalThis &&
1468
- (await Deno.permissions.query({ name: "net" })).state !== "granted"
1469
- ) {
1470
- throw new Error(
1471
- "This DNS test requires Deno network permission (--allow-net).",
1472
- );
1473
- }
1474
1523
  fetchMock.mockGlobal();
1475
1524
  let requests = 0;
1476
1525
  const url = "https://slow-dns.example/object";
@@ -1478,27 +1527,25 @@ test("getDocumentLoader() bounds DNS lookups that ignore the timeout", async ()
1478
1527
  requests++;
1479
1528
  return Response.json({ id: url });
1480
1529
  });
1481
- // Stubbing works only because url.ts uses the default node:dns/promises
1482
- // import; see the FIXME there.
1483
- const originalLookup = dns.lookup;
1484
1530
  let resolveLookup: () => void = () => {};
1485
1531
  const lookedUp = new Promise<void>((resolve) => resolveLookup = resolve);
1486
- dns.lookup = (() =>
1487
- new Promise((resolve) =>
1532
+ const lookup = () =>
1533
+ new Promise<LookupAddress[]>((resolve) =>
1488
1534
  setTimeout(() => {
1489
1535
  resolve([{ address: "93.184.215.14", family: 4 }]);
1490
1536
  resolveLookup();
1491
1537
  }, 200)
1492
- )) as typeof dns.lookup;
1538
+ );
1493
1539
  try {
1494
- const loader = getDocumentLoader({ timeout: 50 });
1540
+ const loader = createDocumentLoader({ timeout: 50 }, {
1541
+ openHop: (url) => unpinnedPublicFetch.openPublicFetch(url, { lookup }),
1542
+ });
1495
1543
  await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1496
1544
  // A late DNS answer must not start the request anymore:
1497
1545
  await lookedUp;
1498
1546
  await new Promise((resolve) => setTimeout(resolve, 10));
1499
1547
  deepStrictEqual(requests, 0);
1500
1548
  } finally {
1501
- dns.lookup = originalLookup;
1502
1549
  fetchMock.hardReset();
1503
1550
  }
1504
1551
  });
@@ -1786,3 +1833,604 @@ for (const url of unsupportedUrls) {
1786
1833
  );
1787
1834
  }
1788
1835
  }
1836
+
1837
+ interface RecordedResponseInit {
1838
+ readonly status?: number;
1839
+ readonly headers?: Record<string, string>;
1840
+ readonly body?: string;
1841
+ /** Whether cancelling the body fails. */
1842
+ readonly failCancel?: boolean;
1843
+ /** Whether cancelling the body never settles, like a tee branch's. */
1844
+ readonly hangCancel?: boolean;
1845
+ /** The `Response.url` to report, like an IP address URL on Bun. */
1846
+ readonly responseUrl?: string;
1847
+ }
1848
+
1849
+ /**
1850
+ * Records the life cycle of fetch sessions and their responses as events:
1851
+ * `open <url>`, `fetch <url> #n`, `cancel #n`, `consumed #n`, and
1852
+ * `close <url>`.
1853
+ */
1854
+ interface HopRecorder {
1855
+ readonly events: readonly string[];
1856
+ /** Makes a response whose body records being consumed or cancelled. */
1857
+ respond(url: string): Response;
1858
+ /** Records opening and closing the given session. */
1859
+ record(url: string, session: PublicFetchSession): PublicFetchSession;
1860
+ /** Opens a session that sends requests to {@link HopRecorder.respond}. */
1861
+ openHop(url: string): Promise<PublicFetchSession>;
1862
+ /**
1863
+ * Checks that every opened session is closed, and that every response is
1864
+ * consumed or cancelled before its session is closed.
1865
+ */
1866
+ assertInvariants(): void;
1867
+ }
1868
+
1869
+ function createHopRecorder(
1870
+ responder: (url: string) => RecordedResponseInit,
1871
+ ): HopRecorder {
1872
+ const events: string[] = [];
1873
+ const states = new Map<Response, { readonly n: number; settled: boolean }>();
1874
+ const violations: string[] = [];
1875
+ let responses = 0;
1876
+ let opens = 0;
1877
+ let closes = 0;
1878
+
1879
+ function respond(url: string): Response {
1880
+ const n = ++responses;
1881
+ events.push(`fetch ${url} #${n}`);
1882
+ const {
1883
+ status = 200,
1884
+ headers,
1885
+ body = "",
1886
+ failCancel,
1887
+ hangCancel,
1888
+ responseUrl,
1889
+ } = responder(url);
1890
+ const state = { n, settled: false };
1891
+ let sent = false;
1892
+ const stream = new ReadableStream<Uint8Array>({
1893
+ pull(controller) {
1894
+ if (!sent && body !== "") {
1895
+ sent = true;
1896
+ controller.enqueue(new TextEncoder().encode(body));
1897
+ return;
1898
+ }
1899
+ controller.close();
1900
+ state.settled = true;
1901
+ events.push(`consumed #${n}`);
1902
+ },
1903
+ cancel() {
1904
+ state.settled = true;
1905
+ events.push(`cancel #${n}`);
1906
+ if (failCancel) throw new Error(`Failed to cancel #${n}`);
1907
+ if (hangCancel) return new Promise<void>(() => {});
1908
+ },
1909
+ }, { highWaterMark: 0 });
1910
+ const response = new Response(stream, { status, headers });
1911
+ if (responseUrl != null) {
1912
+ Object.defineProperty(response, "url", { value: responseUrl });
1913
+ }
1914
+ states.set(response, state);
1915
+ return response;
1916
+ }
1917
+
1918
+ function record(
1919
+ url: string,
1920
+ session: PublicFetchSession,
1921
+ ): PublicFetchSession {
1922
+ opens++;
1923
+ events.push(`open ${url}`);
1924
+ const fetched: Response[] = [];
1925
+ let closed = false;
1926
+ return {
1927
+ get url() {
1928
+ return session.url;
1929
+ },
1930
+ async fetch(input, init) {
1931
+ const response = await session.fetch(input, init);
1932
+ fetched.push(response);
1933
+ return response;
1934
+ },
1935
+ async close() {
1936
+ if (closed) return;
1937
+ closed = true;
1938
+ for (const response of fetched) {
1939
+ const state = states.get(response);
1940
+ if (state != null && !state.settled) {
1941
+ violations.push(`#${state.n} is still open when closing ${url}`);
1942
+ }
1943
+ }
1944
+ closes++;
1945
+ events.push(`close ${url}`);
1946
+ await session.close();
1947
+ },
1948
+ };
1949
+ }
1950
+
1951
+ function openHop(url: string): Promise<PublicFetchSession> {
1952
+ const origin = new URL(url).origin;
1953
+ return Promise.resolve(record(url, {
1954
+ url: new URL(url),
1955
+ fetch(input) {
1956
+ const target = input instanceof Request ? input.url : String(input);
1957
+ ok(new URL(target).origin === origin, `${target} is not on ${origin}`);
1958
+ return Promise.resolve(respond(target));
1959
+ },
1960
+ close: () => Promise.resolve(),
1961
+ }));
1962
+ }
1963
+
1964
+ return {
1965
+ events,
1966
+ respond,
1967
+ record,
1968
+ openHop,
1969
+ assertInvariants() {
1970
+ deepStrictEqual(violations, []);
1971
+ deepStrictEqual(opens, closes);
1972
+ },
1973
+ };
1974
+ }
1975
+
1976
+ const lifecycleDocument = {
1977
+ "@context": "https://www.w3.org/ns/activitystreams",
1978
+ id: "https://b.example/object",
1979
+ type: "Object",
1980
+ };
1981
+
1982
+ test("getDocumentLoader() closes a redirect hop before the next hop", async () => {
1983
+ const recorder = createHopRecorder((url) =>
1984
+ url === "https://a.example/start"
1985
+ ? { status: 302, headers: { Location: "https://b.example/object" } }
1986
+ : { body: JSON.stringify(lifecycleDocument) }
1987
+ );
1988
+ const loader = createDocumentLoader({}, { openHop: recorder.openHop });
1989
+ deepStrictEqual(await loader("https://a.example/start"), {
1990
+ contextUrl: null,
1991
+ documentUrl: "https://b.example/object",
1992
+ document: lifecycleDocument,
1993
+ });
1994
+ deepStrictEqual(recorder.events, [
1995
+ "open https://a.example/start",
1996
+ "fetch https://a.example/start #1",
1997
+ "cancel #1",
1998
+ "close https://a.example/start",
1999
+ "open https://b.example/object",
2000
+ "fetch https://b.example/object #2",
2001
+ "consumed #2",
2002
+ "close https://b.example/object",
2003
+ ]);
2004
+ recorder.assertInvariants();
2005
+ });
2006
+
2007
+ test("getDocumentLoader() cancels the body before a Link header alternate", async () => {
2008
+ const recorder = createHopRecorder((url) =>
2009
+ url === "https://a.example/start"
2010
+ ? {
2011
+ headers: {
2012
+ "Content-Type": "text/plain",
2013
+ Link: '<https://b.example/object>; rel="alternate"; ' +
2014
+ 'type="application/activity+json"',
2015
+ },
2016
+ body: "not JSON",
2017
+ }
2018
+ : { body: JSON.stringify(lifecycleDocument) }
2019
+ );
2020
+ const loader = createDocumentLoader({}, { openHop: recorder.openHop });
2021
+ deepStrictEqual(
2022
+ (await loader("https://a.example/start")).documentUrl,
2023
+ "https://b.example/object",
2024
+ );
2025
+ deepStrictEqual(recorder.events, [
2026
+ "open https://a.example/start",
2027
+ "fetch https://a.example/start #1",
2028
+ "cancel #1",
2029
+ "close https://a.example/start",
2030
+ "open https://b.example/object",
2031
+ "fetch https://b.example/object #2",
2032
+ "consumed #2",
2033
+ "close https://b.example/object",
2034
+ ]);
2035
+ recorder.assertInvariants();
2036
+ });
2037
+
2038
+ test("getDocumentLoader() reads the body before an HTML alternate", async () => {
2039
+ const recorder = createHopRecorder((url) =>
2040
+ url === "https://a.example/start"
2041
+ ? {
2042
+ headers: { "Content-Type": "text/html" },
2043
+ body: '<link rel="alternate" type="application/activity+json" ' +
2044
+ 'href="https://b.example/object">',
2045
+ }
2046
+ : { body: JSON.stringify(lifecycleDocument) }
2047
+ );
2048
+ const loader = createDocumentLoader({}, { openHop: recorder.openHop });
2049
+ deepStrictEqual(
2050
+ (await loader("https://a.example/start")).documentUrl,
2051
+ "https://b.example/object",
2052
+ );
2053
+ deepStrictEqual(recorder.events, [
2054
+ "open https://a.example/start",
2055
+ "fetch https://a.example/start #1",
2056
+ "consumed #1",
2057
+ "close https://a.example/start",
2058
+ "open https://b.example/object",
2059
+ "fetch https://b.example/object #2",
2060
+ "consumed #2",
2061
+ "close https://b.example/object",
2062
+ ]);
2063
+ recorder.assertInvariants();
2064
+ });
2065
+
2066
+ /**
2067
+ * Fails instead of blocking the test if the given promise does not settle
2068
+ * within the given time.
2069
+ */
2070
+ async function settlesWithin<T>(
2071
+ promise: Promise<T>,
2072
+ milliseconds = 1000,
2073
+ ): Promise<T> {
2074
+ let timer: ReturnType<typeof setTimeout> | undefined;
2075
+ try {
2076
+ return await Promise.race([
2077
+ promise,
2078
+ new Promise<never>((_, reject) => {
2079
+ timer = setTimeout(
2080
+ () => reject(new Error(`Did not settle in ${milliseconds} ms.`)),
2081
+ milliseconds,
2082
+ );
2083
+ }),
2084
+ ]);
2085
+ } finally {
2086
+ clearTimeout(timer);
2087
+ }
2088
+ }
2089
+
2090
+ /**
2091
+ * Runs the given function while capturing the document loader's logs, and
2092
+ * waits until the rejections of body cancellations started without being
2093
+ * awaited have been logged.
2094
+ */
2095
+ async function captureDocumentLoaderLogs<T>(
2096
+ fn: () => Promise<T>,
2097
+ ): Promise<{ result: T; records: LogRecord[] }> {
2098
+ const records: LogRecord[] = [];
2099
+ await configure({
2100
+ sinks: { buffer: (record) => records.push(record) },
2101
+ loggers: [
2102
+ {
2103
+ category: ["fedify", "runtime", "docloader"],
2104
+ sinks: ["buffer"],
2105
+ lowestLevel: "debug",
2106
+ },
2107
+ { category: ["logtape", "meta"], sinks: [] },
2108
+ ],
2109
+ reset: true,
2110
+ });
2111
+ try {
2112
+ const result = await fn();
2113
+ await new Promise((resolve) => setTimeout(resolve, 0));
2114
+ return { result, records };
2115
+ } finally {
2116
+ await reset();
2117
+ }
2118
+ }
2119
+
2120
+ function cancelFailureLogs(
2121
+ records: readonly LogRecord[],
2122
+ ): [string, unknown, string][] {
2123
+ return records
2124
+ .filter((record) =>
2125
+ record.rawMessage ===
2126
+ "Failed to cancel the response body from {url}: {error}"
2127
+ )
2128
+ .map((record) => [
2129
+ record.level,
2130
+ record.properties.url,
2131
+ String(record.properties.error),
2132
+ ]);
2133
+ }
2134
+
2135
+ function linkHeaderAlternate(next: string): RecordedResponseInit {
2136
+ return {
2137
+ headers: {
2138
+ "Content-Type": "text/plain",
2139
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`,
2140
+ },
2141
+ body: "not JSON",
2142
+ };
2143
+ }
2144
+
2145
+ test("getDocumentLoader() continues if an intermediate body fails to cancel", async (t) => {
2146
+ for (const kind of ["redirect", "Link header alternate"] as const) {
2147
+ await t.step(kind, async () => {
2148
+ const recorder = createHopRecorder((url) =>
2149
+ url === "https://a.example/start"
2150
+ ? {
2151
+ ...(kind === "redirect"
2152
+ ? {
2153
+ status: 302,
2154
+ headers: { Location: "https://b.example/object" },
2155
+ }
2156
+ : linkHeaderAlternate("https://b.example/object")),
2157
+ failCancel: true,
2158
+ }
2159
+ : { body: JSON.stringify(lifecycleDocument) }
2160
+ );
2161
+ const loader = createDocumentLoader({}, { openHop: recorder.openHop });
2162
+ const { result, records } = await captureDocumentLoaderLogs(() =>
2163
+ loader("https://a.example/start")
2164
+ );
2165
+ deepStrictEqual(result, {
2166
+ contextUrl: null,
2167
+ documentUrl: "https://b.example/object",
2168
+ document: lifecycleDocument,
2169
+ });
2170
+ deepStrictEqual(recorder.events, [
2171
+ "open https://a.example/start",
2172
+ "fetch https://a.example/start #1",
2173
+ "cancel #1",
2174
+ "close https://a.example/start",
2175
+ "open https://b.example/object",
2176
+ "fetch https://b.example/object #2",
2177
+ "consumed #2",
2178
+ "close https://b.example/object",
2179
+ ]);
2180
+ recorder.assertInvariants();
2181
+ // The failure is only logged:
2182
+ deepStrictEqual(cancelFailureLogs(records), [[
2183
+ "debug",
2184
+ "https://a.example/start",
2185
+ "Error: Failed to cancel #1",
2186
+ ]]);
2187
+ });
2188
+ }
2189
+ });
2190
+
2191
+ test("getDocumentLoader() does not wait for intermediate bodies to be cancelled", async (t) => {
2192
+ for (const kind of ["redirect", "Link header alternate"] as const) {
2193
+ await t.step(kind, async () => {
2194
+ const recorder = createHopRecorder((url) =>
2195
+ url === "https://a.example/start"
2196
+ ? {
2197
+ ...(kind === "redirect"
2198
+ ? {
2199
+ status: 302,
2200
+ headers: { Location: "https://b.example/object" },
2201
+ }
2202
+ : linkHeaderAlternate("https://b.example/object")),
2203
+ hangCancel: true,
2204
+ }
2205
+ : { body: JSON.stringify(lifecycleDocument) }
2206
+ );
2207
+ const loader = createDocumentLoader({}, { openHop: recorder.openHop });
2208
+ deepStrictEqual(
2209
+ (await settlesWithin(loader("https://a.example/start"))).documentUrl,
2210
+ "https://b.example/object",
2211
+ );
2212
+ deepStrictEqual(recorder.events, [
2213
+ "open https://a.example/start",
2214
+ "fetch https://a.example/start #1",
2215
+ "cancel #1",
2216
+ "close https://a.example/start",
2217
+ "open https://b.example/object",
2218
+ "fetch https://b.example/object #2",
2219
+ "consumed #2",
2220
+ "close https://b.example/object",
2221
+ ]);
2222
+ recorder.assertInvariants();
2223
+ });
2224
+ }
2225
+
2226
+ await t.step("public session with a never-settling cancel", async () => {
2227
+ // A real session must not wait for a body it cancels on close() either:
2228
+ let cancelled = 0;
2229
+ const pinner: ConnectionPinner = {
2230
+ supports: () => true,
2231
+ fetch: () =>
2232
+ Promise.resolve(
2233
+ new Response(
2234
+ new ReadableStream({
2235
+ cancel() {
2236
+ cancelled++;
2237
+ return new Promise<void>(() => {});
2238
+ },
2239
+ }),
2240
+ { status: 302, headers: { Location: "file:///private-document" } },
2241
+ ),
2242
+ ),
2243
+ release() {},
2244
+ };
2245
+ const publicFetch = createPublicFetch({
2246
+ pinner: () => Promise.resolve(pinner),
2247
+ lookup: () => Promise.reject(new Error("Unexpected default lookup")),
2248
+ });
2249
+ const loader = createDocumentLoader({}, {
2250
+ openHop: (url) =>
2251
+ publicFetch.openPublicFetch(url, { lookup: lookupTestAddresses }),
2252
+ });
2253
+ await settlesWithin(
2254
+ rejects(loader("https://a.example/redirect"), UrlError),
2255
+ );
2256
+ deepStrictEqual(cancelled, 1);
2257
+ });
2258
+ });
2259
+
2260
+ test("getDocumentLoader() completes with tee'd fetch-mock responses", async (t) => {
2261
+ // fetch-mock returns a clone (a tee branch) of a Response instance that a
2262
+ // route returns, and the cancellation of a tee branch settles only after
2263
+ // the other branch is cancelled too. That never happens for a string body
2264
+ // on Node.js, and for a stream body on any runtime:
2265
+ const url = "https://example.com/teed";
2266
+ function stream(text: string): ReadableStream<Uint8Array> {
2267
+ return new ReadableStream({
2268
+ start(controller) {
2269
+ controller.enqueue(new TextEncoder().encode(text));
2270
+ controller.close();
2271
+ },
2272
+ });
2273
+ }
2274
+ const loaders = {
2275
+ unpinned: createDocumentLoader({}, { openHop: openUnpinnedHop }),
2276
+ "private addresses allowed": getDocumentLoader({
2277
+ allowPrivateAddress: true,
2278
+ }),
2279
+ };
2280
+ for (const [name, loader] of Object.entries(loaders)) {
2281
+ for (const body of ["string", "stream"] as const) {
2282
+ await t.step(`${name}: ${body} bodies`, async () => {
2283
+ const make = (text: string) => body === "string" ? text : stream(text);
2284
+ fetchMock.mockGlobal();
2285
+ try {
2286
+ fetchMock.get(
2287
+ `${url}/object`,
2288
+ () =>
2289
+ new Response(make(JSON.stringify(lifecycleDocument)), {
2290
+ headers: { "Content-Type": "application/activity+json" },
2291
+ }),
2292
+ );
2293
+ fetchMock.get(
2294
+ `${url}/redirect`,
2295
+ () =>
2296
+ new Response(make("redirect"), {
2297
+ status: 302,
2298
+ headers: { Location: `${url}/object` },
2299
+ }),
2300
+ );
2301
+ fetchMock.get(
2302
+ `${url}/alternate`,
2303
+ () =>
2304
+ new Response(make("not JSON"), {
2305
+ headers: {
2306
+ "Content-Type": "text/plain",
2307
+ Link: `<${url}/object>; rel="alternate"; ` +
2308
+ 'type="application/activity+json"',
2309
+ },
2310
+ }),
2311
+ );
2312
+ fetchMock.get(
2313
+ `${url}/missing`,
2314
+ () => new Response(make("not found"), { status: 404 }),
2315
+ );
2316
+ for (const path of ["redirect", "alternate"]) {
2317
+ deepStrictEqual(
2318
+ (await settlesWithin(loader(`${url}/${path}`))).document,
2319
+ lifecycleDocument,
2320
+ );
2321
+ }
2322
+ await settlesWithin(rejects(loader(`${url}/missing`), FetchError));
2323
+ deepStrictEqual(
2324
+ fetchMock.callHistory.calls().map((call) => call.url),
2325
+ [
2326
+ `${url}/redirect`,
2327
+ `${url}/object`,
2328
+ `${url}/alternate`,
2329
+ `${url}/object`,
2330
+ `${url}/missing`,
2331
+ ],
2332
+ );
2333
+ } finally {
2334
+ fetchMock.hardReset();
2335
+ }
2336
+ });
2337
+ }
2338
+ }
2339
+ });
2340
+
2341
+ test("getDocumentLoader() resolves each hop once", async () => {
2342
+ const lookups: string[] = [];
2343
+ const recorder = createHopRecorder((url) => {
2344
+ // Pinned responses report the IP address URL on Bun; the request URL must
2345
+ // still be used as the document URL and as the base of Location:
2346
+ const responseUrl = `https://${PUBLIC_ADDRESS.address}${
2347
+ new URL(url).pathname
2348
+ }`;
2349
+ if (url === "https://a.example/start") {
2350
+ return {
2351
+ status: 302,
2352
+ headers: { Location: "https://b.example/next" },
2353
+ responseUrl,
2354
+ };
2355
+ } else if (url === "https://b.example/next") {
2356
+ return { status: 301, headers: { Location: "/object" }, responseUrl };
2357
+ }
2358
+ return { body: JSON.stringify(lifecycleDocument), responseUrl };
2359
+ });
2360
+ const pinner: ConnectionPinner = {
2361
+ supports: () => true,
2362
+ fetch: (_grant, request) => Promise.resolve(recorder.respond(request.url)),
2363
+ release() {},
2364
+ };
2365
+ const publicFetch = createPublicFetch({
2366
+ pinner: () => Promise.resolve(pinner),
2367
+ lookup: () => Promise.reject(new Error("Unexpected default lookup")),
2368
+ });
2369
+ const loader = createDocumentLoader({}, {
2370
+ openHop: async (url) =>
2371
+ recorder.record(
2372
+ url,
2373
+ await publicFetch.openPublicFetch(url, {
2374
+ lookup(hostname) {
2375
+ lookups.push(hostname);
2376
+ return Promise.resolve([PUBLIC_ADDRESS]);
2377
+ },
2378
+ }),
2379
+ ),
2380
+ });
2381
+ deepStrictEqual(await loader("https://a.example/start"), {
2382
+ contextUrl: null,
2383
+ documentUrl: "https://b.example/object",
2384
+ document: lifecycleDocument,
2385
+ });
2386
+ deepStrictEqual(lookups, ["a.example", "b.example", "b.example"]);
2387
+ deepStrictEqual(recorder.events, [
2388
+ "open https://a.example/start",
2389
+ "fetch https://a.example/start #1",
2390
+ "cancel #1",
2391
+ "close https://a.example/start",
2392
+ "open https://b.example/next",
2393
+ "fetch https://b.example/next #2",
2394
+ "cancel #2",
2395
+ "close https://b.example/next",
2396
+ "open https://b.example/object",
2397
+ "fetch https://b.example/object #3",
2398
+ "consumed #3",
2399
+ "close https://b.example/object",
2400
+ ]);
2401
+ recorder.assertInvariants();
2402
+ });
2403
+
2404
+ test("getRemoteDocument() cancels the body before loading a Link header alternate", async () => {
2405
+ const recorder = createHopRecorder(() => ({
2406
+ headers: {
2407
+ "Content-Type": "text/html",
2408
+ Link: '<https://b.example/object>; rel="alternate"; ' +
2409
+ 'type="application/activity+json"',
2410
+ },
2411
+ body: "<html></html>",
2412
+ }));
2413
+ const response = recorder.respond("https://a.example/start");
2414
+ const placeholder: RemoteDocument = {
2415
+ contextUrl: null,
2416
+ document: {},
2417
+ documentUrl: "https://b.example/object",
2418
+ };
2419
+ const alternates: string[] = [];
2420
+ const result = await getRemoteDocument(
2421
+ "https://a.example/start",
2422
+ response,
2423
+ (url) => {
2424
+ alternates.push(url);
2425
+ deepStrictEqual(recorder.events.at(-1), "cancel #1");
2426
+ ok(response.bodyUsed);
2427
+ return Promise.resolve(placeholder);
2428
+ },
2429
+ );
2430
+ ok(result === placeholder);
2431
+ deepStrictEqual(alternates, ["https://b.example/object"]);
2432
+ deepStrictEqual(recorder.events, [
2433
+ "fetch https://a.example/start #1",
2434
+ "cancel #1",
2435
+ ]);
2436
+ });