@fedify/vocab-runtime 2.4.0-pr.936.41 → 2.4.1

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 (152) hide show
  1. package/deno.json +4 -2
  2. package/dist/{tests/docloader-Ck8bcBir.mjs → contexts-B09jb9Pi.cjs} +173 -274
  3. package/dist/{tests/docloader-S_gQZ2tt.cjs → contexts-DWrj4AGR.js} +169 -293
  4. package/dist/{docloader-C_dir7Xb.d.ts → docloader-BMHljyYb.d.cts} +68 -2
  5. package/dist/{docloader-D2DTRiyA.d.cts → docloader-BMHljyYb.d.ts} +68 -2
  6. package/dist/internal/jsonld-cache.cjs +70 -1
  7. package/dist/internal/jsonld-cache.d.cts +35 -2
  8. package/dist/internal/jsonld-cache.d.ts +35 -2
  9. package/dist/internal/jsonld-cache.js +67 -2
  10. package/dist/internal/portable-dereference.cjs +721 -0
  11. package/dist/internal/portable-dereference.d.cts +349 -0
  12. package/dist/internal/portable-dereference.d.ts +349 -0
  13. package/dist/internal/portable-dereference.js +702 -0
  14. package/dist/internal/signed-representation.cjs +378 -0
  15. package/dist/internal/signed-representation.d.cts +144 -0
  16. package/dist/internal/signed-representation.d.ts +144 -0
  17. package/dist/internal/signed-representation.js +371 -0
  18. package/dist/jsonld.cjs +2 -2
  19. package/dist/mod.cjs +762 -4529
  20. package/dist/mod.d.cts +427 -9
  21. package/dist/mod.d.ts +427 -10
  22. package/dist/mod.js +742 -4524
  23. package/dist/portable-6Aj-7dZJ.d.cts +160 -0
  24. package/dist/portable-nkTiqH0w.d.ts +160 -0
  25. package/dist/tests/body-DpC9p-iG.mjs +130 -0
  26. package/dist/tests/body-cKLBprYm.cjs +153 -0
  27. package/dist/tests/body.test.cjs +89 -0
  28. package/dist/tests/body.test.d.cts +1 -0
  29. package/dist/tests/body.test.d.mts +1 -0
  30. package/dist/tests/body.test.mjs +90 -0
  31. package/dist/tests/contexts-C9Lj11SC.cjs +4533 -0
  32. package/dist/tests/contexts-duRA_dDC.mjs +4522 -0
  33. package/dist/tests/decimal.test.cjs +9 -7
  34. package/dist/tests/decimal.test.mjs +9 -6
  35. package/dist/tests/digest-3FeH2Y-Q.cjs +176 -0
  36. package/dist/tests/digest-COC7xDiQ.mjs +141 -0
  37. package/dist/tests/digest.test.cjs +102 -0
  38. package/dist/tests/digest.test.d.cts +1 -0
  39. package/dist/tests/digest.test.d.mts +1 -0
  40. package/dist/tests/digest.test.mjs +103 -0
  41. package/dist/tests/docloader-DyZ1JGIl.cjs +398 -0
  42. package/dist/tests/docloader-tUmMeXjU.mjs +375 -0
  43. package/dist/tests/docloader.test.cjs +816 -39
  44. package/dist/tests/docloader.test.mjs +810 -34
  45. package/dist/tests/internal/multicodec.test.cjs +2 -3
  46. package/dist/tests/internal/multicodec.test.mjs +2 -2
  47. package/dist/tests/internal/portable-dereference.test.cjs +172 -0
  48. package/dist/tests/internal/portable-dereference.test.d.cts +1 -0
  49. package/dist/tests/internal/portable-dereference.test.d.mts +1 -0
  50. package/dist/tests/internal/portable-dereference.test.mjs +173 -0
  51. package/dist/tests/jsonld-CmDhHNTx.cjs +12 -0
  52. package/dist/tests/jsonld-DweIJ07b.mjs +5 -0
  53. package/dist/tests/jsonld-cache-BEXiiM-V.cjs +386 -0
  54. package/dist/tests/jsonld-cache-DFnWsLuZ.mjs +339 -0
  55. package/dist/tests/jsonld-cache.test.cjs +102 -298
  56. package/dist/tests/jsonld-cache.test.mjs +95 -289
  57. package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
  58. package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
  59. package/dist/tests/key.test.cjs +6 -5
  60. package/dist/tests/key.test.mjs +6 -4
  61. package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-DYRmJqFn.mjs} +5 -2
  62. package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-_PqsL1ae.cjs} +5 -2
  63. package/dist/tests/langstr.test.cjs +11 -4
  64. package/dist/tests/langstr.test.mjs +9 -2
  65. package/dist/tests/langtag-CUppHs_s.mjs +45 -0
  66. package/dist/tests/langtag-Czjvxfzx.cjs +50 -0
  67. package/dist/tests/langtag.test.cjs +45 -0
  68. package/dist/tests/langtag.test.d.cts +1 -0
  69. package/dist/tests/langtag.test.d.mts +1 -0
  70. package/dist/tests/langtag.test.mjs +46 -0
  71. package/dist/tests/link.test.cjs +2 -3
  72. package/dist/tests/link.test.mjs +2 -2
  73. package/dist/tests/multibase/multibase.test.cjs +8 -9
  74. package/dist/tests/multibase/multibase.test.mjs +6 -6
  75. package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
  76. package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
  77. package/dist/tests/portable-dereference-DmTYDy2p.mjs +250 -0
  78. package/dist/tests/portable-dereference-qTLMqe2_.cjs +279 -0
  79. package/dist/tests/portable-media-5p8azal0.cjs +153 -0
  80. package/dist/tests/portable-media-B_lt9MMA.mjs +148 -0
  81. package/dist/tests/portable-media.test.cjs +222 -0
  82. package/dist/tests/portable-media.test.d.cts +1 -0
  83. package/dist/tests/portable-media.test.d.mts +1 -0
  84. package/dist/tests/portable-media.test.mjs +223 -0
  85. package/dist/tests/portable-workers.test.cjs +36 -0
  86. package/dist/tests/portable-workers.test.d.cts +2 -0
  87. package/dist/tests/portable-workers.test.d.mts +2 -0
  88. package/dist/tests/portable-workers.test.mjs +35 -0
  89. package/dist/tests/{request-uk51rkhO.cjs → request-BgjA9wyl.cjs} +10 -4
  90. package/dist/tests/{request-C8CaGwtt.mjs → request-DodZNukP.mjs} +8 -2
  91. package/dist/tests/request.test.cjs +7 -4
  92. package/dist/tests/request.test.mjs +5 -2
  93. package/dist/tests/signed-representation.test.cjs +588 -0
  94. package/dist/tests/signed-representation.test.d.cts +1 -0
  95. package/dist/tests/signed-representation.test.d.mts +1 -0
  96. package/dist/tests/signed-representation.test.mjs +589 -0
  97. package/dist/tests/temporal.test.cjs +1 -2
  98. package/dist/tests/temporal.test.mjs +1 -1
  99. package/dist/tests/url-BNakuZ8k.cjs +998 -0
  100. package/dist/tests/url-DMxmp7ZG.mjs +859 -0
  101. package/dist/tests/url.test.cjs +489 -6
  102. package/dist/tests/url.test.mjs +489 -5
  103. package/dist/url-DrGTR8yv.cjs +993 -0
  104. package/dist/url-Dzyp-NsC.js +860 -0
  105. package/package.json +27 -4
  106. package/scripts/test-bun.mjs +17 -0
  107. package/src/body.test.ts +125 -0
  108. package/src/body.ts +152 -0
  109. package/src/contexts/cid-v1.json +114 -0
  110. package/src/contexts/fep-22cd.json +21 -0
  111. package/src/contexts/fep-6757.json +12 -0
  112. package/src/contexts/joinmastodon.json +1 -1
  113. package/src/contexts/miscellany.json +17 -0
  114. package/src/contexts.ts +47 -1
  115. package/src/digest.test.ts +220 -0
  116. package/src/digest.ts +229 -0
  117. package/src/docloader.test.ts +1066 -27
  118. package/src/docloader.ts +322 -105
  119. package/src/internal/jsonld-cache.ts +97 -1
  120. package/src/internal/portable-dereference.test.ts +254 -0
  121. package/src/internal/portable-dereference.ts +1000 -0
  122. package/src/internal/signed-representation.ts +565 -0
  123. package/src/jsonld-cache.test.ts +89 -0
  124. package/src/key.test.ts +9 -0
  125. package/src/key.ts +11 -1
  126. package/src/langstr.test.ts +18 -1
  127. package/src/langstr.ts +6 -2
  128. package/src/langtag.test.ts +53 -0
  129. package/src/langtag.ts +76 -0
  130. package/src/mod.ts +29 -0
  131. package/src/multibase/multibase.test.ts +5 -5
  132. package/src/portable-media.test.ts +293 -0
  133. package/src/portable-media.ts +264 -0
  134. package/src/portable-workers.test.ts +81 -0
  135. package/src/portable.ts +180 -0
  136. package/src/preprocessor.ts +7 -0
  137. package/src/request.test.ts +10 -1
  138. package/src/request.ts +9 -1
  139. package/src/signed-representation.test.ts +338 -0
  140. package/src/url.test.ts +823 -3
  141. package/src/url.ts +700 -23
  142. package/tsdown.config.ts +2 -0
  143. package/dist/tests/url-CsOV_B_P.cjs +0 -482
  144. package/dist/tests/url-Du7RQQgP.mjs +0 -392
  145. package/dist/url-DD4F0ULf.cjs +0 -483
  146. package/dist/url-DGVbSVVi.js +0 -393
  147. /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
  148. /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
  149. /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
  150. /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
  151. /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
  152. /package/dist/tests/{chunk-C2EiDwsr.cjs → rolldown-runtime-emK7D4bc.cjs} +0 -0
@@ -1,11 +1,98 @@
1
+ import { configure, type LogRecord, reset } from "@logtape/logtape";
1
2
  import fetchMock from "fetch-mock";
2
- import { deepStrictEqual, ok, rejects } from "node:assert";
3
- import { test } from "node:test";
3
+ import { deepStrictEqual, ok, rejects, throws } from "node:assert";
4
+ import dns from "node:dns/promises";
5
+ import { test } from "@fedify/fixture";
6
+ import {
7
+ createServer,
8
+ type IncomingMessage,
9
+ type ServerResponse,
10
+ } from "node:http";
11
+ import { gzipSync } from "node:zlib";
4
12
  import preloadedContexts from "./contexts.ts";
5
- import { getDocumentLoader, getRemoteDocument } from "./docloader.ts";
13
+ import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
14
+ import {
15
+ getDocumentLoader,
16
+ getRemoteDocument,
17
+ resolveDocumentLoaderTimeout,
18
+ withDocumentLoaderTimeout,
19
+ } from "./docloader.ts";
20
+ import jsonld from "./jsonld.ts";
6
21
  import { FetchError } from "./request.ts";
7
22
  import { UrlError } from "./url.ts";
8
23
 
24
+ test("getRemoteDocument() forwards options to alternate documents", async () => {
25
+ const records: LogRecord[] = [];
26
+ await reset();
27
+ await configure({
28
+ sinks: { capture: (record) => records.push(record) },
29
+ loggers: [{
30
+ category: ["fedify"],
31
+ lowestLevel: "debug",
32
+ sinks: ["capture"],
33
+ }],
34
+ });
35
+ try {
36
+ for (const linkHeader of [true, false]) {
37
+ for (const suppressError of [true, false, undefined]) {
38
+ records.length = 0;
39
+ const url = "https://example.com/alternate-source";
40
+ const alternateUrl = "https://example.com/missing-alternate";
41
+ const options = {
42
+ suppressError,
43
+ signal: new AbortController().signal,
44
+ };
45
+ const response = new Response(
46
+ `<link rel="alternate" type="application/activity+json" href="${alternateUrl}">`,
47
+ {
48
+ headers: {
49
+ "Content-Type": "text/html",
50
+ ...(linkHeader
51
+ ? {
52
+ Link:
53
+ `<${alternateUrl}>; rel="alternate"; type="application/activity+json"`,
54
+ }
55
+ : {}),
56
+ },
57
+ },
58
+ );
59
+ let alternateRequests = 0;
60
+ await rejects(
61
+ () =>
62
+ getRemoteDocument(
63
+ url,
64
+ response,
65
+ async (requestedUrl, forwarded) => {
66
+ alternateRequests++;
67
+ deepStrictEqual(requestedUrl, alternateUrl);
68
+ deepStrictEqual(forwarded, options);
69
+ return await getRemoteDocument(
70
+ requestedUrl,
71
+ new Response(null, { status: 404 }),
72
+ () => {
73
+ throw new Error("Unexpected additional alternate");
74
+ },
75
+ forwarded,
76
+ );
77
+ },
78
+ options,
79
+ ),
80
+ FetchError,
81
+ );
82
+ deepStrictEqual(alternateRequests, 1);
83
+ const failures = records.filter((record) =>
84
+ record.rawMessage ===
85
+ "Failed to fetch document: {status} {url} {headers}"
86
+ );
87
+ deepStrictEqual(failures.length, 1);
88
+ deepStrictEqual(failures[0].level, suppressError ? "warning" : "error");
89
+ }
90
+ }
91
+ } finally {
92
+ await reset();
93
+ }
94
+ });
95
+
9
96
  test("new FetchError()", () => {
10
97
  const e = new FetchError("https://example.com/", "An error message.");
11
98
  deepStrictEqual(e.name, "FetchError");
@@ -39,7 +126,7 @@ test("getDocumentLoader()", async (t) => {
39
126
  },
40
127
  });
41
128
 
42
- await t.test("ok", async () => {
129
+ await t.step("ok", async () => {
43
130
  deepStrictEqual(await fetchDocumentLoader("https://example.com/object"), {
44
131
  contextUrl: null,
45
132
  documentUrl: "https://example.com/object",
@@ -96,7 +183,7 @@ test("getDocumentLoader()", async (t) => {
96
183
  },
97
184
  });
98
185
 
99
- await t.test("Link header", async () => {
186
+ await t.step("Link header", async () => {
100
187
  deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
101
188
  contextUrl: "https://www.w3.org/ns/activitystreams",
102
189
  documentUrl: "https://example.com/link-ctx",
@@ -119,7 +206,7 @@ test("getDocumentLoader()", async (t) => {
119
206
  });
120
207
  });
121
208
 
122
- await t.test("Link header relative url", async () => {
209
+ await t.step("Link header relative url", async () => {
123
210
  deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
124
211
  contextUrl: "https://www.w3.org/ns/activitystreams",
125
212
  documentUrl: "https://example.com/link-ctx",
@@ -145,7 +232,7 @@ test("getDocumentLoader()", async (t) => {
145
232
  );
146
233
  });
147
234
 
148
- await t.test("wrong Link header syntax", async () => {
235
+ await t.step("wrong Link header syntax", async () => {
149
236
  deepStrictEqual(
150
237
  await fetchDocumentLoader("https://example.com/obj-w-wrong-link"),
151
238
  {
@@ -174,7 +261,7 @@ test("getDocumentLoader()", async (t) => {
174
261
  headers: { "Content-Type": "text/html; charset=utf-8" },
175
262
  });
176
263
 
177
- await t.test("HTML <link>", async () => {
264
+ await t.step("HTML <link>", async () => {
178
265
  deepStrictEqual(
179
266
  await fetchDocumentLoader("https://example.com/html-link"),
180
267
  {
@@ -203,7 +290,7 @@ test("getDocumentLoader()", async (t) => {
203
290
  headers: { "Content-Type": "application/xhtml+xml; charset=utf-8" },
204
291
  });
205
292
 
206
- await t.test("XHTML <link>", async () => {
293
+ await t.step("XHTML <link>", async () => {
207
294
  deepStrictEqual(
208
295
  await fetchDocumentLoader("https://example.com/xhtml-link"),
209
296
  {
@@ -234,7 +321,7 @@ test("getDocumentLoader()", async (t) => {
234
321
  headers: { "Content-Type": "text/html; charset=utf-8" },
235
322
  });
236
323
 
237
- await t.test("HTML <a>", async () => {
324
+ await t.step("HTML <a>", async () => {
238
325
  deepStrictEqual(await fetchDocumentLoader("https://example.com/html-a"), {
239
326
  contextUrl: null,
240
327
  documentUrl: "https://example.com/object",
@@ -258,7 +345,7 @@ test("getDocumentLoader()", async (t) => {
258
345
  headers: { "Content-Type": "text/html; charset=utf-8" },
259
346
  });
260
347
 
261
- await t.test("HTML without ActivityPub alternate link", async () => {
348
+ await t.step("HTML without ActivityPub alternate link", async () => {
262
349
  await rejects(
263
350
  () => fetchDocumentLoader("https://example.com/html-no-alternate"),
264
351
  (error) => {
@@ -295,7 +382,7 @@ test("getDocumentLoader()", async (t) => {
295
382
  headers: { "Content-Type": "text/html; charset=utf-8" },
296
383
  });
297
384
 
298
- await t.test("wrong Content-Type with JSON body", async () => {
385
+ await t.step("wrong Content-Type with JSON body", async () => {
299
386
  deepStrictEqual(
300
387
  await fetchDocumentLoader("https://example.com/wrong-content-type"),
301
388
  {
@@ -319,7 +406,7 @@ test("getDocumentLoader()", async (t) => {
319
406
  },
320
407
  });
321
408
 
322
- await t.test("HTML Content-Length over limit", async () => {
409
+ await t.step("HTML Content-Length over limit", async () => {
323
410
  await rejects(
324
411
  () => fetchDocumentLoader("https://example.com/large-html"),
325
412
  (error) => {
@@ -340,7 +427,7 @@ test("getDocumentLoader()", async (t) => {
340
427
  );
341
428
  });
342
429
 
343
- await t.test("HTML Content-Length over limit cancels body", async () => {
430
+ await t.step("HTML Content-Length over limit cancels body", async () => {
344
431
  let canceled = false;
345
432
  const response = new Response("<!DOCTYPE html>", {
346
433
  headers: {
@@ -348,12 +435,14 @@ test("getDocumentLoader()", async (t) => {
348
435
  "Content-Type": "text/html; charset=utf-8",
349
436
  },
350
437
  });
438
+ // The shared reader in body.ts cancels through a reader rather than the
439
+ // body directly, so stub a real stream instead of a bare `cancel`.
351
440
  Object.defineProperty(response, "body", {
352
- value: {
353
- cancel: () => {
441
+ value: new ReadableStream<Uint8Array>({
442
+ cancel() {
354
443
  canceled = true;
355
444
  },
356
- },
445
+ }),
357
446
  });
358
447
  await rejects(
359
448
  () =>
@@ -371,7 +460,7 @@ test("getDocumentLoader()", async (t) => {
371
460
 
372
461
  fetchMock.get("https://example.com/404", { status: 404 });
373
462
 
374
- await t.test("not ok", async () => {
463
+ await t.step("not ok", async () => {
375
464
  await rejects(
376
465
  () => fetchDocumentLoader("https://example.com/404"),
377
466
  FetchError,
@@ -379,7 +468,7 @@ test("getDocumentLoader()", async (t) => {
379
468
  );
380
469
  });
381
470
 
382
- await t.test("preloaded contexts", async () => {
471
+ await t.step("preloaded contexts", async () => {
383
472
  for (const [url, document] of Object.entries(preloadedContexts)) {
384
473
  deepStrictEqual(await fetchDocumentLoader(url), {
385
474
  contextUrl: null,
@@ -389,7 +478,231 @@ test("getDocumentLoader()", async (t) => {
389
478
  }
390
479
  });
391
480
 
392
- await t.test("deny non-HTTP/HTTPS", async () => {
481
+ await t.step("preloaded Mastodon attribution domains", async () => {
482
+ const context = [
483
+ "https://www.w3.org/ns/activitystreams",
484
+ "http://joinmastodon.org/ns",
485
+ ];
486
+ const expanded = await jsonld.expand({
487
+ "@context": context,
488
+ id: "https://social.example/users/alice",
489
+ type: "Person",
490
+ attributionDomains: ["blog.example"],
491
+ }, { documentLoader: fetchDocumentLoader });
492
+ deepStrictEqual(expanded, [{
493
+ "@id": "https://social.example/users/alice",
494
+ "@type": ["https://www.w3.org/ns/activitystreams#Person"],
495
+ "http://joinmastodon.org/ns#attributionDomains": [
496
+ { "@value": "blog.example" },
497
+ ],
498
+ }]);
499
+ const compacted = await jsonld.compact(expanded, context, {
500
+ documentLoader: fetchDocumentLoader,
501
+ });
502
+ deepStrictEqual(compacted.attributionDomains, ["blog.example"]);
503
+ });
504
+
505
+ // Controlled Identifiers v1.0 requires JSON-LD processors to treat this
506
+ // context URL as already resolved. A temporary W3C outage must not prevent
507
+ // an otherwise valid document from being processed.
508
+ // See: https://www.w3.org/TR/cid-1.0/#json-ld-context
509
+ // https://github.com/fedify-dev/fedify/issues/932
510
+ fetchMock.get("https://www.w3.org/ns/cid/v1", { status: 503 });
511
+ await t.step("preloaded CID v1 context", async () => {
512
+ const url = "https://www.w3.org/ns/cid/v1";
513
+ deepStrictEqual(await fetchDocumentLoader(url), {
514
+ contextUrl: null,
515
+ documentUrl: url,
516
+ document: cidV1Context,
517
+ });
518
+ });
519
+
520
+ // The <https://w3id.org/fep/ef61> URL redirects to a Codeberg Pages host
521
+ // which suffers recurring outages; while it is unreachable, expanding any
522
+ // document referencing it fails before application handlers run. It has to
523
+ // be resolved from the built-in copy rather than over the network.
524
+ // See: https://github.com/fedify-dev/fedify/issues/982
525
+ await t.step("preloaded FEP-ef61 context", async () => {
526
+ const url = "https://w3id.org/fep/ef61";
527
+ ok(url in preloadedContexts);
528
+ deepStrictEqual(await fetchDocumentLoader(url), {
529
+ contextUrl: null,
530
+ documentUrl: url,
531
+ document: {
532
+ "@context": {
533
+ gateways: {
534
+ "@id": "https://w3id.org/fep/ef61/gateways",
535
+ "@type": "@id",
536
+ "@container": "@list",
537
+ },
538
+ digestMultibase:
539
+ "https://www.w3.org/ns/credentials/v2#digestMultibase",
540
+ },
541
+ },
542
+ });
543
+ });
544
+
545
+ // A Codeberg Pages outage must not prevent loading the FEP-7aa9 context.
546
+ // See: https://github.com/fedify-dev/fedify/issues/1078
547
+ fetchMock.get("https://w3id.org/fep/7aa9", { status: 502 });
548
+ await t.step("preloaded FEP-7aa9 context", async () => {
549
+ const url = "https://w3id.org/fep/7aa9";
550
+ deepStrictEqual(await fetchDocumentLoader(url), {
551
+ contextUrl: null,
552
+ documentUrl: url,
553
+ document: {
554
+ "@context": {
555
+ "FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
556
+ "FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
557
+ "FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
558
+ "FeatureAuthorization":
559
+ "https://w3id.org/fep/7aa9#FeatureAuthorization",
560
+ "topic": {
561
+ "@id": "https://w3id.org/fep/7aa9#topic",
562
+ "@type": "@id",
563
+ },
564
+ "featuredObject": {
565
+ "@id": "https://w3id.org/fep/7aa9#featuredObject",
566
+ "@type": "@id",
567
+ },
568
+ "canFeature": {
569
+ "@id": "https://w3id.org/fep/7aa9#canFeature",
570
+ "@type": "@id",
571
+ },
572
+ "featureAuthorization": {
573
+ "@id": "https://w3id.org/fep/7aa9#featureAuthorization",
574
+ "@type": "@id",
575
+ },
576
+ },
577
+ },
578
+ });
579
+ deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
580
+ });
581
+
582
+ // A Codeberg Pages outage must not prevent loading or expanding FEP-6757.
583
+ // See: https://github.com/fedify-dev/fedify/issues/1211
584
+ fetchMock.get("https://w3id.org/fep/6757", { status: 502 });
585
+ await t.step("preloaded FEP-6757 context", async () => {
586
+ const url = "https://w3id.org/fep/6757";
587
+ deepStrictEqual(await fetchDocumentLoader(url), {
588
+ contextUrl: null,
589
+ documentUrl: url,
590
+ document: {
591
+ "@context": {
592
+ license: {
593
+ "@id": "http://purl.org/dc/terms/license",
594
+ "@type": "@id",
595
+ },
596
+ preferredLicense: {
597
+ "@id": "https://w3id.org/fep/6757#preferredLicense",
598
+ "@type": "@id",
599
+ },
600
+ },
601
+ },
602
+ });
603
+ deepStrictEqual(
604
+ await jsonld.expand({
605
+ "@context": ["https://www.w3.org/ns/activitystreams", url],
606
+ type: "Person",
607
+ license: "https://creativecommons.org/licenses/by/4.0/",
608
+ preferredLicense: "https://creativecommons.org/licenses/by-nc/4.0/",
609
+ }, { documentLoader: fetchDocumentLoader }),
610
+ [{
611
+ "@type": ["https://www.w3.org/ns/activitystreams#Person"],
612
+ "http://purl.org/dc/terms/license": [{
613
+ "@id": "https://creativecommons.org/licenses/by/4.0/",
614
+ }],
615
+ "https://w3id.org/fep/6757#preferredLicense": [{
616
+ "@id": "https://creativecommons.org/licenses/by-nc/4.0/",
617
+ }],
618
+ }],
619
+ );
620
+ deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
621
+ });
622
+
623
+ // A Codeberg Pages outage must not prevent expanding translation metadata.
624
+ // See: https://github.com/fedify-dev/fedify/issues/1214
625
+ fetchMock.get("https://w3id.org/fep/22cd", { status: 502 });
626
+ await t.step("preloaded FEP-22cd context", async () => {
627
+ const url = "https://w3id.org/fep/22cd";
628
+ const articleId = "https://example.com/articles/1";
629
+ const translatorId = "https://example.com/users/alice";
630
+ const revisionId = "https://example.com/articles/1/revisions/1";
631
+ deepStrictEqual(await fetchDocumentLoader(url), {
632
+ contextUrl: null,
633
+ documentUrl: url,
634
+ document: {
635
+ "@context": {
636
+ "fep-22cd": "https://w3id.org/fep/22cd#",
637
+ schema: "https://schema.org/",
638
+ xsd: "http://www.w3.org/2001/XMLSchema#",
639
+ translations: {
640
+ "@id": "fep-22cd:translations",
641
+ "@container": "@set",
642
+ },
643
+ Translation: "fep-22cd:Translation",
644
+ sourceUpdated: {
645
+ "@id": "fep-22cd:sourceUpdated",
646
+ "@type": "xsd:dateTime",
647
+ },
648
+ translator: {
649
+ "@id": "schema:translator",
650
+ "@type": "@id",
651
+ "@container": "@set",
652
+ },
653
+ inLanguage: "schema:inLanguage",
654
+ translationOfWork: {
655
+ "@id": "schema:translationOfWork",
656
+ "@type": "@id",
657
+ },
658
+ isBasedOn: { "@id": "schema:isBasedOn", "@type": "@id" },
659
+ },
660
+ },
661
+ });
662
+ deepStrictEqual(
663
+ await jsonld.expand({
664
+ "@context": ["https://www.w3.org/ns/activitystreams", url],
665
+ id: articleId,
666
+ type: "Article",
667
+ contentMap: { en: "Original", ko: "Translation" },
668
+ translations: [{
669
+ type: "Translation",
670
+ inLanguage: "ko",
671
+ translator: [translatorId],
672
+ translationOfWork: articleId,
673
+ sourceUpdated: "2026-09-01T00:00:00Z",
674
+ isBasedOn: revisionId,
675
+ }],
676
+ }, { documentLoader: fetchDocumentLoader }),
677
+ [{
678
+ "@id": articleId,
679
+ "@type": ["https://www.w3.org/ns/activitystreams#Article"],
680
+ "https://www.w3.org/ns/activitystreams#content": [
681
+ { "@language": "en", "@value": "Original" },
682
+ { "@language": "ko", "@value": "Translation" },
683
+ ],
684
+ "https://w3id.org/fep/22cd#translations": [{
685
+ "@type": ["https://w3id.org/fep/22cd#Translation"],
686
+ "https://schema.org/inLanguage": [{ "@value": "ko" }],
687
+ "https://schema.org/translator": [{ "@id": translatorId }],
688
+ "https://schema.org/translationOfWork": [{ "@id": articleId }],
689
+ "https://w3id.org/fep/22cd#sourceUpdated": [{
690
+ "@type": "http://www.w3.org/2001/XMLSchema#dateTime",
691
+ "@value": "2026-09-01T00:00:00Z",
692
+ }],
693
+ "https://schema.org/isBasedOn": [{ "@id": revisionId }],
694
+ }],
695
+ }],
696
+ );
697
+ deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
698
+ deepStrictEqual(
699
+ fetchMock.callHistory.calls("https://www.w3.org/ns/activitystreams")
700
+ .length,
701
+ 0,
702
+ );
703
+ });
704
+
705
+ await t.step("deny non-HTTP/HTTPS", async () => {
393
706
  await rejects(
394
707
  () => fetchDocumentLoader("ftp://localhost"),
395
708
  UrlError,
@@ -423,7 +736,7 @@ test("getDocumentLoader()", async (t) => {
423
736
  },
424
737
  });
425
738
 
426
- await t.test("allowPrivateAddress: false", async () => {
739
+ await t.step("allowPrivateAddress: false", async () => {
427
740
  await rejects(
428
741
  () => fetchDocumentLoader("https://localhost/object"),
429
742
  UrlError,
@@ -440,7 +753,7 @@ test("getDocumentLoader()", async (t) => {
440
753
 
441
754
  const fetchDocumentLoader2 = getDocumentLoader({ allowPrivateAddress: true });
442
755
 
443
- await t.test("allowPrivateAddress: true", async () => {
756
+ await t.step("allowPrivateAddress: true", async () => {
444
757
  const expected = {
445
758
  contextUrl: null,
446
759
  documentUrl: "https://localhost/object",
@@ -477,7 +790,7 @@ test("getDocumentLoader()", async (t) => {
477
790
  };
478
791
  });
479
792
 
480
- await t.test("too many redirects", async () => {
793
+ await t.step("too many redirects", async () => {
481
794
  redirectAttempts = 0;
482
795
  await rejects(
483
796
  () => fetchDocumentLoader("https://example.com/too-many-redirects/0"),
@@ -487,7 +800,7 @@ test("getDocumentLoader()", async (t) => {
487
800
  deepStrictEqual(redirectAttempts, 21);
488
801
  });
489
802
 
490
- await t.test("custom max redirection", async () => {
803
+ await t.step("custom max redirection", async () => {
491
804
  redirectAttempts = 0;
492
805
  const loader = getDocumentLoader({ maxRedirection: 1 });
493
806
  await rejects(
@@ -514,7 +827,7 @@ test("getDocumentLoader()", async (t) => {
514
827
  };
515
828
  });
516
829
 
517
- await t.test("redirect loop", async () => {
830
+ await t.step("redirect loop", async () => {
518
831
  loopAttempts = 0;
519
832
  await rejects(
520
833
  () => fetchDocumentLoader("https://example.com/redirect-loop-a"),
@@ -533,7 +846,7 @@ test("getDocumentLoader()", async (t) => {
533
846
  };
534
847
  });
535
848
 
536
- await t.test("redirect loop with relative location", async () => {
849
+ await t.step("redirect loop with relative location", async () => {
537
850
  relativeLoopAttempts = 0;
538
851
  await rejects(
539
852
  () => fetchDocumentLoader("https://example.com/redirect-loop-relative"),
@@ -553,7 +866,7 @@ test("getDocumentLoader()", async (t) => {
553
866
  headers: { "Content-Type": "text/html; charset=utf-8" },
554
867
  });
555
868
 
556
- await t.test("ReDoS resistance (CVE-2025-68475)", async () => {
869
+ await t.step("ReDoS resistance (CVE-2025-68475)", async () => {
557
870
  const start = performance.now();
558
871
  // The malicious HTML will fail alternate discovery, but the important
559
872
  // thing is that it should complete quickly (not hang due to ReDoS).
@@ -573,3 +886,729 @@ test("getDocumentLoader()", async (t) => {
573
886
 
574
887
  fetchMock.hardReset();
575
888
  });
889
+
890
+ test("getDocumentLoader() bounds JSON, HTML, and alternate documents", async () => {
891
+ const url = "https://example.com/bounded";
892
+ fetchMock.mockGlobal();
893
+ let oversized = true;
894
+ try {
895
+ fetchMock.get(url, () =>
896
+ new Response('{"name":"hello"}', {
897
+ headers: {
898
+ "Content-Type": "application/activity+json",
899
+ ...(oversized
900
+ ? { "Content-Length": String(16 * 1024 * 1024 + 1) }
901
+ : {}),
902
+ },
903
+ }));
904
+ const loader = getDocumentLoader({
905
+ allowPrivateAddress: true,
906
+ });
907
+ await rejects(loader(url), FetchError);
908
+ oversized = false;
909
+ deepStrictEqual((await loader(url)).document, { name: "hello" });
910
+ oversized = true;
911
+ fetchMock.get(
912
+ `${url}/html`,
913
+ () =>
914
+ new Response(" ".repeat(1024 * 1024 + 1), {
915
+ headers: { "Content-Type": "text/html" },
916
+ }),
917
+ );
918
+ await rejects(
919
+ getDocumentLoader({ allowPrivateAddress: true })(`${url}/html`),
920
+ FetchError,
921
+ );
922
+ fetchMock.get(`${url}/alternate`, () =>
923
+ new Response("", {
924
+ headers: {
925
+ "Content-Type": "text/html",
926
+ Link: `<${url}>; rel="alternate"; type="application/activity+json"`,
927
+ },
928
+ }));
929
+ await rejects(loader(`${url}/alternate`), FetchError);
930
+ fetchMock.get(`${url}/redirect`, {
931
+ status: 302,
932
+ headers: { Location: url },
933
+ });
934
+ await rejects(loader(`${url}/redirect`), FetchError);
935
+ } finally {
936
+ fetchMock.hardReset();
937
+ }
938
+ });
939
+
940
+ test("getDocumentLoader() rejects oversized gzip responses from fetch", async () => {
941
+ const document = { name: "a".repeat(16 * 1024 * 1024) };
942
+ const compressed = gzipSync(JSON.stringify(document));
943
+ ok(compressed.byteLength < 64 * 1024);
944
+ const server = createServer((request, response) => {
945
+ const body = request.url === "/small"
946
+ ? gzipSync('{"name":"hello"}')
947
+ : compressed;
948
+ response.writeHead(200, {
949
+ "Content-Type": "application/activity+json",
950
+ "Content-Encoding": "gzip",
951
+ "Content-Length": body.byteLength,
952
+ });
953
+ response.end(body);
954
+ });
955
+ await new Promise<void>((resolve, reject) => {
956
+ server.once("error", reject);
957
+ server.listen(0, "127.0.0.1", resolve);
958
+ });
959
+ try {
960
+ const address = server.address();
961
+ ok(address != null && typeof address !== "string");
962
+ const url = `http://127.0.0.1:${address.port}/document`;
963
+ await rejects(
964
+ getDocumentLoader({
965
+ allowPrivateAddress: true,
966
+ })(url),
967
+ FetchError,
968
+ );
969
+ deepStrictEqual(
970
+ (await getDocumentLoader({
971
+ allowPrivateAddress: true,
972
+ })(new URL("/small", url).href)).document,
973
+ { name: "hello" },
974
+ );
975
+ } finally {
976
+ await new Promise<void>((resolve, reject) => {
977
+ server.close((error) => error == null ? resolve() : reject(error));
978
+ server.closeAllConnections();
979
+ });
980
+ }
981
+ });
982
+
983
+ test("getRemoteDocument() bounds JSON by default", async () => {
984
+ let canceled = false;
985
+ let pulls = 0;
986
+ const response = new Response(
987
+ new ReadableStream({
988
+ pull(controller) {
989
+ pulls++;
990
+ controller.enqueue(new Uint8Array(64 * 1024).fill(32));
991
+ },
992
+ cancel() {
993
+ canceled = true;
994
+ },
995
+ }, { highWaterMark: 0 }),
996
+ );
997
+ await rejects(
998
+ getRemoteDocument(
999
+ "https://example.com/document",
1000
+ response,
1001
+ () => {
1002
+ throw new Error("Unexpected alternate document");
1003
+ },
1004
+ ),
1005
+ FetchError,
1006
+ );
1007
+ deepStrictEqual(canceled, true);
1008
+ deepStrictEqual(pulls, 257);
1009
+ });
1010
+
1011
+ 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) {
1024
+ 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 = (() =>
1029
+ result === "throws"
1030
+ ? Promise.reject(new Error("Resolver unavailable"))
1031
+ : Promise.resolve(
1032
+ result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
1033
+ )) as typeof dns.lookup;
1034
+ const records: LogRecord[] = [];
1035
+ await configure({
1036
+ sinks: {
1037
+ buffer: (record) =>
1038
+ records.push(record),
1039
+ },
1040
+ loggers: [
1041
+ { category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
1042
+ { category: ["logtape", "meta"], sinks: [] },
1043
+ ],
1044
+ reset: true,
1045
+ });
1046
+ try {
1047
+ const url = "https://dns-failure.invalid/object";
1048
+ let error: unknown;
1049
+ await rejects(() => loader(url), (e) => {
1050
+ error = e;
1051
+ return true;
1052
+ });
1053
+ ok(error instanceof UrlError);
1054
+ deepStrictEqual(
1055
+ error.reason,
1056
+ result === "private" ? "disallowed" : "dns",
1057
+ );
1058
+ deepStrictEqual(
1059
+ records.map((r) => [r.level, r.rawMessage, r.properties.url]),
1060
+ [
1061
+ result === "private"
1062
+ ? ["error", "Disallowed private URL: {url}", url]
1063
+ : ["debug", "DNS lookup failed for {url}", url],
1064
+ ],
1065
+ );
1066
+ ok(records[0].properties.error === error);
1067
+ } finally {
1068
+ await reset();
1069
+ dns.lookup = originalLookup;
1070
+ }
1071
+ });
1072
+ }
1073
+ });
1074
+
1075
+ test("getDocumentLoader() bounds alternate document chains", async (t) => {
1076
+ const base = "https://example.com/alternate-chain/";
1077
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
1078
+ for (const mode of ["header", "html", "mixed"] as const) {
1079
+ for (const hops of [20, 21]) {
1080
+ await t.step(`${mode}: ${hops} hops`, async () => {
1081
+ fetchMock.mockGlobal();
1082
+ let requests = 0;
1083
+ fetchMock.get(`begin:${base}`, ({ url }) => {
1084
+ requests++;
1085
+ const index = Number(new URL(url).pathname.split("/").at(-1));
1086
+ // Keep the unpatched regression bounded too.
1087
+ if (index === hops || requests > 30) {
1088
+ return Response.json({ done: true });
1089
+ }
1090
+ const next = `${base}${index + 1}`;
1091
+ if (mode === "mixed" && index % 2 === 1) {
1092
+ return Response.redirect(next, 302);
1093
+ }
1094
+ return alternate(next, mode === "html");
1095
+ });
1096
+ try {
1097
+ if (hops === 20) {
1098
+ deepStrictEqual((await loader(`${base}0`)).document, {
1099
+ done: true,
1100
+ });
1101
+ } else {
1102
+ await rejects(loader(`${base}0`), (error: unknown) => {
1103
+ ok(error instanceof FetchError);
1104
+ ok(error.message.includes("Too many redirections (21)"));
1105
+ return true;
1106
+ });
1107
+ }
1108
+ deepStrictEqual(requests, 21);
1109
+ } finally {
1110
+ fetchMock.hardReset();
1111
+ }
1112
+ });
1113
+ }
1114
+ }
1115
+
1116
+ for (
1117
+ const mode of [
1118
+ "header",
1119
+ "html",
1120
+ "alternate-redirect",
1121
+ "redirect-alternate",
1122
+ "redirect-intermediate",
1123
+ ]
1124
+ ) {
1125
+ await t.step(`${mode}: cycle`, async () => {
1126
+ fetchMock.mockGlobal();
1127
+ let requests = 0;
1128
+ fetchMock.get(`begin:${base}`, ({ url }) => {
1129
+ requests++;
1130
+ if (requests > 30) return Response.json({ stopped: true });
1131
+ const index = Number(new URL(url).pathname.split("/").at(-1));
1132
+ const next = `${base}${
1133
+ mode === "redirect-intermediate"
1134
+ ? (index === 0 ? 1 : index === 1 ? 2 : 1)
1135
+ : 1 - index
1136
+ }`;
1137
+ if (
1138
+ (mode === "alternate-redirect" && index === 1) ||
1139
+ ((mode === "redirect-alternate" ||
1140
+ mode === "redirect-intermediate") && index === 0)
1141
+ ) {
1142
+ return Response.redirect(next, 302);
1143
+ }
1144
+ return alternate(next, mode === "html");
1145
+ });
1146
+ try {
1147
+ await rejects(loader(`${base}0`), (error: unknown) => {
1148
+ ok(error instanceof FetchError);
1149
+ ok(error.message.includes("Redirect loop detected:"));
1150
+ return true;
1151
+ });
1152
+ deepStrictEqual(requests, mode === "redirect-intermediate" ? 3 : 2);
1153
+ } finally {
1154
+ fetchMock.hardReset();
1155
+ }
1156
+ });
1157
+ }
1158
+
1159
+ await t.step(
1160
+ "alternate followed by 20 redirects shares the limit",
1161
+ async () => {
1162
+ fetchMock.mockGlobal();
1163
+ let requests = 0;
1164
+ fetchMock.get(`begin:${base}`, ({ url }) => {
1165
+ requests++;
1166
+ const index = Number(new URL(url).pathname.split("/").at(-1));
1167
+ if (index === 21) return Response.json({ done: true });
1168
+ return index === 0
1169
+ ? alternate(`${base}1`, false)
1170
+ : Response.redirect(`${base}${index + 1}`, 302);
1171
+ });
1172
+ try {
1173
+ await rejects(loader(`${base}0`), (error: unknown) => {
1174
+ ok(error instanceof FetchError);
1175
+ ok(error.message.includes("Too many redirections (21)"));
1176
+ return true;
1177
+ });
1178
+ deepStrictEqual(requests, 21);
1179
+ } finally {
1180
+ fetchMock.hardReset();
1181
+ }
1182
+ },
1183
+ );
1184
+
1185
+ await t.step(
1186
+ "relative alternate after redirect and isolated calls",
1187
+ async () => {
1188
+ fetchMock.mockGlobal();
1189
+ fetchMock.get(`${base}start`, Response.redirect(`${base}html`, 302));
1190
+ fetchMock.get(`${base}html`, alternate("./document", true));
1191
+ fetchMock.get(`${base}document`, Response.json({ done: true }));
1192
+ try {
1193
+ for (let i = 0; i < 2; i++) {
1194
+ const results = await Promise.all([
1195
+ loader(`${base}start`),
1196
+ loader(`${base}start`),
1197
+ ]);
1198
+ for (const result of results) {
1199
+ deepStrictEqual(result.document, { done: true });
1200
+ deepStrictEqual(result.documentUrl, `${base}document`);
1201
+ }
1202
+ }
1203
+ } finally {
1204
+ fetchMock.hardReset();
1205
+ }
1206
+ },
1207
+ );
1208
+
1209
+ function alternate(next: string, html: boolean): Response {
1210
+ return html
1211
+ ? new Response(
1212
+ `<link rel="alternate" type="application/activity+json" href="${next}">`,
1213
+ {
1214
+ headers: { "Content-Type": "text/html" },
1215
+ },
1216
+ )
1217
+ : new Response("not JSON", {
1218
+ headers: {
1219
+ "Content-Type": "text/plain",
1220
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`,
1221
+ },
1222
+ });
1223
+ }
1224
+ });
1225
+
1226
+ test("getDocumentLoader() preserves cancellation across alternates", async (t) => {
1227
+ const base = "https://example.com/alternate-abort/";
1228
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
1229
+ for (const html of [false, true]) {
1230
+ await t.step(html ? "HTML" : "Link header", async () => {
1231
+ fetchMock.mockGlobal();
1232
+ const controller = new AbortController();
1233
+ let requests = 0;
1234
+ fetchMock.get(`begin:${base}`, ({ url }) => {
1235
+ requests++;
1236
+ const index = Number(new URL(url).pathname.split("/").at(-1));
1237
+ if (index === 2) return Response.json({ done: true });
1238
+ if (index === 1) controller.abort();
1239
+ const next = `${base}${index + 1}`;
1240
+ return new Response(
1241
+ html
1242
+ ? `<link rel="alternate" type="application/activity+json" href="${next}">`
1243
+ : "not JSON",
1244
+ {
1245
+ headers: html ? { "Content-Type": "text/html" } : {
1246
+ "Content-Type": "text/plain",
1247
+ Link:
1248
+ `<${next}>; rel="alternate"; type="application/activity+json"`,
1249
+ },
1250
+ },
1251
+ );
1252
+ });
1253
+ try {
1254
+ await rejects(loader(`${base}0`, { signal: controller.signal }), {
1255
+ name: "AbortError",
1256
+ });
1257
+ deepStrictEqual(requests, 2);
1258
+ } finally {
1259
+ fetchMock.hardReset();
1260
+ }
1261
+ });
1262
+ }
1263
+ });
1264
+
1265
+ test("getDocumentLoader() rejects cancellation before fetching", async () => {
1266
+ fetchMock.mockGlobal();
1267
+ let requests = 0;
1268
+ const url = "https://example.com/pre-aborted-alternate";
1269
+ fetchMock.get(url, () => {
1270
+ requests++;
1271
+ return Response.json({ done: true });
1272
+ });
1273
+ try {
1274
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
1275
+ const controller = new AbortController();
1276
+ controller.abort();
1277
+ await rejects(loader(url, { signal: controller.signal }), {
1278
+ name: "AbortError",
1279
+ });
1280
+ deepStrictEqual(requests, 0);
1281
+ } finally {
1282
+ fetchMock.hardReset();
1283
+ }
1284
+ });
1285
+
1286
+ async function withServer<T>(
1287
+ handler: (request: IncomingMessage, response: ServerResponse) => void,
1288
+ callback: (baseUrl: string) => Promise<T>,
1289
+ ): Promise<T> {
1290
+ const server = createServer(handler);
1291
+ await new Promise<void>((resolve, reject) => {
1292
+ server.once("error", reject);
1293
+ server.listen(0, "127.0.0.1", resolve);
1294
+ });
1295
+ try {
1296
+ const address = server.address();
1297
+ ok(address != null && typeof address !== "string");
1298
+ return await callback(`http://127.0.0.1:${address.port}`);
1299
+ } finally {
1300
+ await new Promise<void>((resolve, reject) => {
1301
+ server.close((error) => error == null ? resolve() : reject(error));
1302
+ server.closeAllConnections();
1303
+ });
1304
+ }
1305
+ }
1306
+
1307
+ function isTimeoutFetchError(error: unknown, url: string): boolean {
1308
+ ok(error instanceof FetchError, String(error));
1309
+ deepStrictEqual(error.url.href, url);
1310
+ deepStrictEqual(error.response, undefined);
1311
+ ok(error.cause instanceof DOMException);
1312
+ deepStrictEqual(error.cause.name, "TimeoutError");
1313
+ return true;
1314
+ }
1315
+
1316
+ test("resolveDocumentLoaderTimeout() validates timeouts", () => {
1317
+ deepStrictEqual(resolveDocumentLoaderTimeout(undefined), 10_000);
1318
+ deepStrictEqual(resolveDocumentLoaderTimeout(null), null);
1319
+ deepStrictEqual(resolveDocumentLoaderTimeout(1500), 1500);
1320
+ deepStrictEqual(resolveDocumentLoaderTimeout(0.5), 1);
1321
+ deepStrictEqual(resolveDocumentLoaderTimeout(2_147_483_647), 2_147_483_647);
1322
+ for (const invalid of [0, -1, NaN, Infinity, 2_147_483_648]) {
1323
+ throws(() => resolveDocumentLoaderTimeout(invalid), RangeError);
1324
+ }
1325
+ throws(() => getDocumentLoader({ timeout: 0 }), RangeError);
1326
+ });
1327
+
1328
+ test("getDocumentLoader() times out a request without a response", async () => {
1329
+ await withServer(() => {
1330
+ // Never respond.
1331
+ }, async (baseUrl) => {
1332
+ const url = `${baseUrl}/hang`;
1333
+ const loader = getDocumentLoader({
1334
+ allowPrivateAddress: true,
1335
+ timeout: 100,
1336
+ });
1337
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1338
+ });
1339
+ });
1340
+
1341
+ test("getDocumentLoader() times out a stalled response body", async () => {
1342
+ for (const status of [200, 404]) {
1343
+ await withServer((_, response) => {
1344
+ response.writeHead(status, {
1345
+ "Content-Type": "application/activity+json",
1346
+ });
1347
+ response.write('{"id":');
1348
+ // Never finish the body.
1349
+ }, async (baseUrl) => {
1350
+ const url = `${baseUrl}/stalled`;
1351
+ const loader = getDocumentLoader({
1352
+ allowPrivateAddress: true,
1353
+ timeout: 200,
1354
+ });
1355
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1356
+ });
1357
+ }
1358
+ });
1359
+
1360
+ test("getDocumentLoader() shares the timeout across redirects", async () => {
1361
+ let requests = 0;
1362
+ await withServer((request, response) => {
1363
+ requests++;
1364
+ const index = Number(request.url?.split("/").at(-1));
1365
+ setTimeout(() => {
1366
+ if (index >= 15) {
1367
+ response.writeHead(200, {
1368
+ "Content-Type": "application/activity+json",
1369
+ });
1370
+ response.end('{"done":true}');
1371
+ return;
1372
+ }
1373
+ response.writeHead(302, { Location: `/redirect/${index + 1}` });
1374
+ response.end();
1375
+ }, 50);
1376
+ }, async (baseUrl) => {
1377
+ const url = `${baseUrl}/redirect/0`;
1378
+ // Each hop is well within the timeout, but the whole chain is not:
1379
+ const loader = getDocumentLoader({
1380
+ allowPrivateAddress: true,
1381
+ timeout: 300,
1382
+ });
1383
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1384
+ ok(requests < 16, `requests: ${requests}`);
1385
+ // Without a timeout, the chain completes:
1386
+ const unbounded = getDocumentLoader({
1387
+ allowPrivateAddress: true,
1388
+ timeout: null,
1389
+ });
1390
+ deepStrictEqual((await unbounded(url)).document, { done: true });
1391
+ });
1392
+ });
1393
+
1394
+ test("getDocumentLoader() shares the timeout across alternates", async (t) => {
1395
+ for (const html of [false, true]) {
1396
+ await t.step(html ? "HTML" : "Link header", async () => {
1397
+ await withServer((request, response) => {
1398
+ const index = Number(request.url?.split("/").at(-1));
1399
+ setTimeout(() => {
1400
+ if (index >= 15) {
1401
+ response.writeHead(200, {
1402
+ "Content-Type": "application/activity+json",
1403
+ });
1404
+ response.end('{"done":true}');
1405
+ return;
1406
+ }
1407
+ const next = `/alternate/${index + 1}`;
1408
+ response.writeHead(
1409
+ 200,
1410
+ html ? { "Content-Type": "text/html" } : {
1411
+ "Content-Type": "text/plain",
1412
+ Link:
1413
+ `<${next}>; rel="alternate"; type="application/activity+json"`,
1414
+ },
1415
+ );
1416
+ response.end(
1417
+ html
1418
+ ? `<link rel="alternate" type="application/activity+json" href="${next}">`
1419
+ : "not JSON",
1420
+ );
1421
+ }, 50);
1422
+ }, async (baseUrl) => {
1423
+ const url = `${baseUrl}/alternate/0`;
1424
+ const loader = getDocumentLoader({
1425
+ allowPrivateAddress: true,
1426
+ timeout: 300,
1427
+ });
1428
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1429
+ });
1430
+ });
1431
+ }
1432
+ });
1433
+
1434
+ test("getDocumentLoader() lets the caller's signal abort a request", async () => {
1435
+ await withServer(() => {
1436
+ // Never respond.
1437
+ }, async (baseUrl) => {
1438
+ const url = `${baseUrl}/hang`;
1439
+ for (const timeout of [undefined, 60_000, null]) {
1440
+ const loader = getDocumentLoader({ allowPrivateAddress: true, timeout });
1441
+ const controller = new AbortController();
1442
+ const reason = new Error("Canceled by the caller");
1443
+ setTimeout(() => controller.abort(reason), 50);
1444
+ await rejects(loader(url, { signal: controller.signal }), (e) => {
1445
+ deepStrictEqual(e, reason);
1446
+ return true;
1447
+ });
1448
+ }
1449
+ // The caller's signal wins even if the timeout fires too:
1450
+ const loader = getDocumentLoader({
1451
+ allowPrivateAddress: true,
1452
+ timeout: 50,
1453
+ });
1454
+ const controller = new AbortController();
1455
+ const reason = new Error("Canceled by the caller");
1456
+ controller.abort(reason);
1457
+ await rejects(loader(url, { signal: controller.signal }), (e) => {
1458
+ deepStrictEqual(e, reason);
1459
+ return true;
1460
+ });
1461
+ });
1462
+ });
1463
+
1464
+ 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
+ fetchMock.mockGlobal();
1475
+ let requests = 0;
1476
+ const url = "https://slow-dns.example/object";
1477
+ fetchMock.get(url, () => {
1478
+ requests++;
1479
+ return Response.json({ id: url });
1480
+ });
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
+ let resolveLookup: () => void = () => {};
1485
+ const lookedUp = new Promise<void>((resolve) => resolveLookup = resolve);
1486
+ dns.lookup = (() =>
1487
+ new Promise((resolve) =>
1488
+ setTimeout(() => {
1489
+ resolve([{ address: "93.184.215.14", family: 4 }]);
1490
+ resolveLookup();
1491
+ }, 200)
1492
+ )) as typeof dns.lookup;
1493
+ try {
1494
+ const loader = getDocumentLoader({ timeout: 50 });
1495
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1496
+ // A late DNS answer must not start the request anymore:
1497
+ await lookedUp;
1498
+ await new Promise((resolve) => setTimeout(resolve, 10));
1499
+ deepStrictEqual(requests, 0);
1500
+ } finally {
1501
+ dns.lookup = originalLookup;
1502
+ fetchMock.hardReset();
1503
+ }
1504
+ });
1505
+
1506
+ test("withDocumentLoaderTimeout() cleans up after each call", async () => {
1507
+ const signals: AbortSignal[] = [];
1508
+ const loader = withDocumentLoaderTimeout((url, options) => {
1509
+ signals.push(options!.signal!);
1510
+ return Promise.resolve({
1511
+ contextUrl: null,
1512
+ document: {},
1513
+ documentUrl: url,
1514
+ });
1515
+ }, 50);
1516
+ const results = await Promise.all([
1517
+ loader("https://example.com/a"),
1518
+ loader("https://example.com/b"),
1519
+ ]);
1520
+ deepStrictEqual(results.map((r) => r.documentUrl), [
1521
+ "https://example.com/a",
1522
+ "https://example.com/b",
1523
+ ]);
1524
+ ok(signals[0] !== signals[1]);
1525
+ // The timers are cleared, so the signals are never aborted:
1526
+ await new Promise((resolve) => setTimeout(resolve, 100));
1527
+ ok(signals.every((signal) => !signal.aborted));
1528
+ // A loader that is given no timeout is returned as is:
1529
+ const inner = () => Promise.reject(new Error("unused"));
1530
+ ok(withDocumentLoaderTimeout(inner, null) === inner);
1531
+ });
1532
+
1533
+ test("getRemoteDocument() keeps error bodies byte for byte", async () => {
1534
+ const bytes = new Uint8Array([0xef, 0xbb, 0xbf, 0x4e, 0x6f, 0xff, 0xfe]);
1535
+ const url = "https://example.com/error";
1536
+ let error: unknown;
1537
+ await rejects(
1538
+ getRemoteDocument(
1539
+ url,
1540
+ new Response(bytes, { status: 404 }),
1541
+ () => Promise.reject(new Error("unused")),
1542
+ ),
1543
+ (e) => {
1544
+ error = e;
1545
+ return true;
1546
+ },
1547
+ );
1548
+ ok(error instanceof FetchError);
1549
+ ok(error.response != null);
1550
+ deepStrictEqual(error.response.status, 404);
1551
+ deepStrictEqual(new Uint8Array(await error.response.arrayBuffer()), bytes);
1552
+ });
1553
+
1554
+ test("getRemoteDocument() keeps metadata of bodiless or large errors", async () => {
1555
+ const url = "https://example.com/error";
1556
+ for (
1557
+ const response of [
1558
+ new Response(null, { status: 304, headers: { ETag: '"a"' } }),
1559
+ new Response(new Uint8Array(1024 * 1024 + 1), {
1560
+ status: 500,
1561
+ headers: { ETag: '"a"' },
1562
+ }),
1563
+ ]
1564
+ ) {
1565
+ await rejects(
1566
+ getRemoteDocument(
1567
+ url,
1568
+ response,
1569
+ () => Promise.reject(new Error("unused")),
1570
+ ),
1571
+ (e) => {
1572
+ ok(e instanceof FetchError);
1573
+ ok(e.response != null);
1574
+ deepStrictEqual(e.response.status, response.status);
1575
+ deepStrictEqual(e.response.headers.get("ETag"), '"a"');
1576
+ deepStrictEqual(e.response.body, null);
1577
+ return true;
1578
+ },
1579
+ );
1580
+ }
1581
+ });
1582
+
1583
+ test("getDocumentLoader() reports statuses that Response cannot hold", async () => {
1584
+ await withServer((_, response) => {
1585
+ response.writeHead(999);
1586
+ response.end("Request denied");
1587
+ }, async (baseUrl) => {
1588
+ const url = `${baseUrl}/denied`;
1589
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
1590
+ let error: unknown;
1591
+ await rejects(loader(url), (e) => {
1592
+ error = e;
1593
+ return true;
1594
+ });
1595
+ ok(error instanceof FetchError, String(error));
1596
+ deepStrictEqual(error.response?.status, 999);
1597
+ deepStrictEqual(await error.response.text(), "Request denied");
1598
+ });
1599
+ });
1600
+
1601
+ test("getDocumentLoader() times out a stalled body of such a status", async () => {
1602
+ await withServer((_, response) => {
1603
+ response.writeHead(999);
1604
+ response.write("Request");
1605
+ // Never finish the body.
1606
+ }, async (baseUrl) => {
1607
+ const url = `${baseUrl}/stalled-denied`;
1608
+ const loader = getDocumentLoader({
1609
+ allowPrivateAddress: true,
1610
+ timeout: 200,
1611
+ });
1612
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1613
+ });
1614
+ });