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

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 (138) hide show
  1. package/deno.json +4 -2
  2. package/dist/{tests/docloader-Ck8bcBir.mjs → contexts-BK1CqBR5.cjs} +164 -275
  3. package/dist/{tests/docloader-S_gQZ2tt.cjs → contexts-DPuJ4UYL.js} +160 -294
  4. package/dist/{docloader-C_dir7Xb.d.ts → docloader-CYwqh5Df.d.cts} +66 -2
  5. package/dist/{docloader-D2DTRiyA.d.cts → docloader-CYwqh5Df.d.ts} +66 -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 +711 -4527
  20. package/dist/mod.d.cts +400 -8
  21. package/dist/mod.d.ts +400 -9
  22. package/dist/mod.js +692 -4522
  23. package/dist/portable-DqtfLy_1.d.cts +160 -0
  24. package/dist/portable-DtWsu2yU.d.ts +160 -0
  25. package/dist/tests/body-CYiu9CO-.mjs +130 -0
  26. package/dist/tests/body-CkEwROsn.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-CIKsin4e.mjs +4512 -0
  32. package/dist/tests/contexts-DizzBjz4.cjs +4523 -0
  33. package/dist/tests/decimal.test.cjs +8 -7
  34. package/dist/tests/decimal.test.mjs +8 -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-C3YFl6y4.cjs +396 -0
  42. package/dist/tests/docloader-CvmS4sVq.mjs +373 -0
  43. package/dist/tests/docloader.test.cjs +692 -39
  44. package/dist/tests/docloader.test.mjs +686 -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-cache-BPQmOZWD.mjs +342 -0
  52. package/dist/tests/jsonld-cache-C07AyNOY.cjs +397 -0
  53. package/dist/tests/jsonld-cache.test.cjs +101 -298
  54. package/dist/tests/jsonld-cache.test.mjs +94 -289
  55. package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
  56. package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
  57. package/dist/tests/key.test.cjs +6 -5
  58. package/dist/tests/key.test.mjs +6 -4
  59. package/dist/tests/langstr.test.cjs +4 -4
  60. package/dist/tests/langstr.test.mjs +2 -2
  61. package/dist/tests/link.test.cjs +2 -3
  62. package/dist/tests/link.test.mjs +2 -2
  63. package/dist/tests/multibase/multibase.test.cjs +8 -9
  64. package/dist/tests/multibase/multibase.test.mjs +6 -6
  65. package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
  66. package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
  67. package/dist/tests/portable-dereference-BNXtgg5U.cjs +278 -0
  68. package/dist/tests/portable-dereference-DRE5bz-l.mjs +249 -0
  69. package/dist/tests/portable-media-BLKllU56.cjs +153 -0
  70. package/dist/tests/portable-media-DRSGBORB.mjs +148 -0
  71. package/dist/tests/portable-media.test.cjs +222 -0
  72. package/dist/tests/portable-media.test.d.cts +1 -0
  73. package/dist/tests/portable-media.test.d.mts +1 -0
  74. package/dist/tests/portable-media.test.mjs +223 -0
  75. package/dist/tests/portable-workers.test.cjs +36 -0
  76. package/dist/tests/portable-workers.test.d.cts +2 -0
  77. package/dist/tests/portable-workers.test.d.mts +2 -0
  78. package/dist/tests/portable-workers.test.mjs +35 -0
  79. package/dist/tests/{request-uk51rkhO.cjs → request-8vPWtV1-.cjs} +10 -4
  80. package/dist/tests/{request-C8CaGwtt.mjs → request-DRaOaTqD.mjs} +8 -2
  81. package/dist/tests/request.test.cjs +7 -4
  82. package/dist/tests/request.test.mjs +5 -2
  83. package/dist/tests/signed-representation.test.cjs +588 -0
  84. package/dist/tests/signed-representation.test.d.cts +1 -0
  85. package/dist/tests/signed-representation.test.d.mts +1 -0
  86. package/dist/tests/signed-representation.test.mjs +589 -0
  87. package/dist/tests/temporal.test.cjs +1 -2
  88. package/dist/tests/temporal.test.mjs +1 -1
  89. package/dist/tests/url-BNakuZ8k.cjs +998 -0
  90. package/dist/tests/url-DMxmp7ZG.mjs +859 -0
  91. package/dist/tests/url.test.cjs +489 -6
  92. package/dist/tests/url.test.mjs +489 -5
  93. package/dist/url-DrGTR8yv.cjs +993 -0
  94. package/dist/url-Dzyp-NsC.js +860 -0
  95. package/package.json +27 -4
  96. package/scripts/test-bun.mjs +17 -0
  97. package/src/body.test.ts +125 -0
  98. package/src/body.ts +152 -0
  99. package/src/contexts/cid-v1.json +114 -0
  100. package/src/contexts/fep-22cd.json +21 -0
  101. package/src/contexts/miscellany.json +17 -0
  102. package/src/contexts.ts +39 -1
  103. package/src/digest.test.ts +220 -0
  104. package/src/digest.ts +229 -0
  105. package/src/docloader.test.ts +918 -27
  106. package/src/docloader.ts +320 -105
  107. package/src/internal/jsonld-cache.ts +97 -1
  108. package/src/internal/portable-dereference.test.ts +254 -0
  109. package/src/internal/portable-dereference.ts +1000 -0
  110. package/src/internal/signed-representation.ts +565 -0
  111. package/src/jsonld-cache.test.ts +89 -0
  112. package/src/key.test.ts +9 -0
  113. package/src/key.ts +11 -1
  114. package/src/mod.ts +28 -0
  115. package/src/multibase/multibase.test.ts +5 -5
  116. package/src/portable-media.test.ts +293 -0
  117. package/src/portable-media.ts +264 -0
  118. package/src/portable-workers.test.ts +81 -0
  119. package/src/portable.ts +180 -0
  120. package/src/preprocessor.ts +7 -0
  121. package/src/request.test.ts +10 -1
  122. package/src/request.ts +9 -1
  123. package/src/signed-representation.test.ts +338 -0
  124. package/src/url.test.ts +823 -3
  125. package/src/url.ts +700 -23
  126. package/tsdown.config.ts +2 -0
  127. package/dist/tests/url-CsOV_B_P.cjs +0 -482
  128. package/dist/tests/url-Du7RQQgP.mjs +0 -392
  129. package/dist/url-DD4F0ULf.cjs +0 -483
  130. package/dist/url-DGVbSVVi.js +0 -393
  131. /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
  132. /package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-C4Fl80ae.cjs} +0 -0
  133. /package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-CQ26J_L7.mjs} +0 -0
  134. /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
  135. /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
  136. /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
  137. /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
  138. /package/dist/tests/{chunk-C2EiDwsr.cjs → rolldown-runtime-emK7D4bc.cjs} +0 -0
@@ -1,11 +1,97 @@
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";
6
20
  import { FetchError } from "./request.ts";
7
21
  import { UrlError } from "./url.ts";
8
22
 
23
+ test("getRemoteDocument() forwards options to alternate documents", async () => {
24
+ const records: LogRecord[] = [];
25
+ await reset();
26
+ await configure({
27
+ sinks: { capture: (record) => records.push(record) },
28
+ loggers: [{
29
+ category: ["fedify"],
30
+ lowestLevel: "debug",
31
+ sinks: ["capture"],
32
+ }],
33
+ });
34
+ try {
35
+ for (const linkHeader of [true, false]) {
36
+ for (const suppressError of [true, false, undefined]) {
37
+ records.length = 0;
38
+ const url = "https://example.com/alternate-source";
39
+ const alternateUrl = "https://example.com/missing-alternate";
40
+ const options = {
41
+ suppressError,
42
+ signal: new AbortController().signal,
43
+ };
44
+ const response = new Response(
45
+ `<link rel="alternate" type="application/activity+json" href="${alternateUrl}">`,
46
+ {
47
+ headers: {
48
+ "Content-Type": "text/html",
49
+ ...(linkHeader
50
+ ? {
51
+ Link:
52
+ `<${alternateUrl}>; rel="alternate"; type="application/activity+json"`,
53
+ }
54
+ : {}),
55
+ },
56
+ },
57
+ );
58
+ let alternateRequests = 0;
59
+ await rejects(
60
+ () =>
61
+ getRemoteDocument(
62
+ url,
63
+ response,
64
+ async (requestedUrl, forwarded) => {
65
+ alternateRequests++;
66
+ deepStrictEqual(requestedUrl, alternateUrl);
67
+ deepStrictEqual(forwarded, options);
68
+ return await getRemoteDocument(
69
+ requestedUrl,
70
+ new Response(null, { status: 404 }),
71
+ () => {
72
+ throw new Error("Unexpected additional alternate");
73
+ },
74
+ forwarded,
75
+ );
76
+ },
77
+ options,
78
+ ),
79
+ FetchError,
80
+ );
81
+ deepStrictEqual(alternateRequests, 1);
82
+ const failures = records.filter((record) =>
83
+ record.rawMessage ===
84
+ "Failed to fetch document: {status} {url} {headers}"
85
+ );
86
+ deepStrictEqual(failures.length, 1);
87
+ deepStrictEqual(failures[0].level, suppressError ? "warning" : "error");
88
+ }
89
+ }
90
+ } finally {
91
+ await reset();
92
+ }
93
+ });
94
+
9
95
  test("new FetchError()", () => {
10
96
  const e = new FetchError("https://example.com/", "An error message.");
11
97
  deepStrictEqual(e.name, "FetchError");
@@ -39,7 +125,7 @@ test("getDocumentLoader()", async (t) => {
39
125
  },
40
126
  });
41
127
 
42
- await t.test("ok", async () => {
128
+ await t.step("ok", async () => {
43
129
  deepStrictEqual(await fetchDocumentLoader("https://example.com/object"), {
44
130
  contextUrl: null,
45
131
  documentUrl: "https://example.com/object",
@@ -96,7 +182,7 @@ test("getDocumentLoader()", async (t) => {
96
182
  },
97
183
  });
98
184
 
99
- await t.test("Link header", async () => {
185
+ await t.step("Link header", async () => {
100
186
  deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
101
187
  contextUrl: "https://www.w3.org/ns/activitystreams",
102
188
  documentUrl: "https://example.com/link-ctx",
@@ -119,7 +205,7 @@ test("getDocumentLoader()", async (t) => {
119
205
  });
120
206
  });
121
207
 
122
- await t.test("Link header relative url", async () => {
208
+ await t.step("Link header relative url", async () => {
123
209
  deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
124
210
  contextUrl: "https://www.w3.org/ns/activitystreams",
125
211
  documentUrl: "https://example.com/link-ctx",
@@ -145,7 +231,7 @@ test("getDocumentLoader()", async (t) => {
145
231
  );
146
232
  });
147
233
 
148
- await t.test("wrong Link header syntax", async () => {
234
+ await t.step("wrong Link header syntax", async () => {
149
235
  deepStrictEqual(
150
236
  await fetchDocumentLoader("https://example.com/obj-w-wrong-link"),
151
237
  {
@@ -174,7 +260,7 @@ test("getDocumentLoader()", async (t) => {
174
260
  headers: { "Content-Type": "text/html; charset=utf-8" },
175
261
  });
176
262
 
177
- await t.test("HTML <link>", async () => {
263
+ await t.step("HTML <link>", async () => {
178
264
  deepStrictEqual(
179
265
  await fetchDocumentLoader("https://example.com/html-link"),
180
266
  {
@@ -203,7 +289,7 @@ test("getDocumentLoader()", async (t) => {
203
289
  headers: { "Content-Type": "application/xhtml+xml; charset=utf-8" },
204
290
  });
205
291
 
206
- await t.test("XHTML <link>", async () => {
292
+ await t.step("XHTML <link>", async () => {
207
293
  deepStrictEqual(
208
294
  await fetchDocumentLoader("https://example.com/xhtml-link"),
209
295
  {
@@ -234,7 +320,7 @@ test("getDocumentLoader()", async (t) => {
234
320
  headers: { "Content-Type": "text/html; charset=utf-8" },
235
321
  });
236
322
 
237
- await t.test("HTML <a>", async () => {
323
+ await t.step("HTML <a>", async () => {
238
324
  deepStrictEqual(await fetchDocumentLoader("https://example.com/html-a"), {
239
325
  contextUrl: null,
240
326
  documentUrl: "https://example.com/object",
@@ -258,7 +344,7 @@ test("getDocumentLoader()", async (t) => {
258
344
  headers: { "Content-Type": "text/html; charset=utf-8" },
259
345
  });
260
346
 
261
- await t.test("HTML without ActivityPub alternate link", async () => {
347
+ await t.step("HTML without ActivityPub alternate link", async () => {
262
348
  await rejects(
263
349
  () => fetchDocumentLoader("https://example.com/html-no-alternate"),
264
350
  (error) => {
@@ -295,7 +381,7 @@ test("getDocumentLoader()", async (t) => {
295
381
  headers: { "Content-Type": "text/html; charset=utf-8" },
296
382
  });
297
383
 
298
- await t.test("wrong Content-Type with JSON body", async () => {
384
+ await t.step("wrong Content-Type with JSON body", async () => {
299
385
  deepStrictEqual(
300
386
  await fetchDocumentLoader("https://example.com/wrong-content-type"),
301
387
  {
@@ -319,7 +405,7 @@ test("getDocumentLoader()", async (t) => {
319
405
  },
320
406
  });
321
407
 
322
- await t.test("HTML Content-Length over limit", async () => {
408
+ await t.step("HTML Content-Length over limit", async () => {
323
409
  await rejects(
324
410
  () => fetchDocumentLoader("https://example.com/large-html"),
325
411
  (error) => {
@@ -340,7 +426,7 @@ test("getDocumentLoader()", async (t) => {
340
426
  );
341
427
  });
342
428
 
343
- await t.test("HTML Content-Length over limit cancels body", async () => {
429
+ await t.step("HTML Content-Length over limit cancels body", async () => {
344
430
  let canceled = false;
345
431
  const response = new Response("<!DOCTYPE html>", {
346
432
  headers: {
@@ -348,12 +434,14 @@ test("getDocumentLoader()", async (t) => {
348
434
  "Content-Type": "text/html; charset=utf-8",
349
435
  },
350
436
  });
437
+ // The shared reader in body.ts cancels through a reader rather than the
438
+ // body directly, so stub a real stream instead of a bare `cancel`.
351
439
  Object.defineProperty(response, "body", {
352
- value: {
353
- cancel: () => {
440
+ value: new ReadableStream<Uint8Array>({
441
+ cancel() {
354
442
  canceled = true;
355
443
  },
356
- },
444
+ }),
357
445
  });
358
446
  await rejects(
359
447
  () =>
@@ -371,7 +459,7 @@ test("getDocumentLoader()", async (t) => {
371
459
 
372
460
  fetchMock.get("https://example.com/404", { status: 404 });
373
461
 
374
- await t.test("not ok", async () => {
462
+ await t.step("not ok", async () => {
375
463
  await rejects(
376
464
  () => fetchDocumentLoader("https://example.com/404"),
377
465
  FetchError,
@@ -379,7 +467,7 @@ test("getDocumentLoader()", async (t) => {
379
467
  );
380
468
  });
381
469
 
382
- await t.test("preloaded contexts", async () => {
470
+ await t.step("preloaded contexts", async () => {
383
471
  for (const [url, document] of Object.entries(preloadedContexts)) {
384
472
  deepStrictEqual(await fetchDocumentLoader(url), {
385
473
  contextUrl: null,
@@ -389,7 +477,84 @@ test("getDocumentLoader()", async (t) => {
389
477
  }
390
478
  });
391
479
 
392
- await t.test("deny non-HTTP/HTTPS", async () => {
480
+ // Controlled Identifiers v1.0 requires JSON-LD processors to treat this
481
+ // context URL as already resolved. A temporary W3C outage must not prevent
482
+ // an otherwise valid document from being processed.
483
+ // See: https://www.w3.org/TR/cid-1.0/#json-ld-context
484
+ // https://github.com/fedify-dev/fedify/issues/932
485
+ fetchMock.get("https://www.w3.org/ns/cid/v1", { status: 503 });
486
+ await t.step("preloaded CID v1 context", async () => {
487
+ const url = "https://www.w3.org/ns/cid/v1";
488
+ deepStrictEqual(await fetchDocumentLoader(url), {
489
+ contextUrl: null,
490
+ documentUrl: url,
491
+ document: cidV1Context,
492
+ });
493
+ });
494
+
495
+ // The <https://w3id.org/fep/ef61> URL redirects to a Codeberg Pages host
496
+ // which suffers recurring outages; while it is unreachable, expanding any
497
+ // document referencing it fails before application handlers run. It has to
498
+ // be resolved from the built-in copy rather than over the network.
499
+ // See: https://github.com/fedify-dev/fedify/issues/982
500
+ await t.step("preloaded FEP-ef61 context", async () => {
501
+ const url = "https://w3id.org/fep/ef61";
502
+ ok(url in preloadedContexts);
503
+ deepStrictEqual(await fetchDocumentLoader(url), {
504
+ contextUrl: null,
505
+ documentUrl: url,
506
+ document: {
507
+ "@context": {
508
+ gateways: {
509
+ "@id": "https://w3id.org/fep/ef61/gateways",
510
+ "@type": "@id",
511
+ "@container": "@list",
512
+ },
513
+ digestMultibase:
514
+ "https://www.w3.org/ns/credentials/v2#digestMultibase",
515
+ },
516
+ },
517
+ });
518
+ });
519
+
520
+ // A Codeberg Pages outage must not prevent loading the FEP-7aa9 context.
521
+ // See: https://github.com/fedify-dev/fedify/issues/1078
522
+ fetchMock.get("https://w3id.org/fep/7aa9", { status: 502 });
523
+ await t.step("preloaded FEP-7aa9 context", async () => {
524
+ const url = "https://w3id.org/fep/7aa9";
525
+ deepStrictEqual(await fetchDocumentLoader(url), {
526
+ contextUrl: null,
527
+ documentUrl: url,
528
+ document: {
529
+ "@context": {
530
+ "FeaturedCollection": "https://w3id.org/fep/7aa9#FeaturedCollection",
531
+ "FeaturedItem": "https://w3id.org/fep/7aa9#FeaturedItem",
532
+ "FeatureRequest": "https://w3id.org/fep/7aa9#FeatureRequest",
533
+ "FeatureAuthorization":
534
+ "https://w3id.org/fep/7aa9#FeatureAuthorization",
535
+ "topic": {
536
+ "@id": "https://w3id.org/fep/7aa9#topic",
537
+ "@type": "@id",
538
+ },
539
+ "featuredObject": {
540
+ "@id": "https://w3id.org/fep/7aa9#featuredObject",
541
+ "@type": "@id",
542
+ },
543
+ "canFeature": {
544
+ "@id": "https://w3id.org/fep/7aa9#canFeature",
545
+ "@type": "@id",
546
+ },
547
+ "featureAuthorization": {
548
+ "@id": "https://w3id.org/fep/7aa9#featureAuthorization",
549
+ "@type": "@id",
550
+ },
551
+ },
552
+ },
553
+ });
554
+ deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
555
+ });
556
+
557
+ await t.step("deny non-HTTP/HTTPS", async () => {
393
558
  await rejects(
394
559
  () => fetchDocumentLoader("ftp://localhost"),
395
560
  UrlError,
@@ -423,7 +588,7 @@ test("getDocumentLoader()", async (t) => {
423
588
  },
424
589
  });
425
590
 
426
- await t.test("allowPrivateAddress: false", async () => {
591
+ await t.step("allowPrivateAddress: false", async () => {
427
592
  await rejects(
428
593
  () => fetchDocumentLoader("https://localhost/object"),
429
594
  UrlError,
@@ -440,7 +605,7 @@ test("getDocumentLoader()", async (t) => {
440
605
 
441
606
  const fetchDocumentLoader2 = getDocumentLoader({ allowPrivateAddress: true });
442
607
 
443
- await t.test("allowPrivateAddress: true", async () => {
608
+ await t.step("allowPrivateAddress: true", async () => {
444
609
  const expected = {
445
610
  contextUrl: null,
446
611
  documentUrl: "https://localhost/object",
@@ -477,7 +642,7 @@ test("getDocumentLoader()", async (t) => {
477
642
  };
478
643
  });
479
644
 
480
- await t.test("too many redirects", async () => {
645
+ await t.step("too many redirects", async () => {
481
646
  redirectAttempts = 0;
482
647
  await rejects(
483
648
  () => fetchDocumentLoader("https://example.com/too-many-redirects/0"),
@@ -487,7 +652,7 @@ test("getDocumentLoader()", async (t) => {
487
652
  deepStrictEqual(redirectAttempts, 21);
488
653
  });
489
654
 
490
- await t.test("custom max redirection", async () => {
655
+ await t.step("custom max redirection", async () => {
491
656
  redirectAttempts = 0;
492
657
  const loader = getDocumentLoader({ maxRedirection: 1 });
493
658
  await rejects(
@@ -514,7 +679,7 @@ test("getDocumentLoader()", async (t) => {
514
679
  };
515
680
  });
516
681
 
517
- await t.test("redirect loop", async () => {
682
+ await t.step("redirect loop", async () => {
518
683
  loopAttempts = 0;
519
684
  await rejects(
520
685
  () => fetchDocumentLoader("https://example.com/redirect-loop-a"),
@@ -533,7 +698,7 @@ test("getDocumentLoader()", async (t) => {
533
698
  };
534
699
  });
535
700
 
536
- await t.test("redirect loop with relative location", async () => {
701
+ await t.step("redirect loop with relative location", async () => {
537
702
  relativeLoopAttempts = 0;
538
703
  await rejects(
539
704
  () => fetchDocumentLoader("https://example.com/redirect-loop-relative"),
@@ -553,7 +718,7 @@ test("getDocumentLoader()", async (t) => {
553
718
  headers: { "Content-Type": "text/html; charset=utf-8" },
554
719
  });
555
720
 
556
- await t.test("ReDoS resistance (CVE-2025-68475)", async () => {
721
+ await t.step("ReDoS resistance (CVE-2025-68475)", async () => {
557
722
  const start = performance.now();
558
723
  // The malicious HTML will fail alternate discovery, but the important
559
724
  // thing is that it should complete quickly (not hang due to ReDoS).
@@ -573,3 +738,729 @@ test("getDocumentLoader()", async (t) => {
573
738
 
574
739
  fetchMock.hardReset();
575
740
  });
741
+
742
+ test("getDocumentLoader() bounds JSON, HTML, and alternate documents", async () => {
743
+ const url = "https://example.com/bounded";
744
+ fetchMock.mockGlobal();
745
+ let oversized = true;
746
+ try {
747
+ fetchMock.get(url, () =>
748
+ new Response('{"name":"hello"}', {
749
+ headers: {
750
+ "Content-Type": "application/activity+json",
751
+ ...(oversized
752
+ ? { "Content-Length": String(16 * 1024 * 1024 + 1) }
753
+ : {}),
754
+ },
755
+ }));
756
+ const loader = getDocumentLoader({
757
+ allowPrivateAddress: true,
758
+ });
759
+ await rejects(loader(url), FetchError);
760
+ oversized = false;
761
+ deepStrictEqual((await loader(url)).document, { name: "hello" });
762
+ oversized = true;
763
+ fetchMock.get(
764
+ `${url}/html`,
765
+ () =>
766
+ new Response(" ".repeat(1024 * 1024 + 1), {
767
+ headers: { "Content-Type": "text/html" },
768
+ }),
769
+ );
770
+ await rejects(
771
+ getDocumentLoader({ allowPrivateAddress: true })(`${url}/html`),
772
+ FetchError,
773
+ );
774
+ fetchMock.get(`${url}/alternate`, () =>
775
+ new Response("", {
776
+ headers: {
777
+ "Content-Type": "text/html",
778
+ Link: `<${url}>; rel="alternate"; type="application/activity+json"`,
779
+ },
780
+ }));
781
+ await rejects(loader(`${url}/alternate`), FetchError);
782
+ fetchMock.get(`${url}/redirect`, {
783
+ status: 302,
784
+ headers: { Location: url },
785
+ });
786
+ await rejects(loader(`${url}/redirect`), FetchError);
787
+ } finally {
788
+ fetchMock.hardReset();
789
+ }
790
+ });
791
+
792
+ test("getDocumentLoader() rejects oversized gzip responses from fetch", async () => {
793
+ const document = { name: "a".repeat(16 * 1024 * 1024) };
794
+ const compressed = gzipSync(JSON.stringify(document));
795
+ ok(compressed.byteLength < 64 * 1024);
796
+ const server = createServer((request, response) => {
797
+ const body = request.url === "/small"
798
+ ? gzipSync('{"name":"hello"}')
799
+ : compressed;
800
+ response.writeHead(200, {
801
+ "Content-Type": "application/activity+json",
802
+ "Content-Encoding": "gzip",
803
+ "Content-Length": body.byteLength,
804
+ });
805
+ response.end(body);
806
+ });
807
+ await new Promise<void>((resolve, reject) => {
808
+ server.once("error", reject);
809
+ server.listen(0, "127.0.0.1", resolve);
810
+ });
811
+ try {
812
+ const address = server.address();
813
+ ok(address != null && typeof address !== "string");
814
+ const url = `http://127.0.0.1:${address.port}/document`;
815
+ await rejects(
816
+ getDocumentLoader({
817
+ allowPrivateAddress: true,
818
+ })(url),
819
+ FetchError,
820
+ );
821
+ deepStrictEqual(
822
+ (await getDocumentLoader({
823
+ allowPrivateAddress: true,
824
+ })(new URL("/small", url).href)).document,
825
+ { name: "hello" },
826
+ );
827
+ } finally {
828
+ await new Promise<void>((resolve, reject) => {
829
+ server.close((error) => error == null ? resolve() : reject(error));
830
+ server.closeAllConnections();
831
+ });
832
+ }
833
+ });
834
+
835
+ test("getRemoteDocument() bounds JSON by default", async () => {
836
+ let canceled = false;
837
+ let pulls = 0;
838
+ const response = new Response(
839
+ new ReadableStream({
840
+ pull(controller) {
841
+ pulls++;
842
+ controller.enqueue(new Uint8Array(64 * 1024).fill(32));
843
+ },
844
+ cancel() {
845
+ canceled = true;
846
+ },
847
+ }, { highWaterMark: 0 }),
848
+ );
849
+ await rejects(
850
+ getRemoteDocument(
851
+ "https://example.com/document",
852
+ response,
853
+ () => {
854
+ throw new Error("Unexpected alternate document");
855
+ },
856
+ ),
857
+ FetchError,
858
+ );
859
+ deepStrictEqual(canceled, true);
860
+ deepStrictEqual(pulls, 257);
861
+ });
862
+
863
+ test("getDocumentLoader() logs DNS failures as such", async (t) => {
864
+ // The validator skips DNS when Deno has no network permission. Checked
865
+ // here rather than with top-level await, which the CommonJS build rejects.
866
+ if (
867
+ "Deno" in globalThis &&
868
+ (await Deno.permissions.query({ name: "net" })).state !== "granted"
869
+ ) {
870
+ throw new Error(
871
+ "This DNS test requires Deno network permission (--allow-net).",
872
+ );
873
+ }
874
+ const loader = getDocumentLoader();
875
+ for (const result of ["throws", "empty", "private"] as const) {
876
+ await t.step(result, async () => {
877
+ // Stubbing works only because url.ts uses the default node:dns/promises
878
+ // import; see the FIXME there.
879
+ const originalLookup = dns.lookup;
880
+ dns.lookup = (() =>
881
+ result === "throws"
882
+ ? Promise.reject(new Error("Resolver unavailable"))
883
+ : Promise.resolve(
884
+ result === "empty" ? [] : [{ address: "127.0.0.1", family: 4 }],
885
+ )) as typeof dns.lookup;
886
+ const records: LogRecord[] = [];
887
+ await configure({
888
+ sinks: {
889
+ buffer: (record) =>
890
+ records.push(record),
891
+ },
892
+ loggers: [
893
+ { category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
894
+ { category: ["logtape", "meta"], sinks: [] },
895
+ ],
896
+ reset: true,
897
+ });
898
+ try {
899
+ const url = "https://dns-failure.invalid/object";
900
+ let error: unknown;
901
+ await rejects(() => loader(url), (e) => {
902
+ error = e;
903
+ return true;
904
+ });
905
+ ok(error instanceof UrlError);
906
+ deepStrictEqual(
907
+ error.reason,
908
+ result === "private" ? "disallowed" : "dns",
909
+ );
910
+ deepStrictEqual(
911
+ records.map((r) => [r.level, r.rawMessage, r.properties.url]),
912
+ [
913
+ result === "private"
914
+ ? ["error", "Disallowed private URL: {url}", url]
915
+ : ["debug", "DNS lookup failed for {url}", url],
916
+ ],
917
+ );
918
+ ok(records[0].properties.error === error);
919
+ } finally {
920
+ await reset();
921
+ dns.lookup = originalLookup;
922
+ }
923
+ });
924
+ }
925
+ });
926
+
927
+ test("getDocumentLoader() bounds alternate document chains", async (t) => {
928
+ const base = "https://example.com/alternate-chain/";
929
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
930
+ for (const mode of ["header", "html", "mixed"] as const) {
931
+ for (const hops of [20, 21]) {
932
+ await t.step(`${mode}: ${hops} hops`, async () => {
933
+ fetchMock.mockGlobal();
934
+ let requests = 0;
935
+ fetchMock.get(`begin:${base}`, ({ url }) => {
936
+ requests++;
937
+ const index = Number(new URL(url).pathname.split("/").at(-1));
938
+ // Keep the unpatched regression bounded too.
939
+ if (index === hops || requests > 30) {
940
+ return Response.json({ done: true });
941
+ }
942
+ const next = `${base}${index + 1}`;
943
+ if (mode === "mixed" && index % 2 === 1) {
944
+ return Response.redirect(next, 302);
945
+ }
946
+ return alternate(next, mode === "html");
947
+ });
948
+ try {
949
+ if (hops === 20) {
950
+ deepStrictEqual((await loader(`${base}0`)).document, {
951
+ done: true,
952
+ });
953
+ } else {
954
+ await rejects(loader(`${base}0`), (error: unknown) => {
955
+ ok(error instanceof FetchError);
956
+ ok(error.message.includes("Too many redirections (21)"));
957
+ return true;
958
+ });
959
+ }
960
+ deepStrictEqual(requests, 21);
961
+ } finally {
962
+ fetchMock.hardReset();
963
+ }
964
+ });
965
+ }
966
+ }
967
+
968
+ for (
969
+ const mode of [
970
+ "header",
971
+ "html",
972
+ "alternate-redirect",
973
+ "redirect-alternate",
974
+ "redirect-intermediate",
975
+ ]
976
+ ) {
977
+ await t.step(`${mode}: cycle`, async () => {
978
+ fetchMock.mockGlobal();
979
+ let requests = 0;
980
+ fetchMock.get(`begin:${base}`, ({ url }) => {
981
+ requests++;
982
+ if (requests > 30) return Response.json({ stopped: true });
983
+ const index = Number(new URL(url).pathname.split("/").at(-1));
984
+ const next = `${base}${
985
+ mode === "redirect-intermediate"
986
+ ? (index === 0 ? 1 : index === 1 ? 2 : 1)
987
+ : 1 - index
988
+ }`;
989
+ if (
990
+ (mode === "alternate-redirect" && index === 1) ||
991
+ ((mode === "redirect-alternate" ||
992
+ mode === "redirect-intermediate") && index === 0)
993
+ ) {
994
+ return Response.redirect(next, 302);
995
+ }
996
+ return alternate(next, mode === "html");
997
+ });
998
+ try {
999
+ await rejects(loader(`${base}0`), (error: unknown) => {
1000
+ ok(error instanceof FetchError);
1001
+ ok(error.message.includes("Redirect loop detected:"));
1002
+ return true;
1003
+ });
1004
+ deepStrictEqual(requests, mode === "redirect-intermediate" ? 3 : 2);
1005
+ } finally {
1006
+ fetchMock.hardReset();
1007
+ }
1008
+ });
1009
+ }
1010
+
1011
+ await t.step(
1012
+ "alternate followed by 20 redirects shares the limit",
1013
+ async () => {
1014
+ fetchMock.mockGlobal();
1015
+ let requests = 0;
1016
+ fetchMock.get(`begin:${base}`, ({ url }) => {
1017
+ requests++;
1018
+ const index = Number(new URL(url).pathname.split("/").at(-1));
1019
+ if (index === 21) return Response.json({ done: true });
1020
+ return index === 0
1021
+ ? alternate(`${base}1`, false)
1022
+ : Response.redirect(`${base}${index + 1}`, 302);
1023
+ });
1024
+ try {
1025
+ await rejects(loader(`${base}0`), (error: unknown) => {
1026
+ ok(error instanceof FetchError);
1027
+ ok(error.message.includes("Too many redirections (21)"));
1028
+ return true;
1029
+ });
1030
+ deepStrictEqual(requests, 21);
1031
+ } finally {
1032
+ fetchMock.hardReset();
1033
+ }
1034
+ },
1035
+ );
1036
+
1037
+ await t.step(
1038
+ "relative alternate after redirect and isolated calls",
1039
+ async () => {
1040
+ fetchMock.mockGlobal();
1041
+ fetchMock.get(`${base}start`, Response.redirect(`${base}html`, 302));
1042
+ fetchMock.get(`${base}html`, alternate("./document", true));
1043
+ fetchMock.get(`${base}document`, Response.json({ done: true }));
1044
+ try {
1045
+ for (let i = 0; i < 2; i++) {
1046
+ const results = await Promise.all([
1047
+ loader(`${base}start`),
1048
+ loader(`${base}start`),
1049
+ ]);
1050
+ for (const result of results) {
1051
+ deepStrictEqual(result.document, { done: true });
1052
+ deepStrictEqual(result.documentUrl, `${base}document`);
1053
+ }
1054
+ }
1055
+ } finally {
1056
+ fetchMock.hardReset();
1057
+ }
1058
+ },
1059
+ );
1060
+
1061
+ function alternate(next: string, html: boolean): Response {
1062
+ return html
1063
+ ? new Response(
1064
+ `<link rel="alternate" type="application/activity+json" href="${next}">`,
1065
+ {
1066
+ headers: { "Content-Type": "text/html" },
1067
+ },
1068
+ )
1069
+ : new Response("not JSON", {
1070
+ headers: {
1071
+ "Content-Type": "text/plain",
1072
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`,
1073
+ },
1074
+ });
1075
+ }
1076
+ });
1077
+
1078
+ test("getDocumentLoader() preserves cancellation across alternates", async (t) => {
1079
+ const base = "https://example.com/alternate-abort/";
1080
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
1081
+ for (const html of [false, true]) {
1082
+ await t.step(html ? "HTML" : "Link header", async () => {
1083
+ fetchMock.mockGlobal();
1084
+ const controller = new AbortController();
1085
+ let requests = 0;
1086
+ fetchMock.get(`begin:${base}`, ({ url }) => {
1087
+ requests++;
1088
+ const index = Number(new URL(url).pathname.split("/").at(-1));
1089
+ if (index === 2) return Response.json({ done: true });
1090
+ if (index === 1) controller.abort();
1091
+ const next = `${base}${index + 1}`;
1092
+ return new Response(
1093
+ html
1094
+ ? `<link rel="alternate" type="application/activity+json" href="${next}">`
1095
+ : "not JSON",
1096
+ {
1097
+ headers: html ? { "Content-Type": "text/html" } : {
1098
+ "Content-Type": "text/plain",
1099
+ Link:
1100
+ `<${next}>; rel="alternate"; type="application/activity+json"`,
1101
+ },
1102
+ },
1103
+ );
1104
+ });
1105
+ try {
1106
+ await rejects(loader(`${base}0`, { signal: controller.signal }), {
1107
+ name: "AbortError",
1108
+ });
1109
+ deepStrictEqual(requests, 2);
1110
+ } finally {
1111
+ fetchMock.hardReset();
1112
+ }
1113
+ });
1114
+ }
1115
+ });
1116
+
1117
+ test("getDocumentLoader() rejects cancellation before fetching", async () => {
1118
+ fetchMock.mockGlobal();
1119
+ let requests = 0;
1120
+ const url = "https://example.com/pre-aborted-alternate";
1121
+ fetchMock.get(url, () => {
1122
+ requests++;
1123
+ return Response.json({ done: true });
1124
+ });
1125
+ try {
1126
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
1127
+ const controller = new AbortController();
1128
+ controller.abort();
1129
+ await rejects(loader(url, { signal: controller.signal }), {
1130
+ name: "AbortError",
1131
+ });
1132
+ deepStrictEqual(requests, 0);
1133
+ } finally {
1134
+ fetchMock.hardReset();
1135
+ }
1136
+ });
1137
+
1138
+ async function withServer<T>(
1139
+ handler: (request: IncomingMessage, response: ServerResponse) => void,
1140
+ callback: (baseUrl: string) => Promise<T>,
1141
+ ): Promise<T> {
1142
+ const server = createServer(handler);
1143
+ await new Promise<void>((resolve, reject) => {
1144
+ server.once("error", reject);
1145
+ server.listen(0, "127.0.0.1", resolve);
1146
+ });
1147
+ try {
1148
+ const address = server.address();
1149
+ ok(address != null && typeof address !== "string");
1150
+ return await callback(`http://127.0.0.1:${address.port}`);
1151
+ } finally {
1152
+ await new Promise<void>((resolve, reject) => {
1153
+ server.close((error) => error == null ? resolve() : reject(error));
1154
+ server.closeAllConnections();
1155
+ });
1156
+ }
1157
+ }
1158
+
1159
+ function isTimeoutFetchError(error: unknown, url: string): boolean {
1160
+ ok(error instanceof FetchError, String(error));
1161
+ deepStrictEqual(error.url.href, url);
1162
+ deepStrictEqual(error.response, undefined);
1163
+ ok(error.cause instanceof DOMException);
1164
+ deepStrictEqual(error.cause.name, "TimeoutError");
1165
+ return true;
1166
+ }
1167
+
1168
+ test("resolveDocumentLoaderTimeout() validates timeouts", () => {
1169
+ deepStrictEqual(resolveDocumentLoaderTimeout(undefined), 10_000);
1170
+ deepStrictEqual(resolveDocumentLoaderTimeout(null), null);
1171
+ deepStrictEqual(resolveDocumentLoaderTimeout(1500), 1500);
1172
+ deepStrictEqual(resolveDocumentLoaderTimeout(0.5), 1);
1173
+ deepStrictEqual(resolveDocumentLoaderTimeout(2_147_483_647), 2_147_483_647);
1174
+ for (const invalid of [0, -1, NaN, Infinity, 2_147_483_648]) {
1175
+ throws(() => resolveDocumentLoaderTimeout(invalid), RangeError);
1176
+ }
1177
+ throws(() => getDocumentLoader({ timeout: 0 }), RangeError);
1178
+ });
1179
+
1180
+ test("getDocumentLoader() times out a request without a response", async () => {
1181
+ await withServer(() => {
1182
+ // Never respond.
1183
+ }, async (baseUrl) => {
1184
+ const url = `${baseUrl}/hang`;
1185
+ const loader = getDocumentLoader({
1186
+ allowPrivateAddress: true,
1187
+ timeout: 100,
1188
+ });
1189
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1190
+ });
1191
+ });
1192
+
1193
+ test("getDocumentLoader() times out a stalled response body", async () => {
1194
+ for (const status of [200, 404]) {
1195
+ await withServer((_, response) => {
1196
+ response.writeHead(status, {
1197
+ "Content-Type": "application/activity+json",
1198
+ });
1199
+ response.write('{"id":');
1200
+ // Never finish the body.
1201
+ }, async (baseUrl) => {
1202
+ const url = `${baseUrl}/stalled`;
1203
+ const loader = getDocumentLoader({
1204
+ allowPrivateAddress: true,
1205
+ timeout: 200,
1206
+ });
1207
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1208
+ });
1209
+ }
1210
+ });
1211
+
1212
+ test("getDocumentLoader() shares the timeout across redirects", async () => {
1213
+ let requests = 0;
1214
+ await withServer((request, response) => {
1215
+ requests++;
1216
+ const index = Number(request.url?.split("/").at(-1));
1217
+ setTimeout(() => {
1218
+ if (index >= 15) {
1219
+ response.writeHead(200, {
1220
+ "Content-Type": "application/activity+json",
1221
+ });
1222
+ response.end('{"done":true}');
1223
+ return;
1224
+ }
1225
+ response.writeHead(302, { Location: `/redirect/${index + 1}` });
1226
+ response.end();
1227
+ }, 50);
1228
+ }, async (baseUrl) => {
1229
+ const url = `${baseUrl}/redirect/0`;
1230
+ // Each hop is well within the timeout, but the whole chain is not:
1231
+ const loader = getDocumentLoader({
1232
+ allowPrivateAddress: true,
1233
+ timeout: 300,
1234
+ });
1235
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1236
+ ok(requests < 16, `requests: ${requests}`);
1237
+ // Without a timeout, the chain completes:
1238
+ const unbounded = getDocumentLoader({
1239
+ allowPrivateAddress: true,
1240
+ timeout: null,
1241
+ });
1242
+ deepStrictEqual((await unbounded(url)).document, { done: true });
1243
+ });
1244
+ });
1245
+
1246
+ test("getDocumentLoader() shares the timeout across alternates", async (t) => {
1247
+ for (const html of [false, true]) {
1248
+ await t.step(html ? "HTML" : "Link header", async () => {
1249
+ await withServer((request, response) => {
1250
+ const index = Number(request.url?.split("/").at(-1));
1251
+ setTimeout(() => {
1252
+ if (index >= 15) {
1253
+ response.writeHead(200, {
1254
+ "Content-Type": "application/activity+json",
1255
+ });
1256
+ response.end('{"done":true}');
1257
+ return;
1258
+ }
1259
+ const next = `/alternate/${index + 1}`;
1260
+ response.writeHead(
1261
+ 200,
1262
+ html ? { "Content-Type": "text/html" } : {
1263
+ "Content-Type": "text/plain",
1264
+ Link:
1265
+ `<${next}>; rel="alternate"; type="application/activity+json"`,
1266
+ },
1267
+ );
1268
+ response.end(
1269
+ html
1270
+ ? `<link rel="alternate" type="application/activity+json" href="${next}">`
1271
+ : "not JSON",
1272
+ );
1273
+ }, 50);
1274
+ }, async (baseUrl) => {
1275
+ const url = `${baseUrl}/alternate/0`;
1276
+ const loader = getDocumentLoader({
1277
+ allowPrivateAddress: true,
1278
+ timeout: 300,
1279
+ });
1280
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1281
+ });
1282
+ });
1283
+ }
1284
+ });
1285
+
1286
+ test("getDocumentLoader() lets the caller's signal abort a request", async () => {
1287
+ await withServer(() => {
1288
+ // Never respond.
1289
+ }, async (baseUrl) => {
1290
+ const url = `${baseUrl}/hang`;
1291
+ for (const timeout of [undefined, 60_000, null]) {
1292
+ const loader = getDocumentLoader({ allowPrivateAddress: true, timeout });
1293
+ const controller = new AbortController();
1294
+ const reason = new Error("Canceled by the caller");
1295
+ setTimeout(() => controller.abort(reason), 50);
1296
+ await rejects(loader(url, { signal: controller.signal }), (e) => {
1297
+ deepStrictEqual(e, reason);
1298
+ return true;
1299
+ });
1300
+ }
1301
+ // The caller's signal wins even if the timeout fires too:
1302
+ const loader = getDocumentLoader({
1303
+ allowPrivateAddress: true,
1304
+ timeout: 50,
1305
+ });
1306
+ const controller = new AbortController();
1307
+ const reason = new Error("Canceled by the caller");
1308
+ controller.abort(reason);
1309
+ await rejects(loader(url, { signal: controller.signal }), (e) => {
1310
+ deepStrictEqual(e, reason);
1311
+ return true;
1312
+ });
1313
+ });
1314
+ });
1315
+
1316
+ test("getDocumentLoader() bounds DNS lookups that ignore the timeout", async () => {
1317
+ // The validator skips DNS when Deno has no network permission.
1318
+ if (
1319
+ "Deno" in globalThis &&
1320
+ (await Deno.permissions.query({ name: "net" })).state !== "granted"
1321
+ ) {
1322
+ throw new Error(
1323
+ "This DNS test requires Deno network permission (--allow-net).",
1324
+ );
1325
+ }
1326
+ fetchMock.mockGlobal();
1327
+ let requests = 0;
1328
+ const url = "https://slow-dns.example/object";
1329
+ fetchMock.get(url, () => {
1330
+ requests++;
1331
+ return Response.json({ id: url });
1332
+ });
1333
+ // Stubbing works only because url.ts uses the default node:dns/promises
1334
+ // import; see the FIXME there.
1335
+ const originalLookup = dns.lookup;
1336
+ let resolveLookup: () => void = () => {};
1337
+ const lookedUp = new Promise<void>((resolve) => resolveLookup = resolve);
1338
+ dns.lookup = (() =>
1339
+ new Promise((resolve) =>
1340
+ setTimeout(() => {
1341
+ resolve([{ address: "93.184.215.14", family: 4 }]);
1342
+ resolveLookup();
1343
+ }, 200)
1344
+ )) as typeof dns.lookup;
1345
+ try {
1346
+ const loader = getDocumentLoader({ timeout: 50 });
1347
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1348
+ // A late DNS answer must not start the request anymore:
1349
+ await lookedUp;
1350
+ await new Promise((resolve) => setTimeout(resolve, 10));
1351
+ deepStrictEqual(requests, 0);
1352
+ } finally {
1353
+ dns.lookup = originalLookup;
1354
+ fetchMock.hardReset();
1355
+ }
1356
+ });
1357
+
1358
+ test("withDocumentLoaderTimeout() cleans up after each call", async () => {
1359
+ const signals: AbortSignal[] = [];
1360
+ const loader = withDocumentLoaderTimeout((url, options) => {
1361
+ signals.push(options!.signal!);
1362
+ return Promise.resolve({
1363
+ contextUrl: null,
1364
+ document: {},
1365
+ documentUrl: url,
1366
+ });
1367
+ }, 50);
1368
+ const results = await Promise.all([
1369
+ loader("https://example.com/a"),
1370
+ loader("https://example.com/b"),
1371
+ ]);
1372
+ deepStrictEqual(results.map((r) => r.documentUrl), [
1373
+ "https://example.com/a",
1374
+ "https://example.com/b",
1375
+ ]);
1376
+ ok(signals[0] !== signals[1]);
1377
+ // The timers are cleared, so the signals are never aborted:
1378
+ await new Promise((resolve) => setTimeout(resolve, 100));
1379
+ ok(signals.every((signal) => !signal.aborted));
1380
+ // A loader that is given no timeout is returned as is:
1381
+ const inner = () => Promise.reject(new Error("unused"));
1382
+ ok(withDocumentLoaderTimeout(inner, null) === inner);
1383
+ });
1384
+
1385
+ test("getRemoteDocument() keeps error bodies byte for byte", async () => {
1386
+ const bytes = new Uint8Array([0xef, 0xbb, 0xbf, 0x4e, 0x6f, 0xff, 0xfe]);
1387
+ const url = "https://example.com/error";
1388
+ let error: unknown;
1389
+ await rejects(
1390
+ getRemoteDocument(
1391
+ url,
1392
+ new Response(bytes, { status: 404 }),
1393
+ () => Promise.reject(new Error("unused")),
1394
+ ),
1395
+ (e) => {
1396
+ error = e;
1397
+ return true;
1398
+ },
1399
+ );
1400
+ ok(error instanceof FetchError);
1401
+ ok(error.response != null);
1402
+ deepStrictEqual(error.response.status, 404);
1403
+ deepStrictEqual(new Uint8Array(await error.response.arrayBuffer()), bytes);
1404
+ });
1405
+
1406
+ test("getRemoteDocument() keeps metadata of bodiless or large errors", async () => {
1407
+ const url = "https://example.com/error";
1408
+ for (
1409
+ const response of [
1410
+ new Response(null, { status: 304, headers: { ETag: '"a"' } }),
1411
+ new Response(new Uint8Array(1024 * 1024 + 1), {
1412
+ status: 500,
1413
+ headers: { ETag: '"a"' },
1414
+ }),
1415
+ ]
1416
+ ) {
1417
+ await rejects(
1418
+ getRemoteDocument(
1419
+ url,
1420
+ response,
1421
+ () => Promise.reject(new Error("unused")),
1422
+ ),
1423
+ (e) => {
1424
+ ok(e instanceof FetchError);
1425
+ ok(e.response != null);
1426
+ deepStrictEqual(e.response.status, response.status);
1427
+ deepStrictEqual(e.response.headers.get("ETag"), '"a"');
1428
+ deepStrictEqual(e.response.body, null);
1429
+ return true;
1430
+ },
1431
+ );
1432
+ }
1433
+ });
1434
+
1435
+ test("getDocumentLoader() reports statuses that Response cannot hold", async () => {
1436
+ await withServer((_, response) => {
1437
+ response.writeHead(999);
1438
+ response.end("Request denied");
1439
+ }, async (baseUrl) => {
1440
+ const url = `${baseUrl}/denied`;
1441
+ const loader = getDocumentLoader({ allowPrivateAddress: true });
1442
+ let error: unknown;
1443
+ await rejects(loader(url), (e) => {
1444
+ error = e;
1445
+ return true;
1446
+ });
1447
+ ok(error instanceof FetchError, String(error));
1448
+ deepStrictEqual(error.response?.status, 999);
1449
+ deepStrictEqual(await error.response.text(), "Request denied");
1450
+ });
1451
+ });
1452
+
1453
+ test("getDocumentLoader() times out a stalled body of such a status", async () => {
1454
+ await withServer((_, response) => {
1455
+ response.writeHead(999);
1456
+ response.write("Request");
1457
+ // Never finish the body.
1458
+ }, async (baseUrl) => {
1459
+ const url = `${baseUrl}/stalled-denied`;
1460
+ const loader = getDocumentLoader({
1461
+ allowPrivateAddress: true,
1462
+ timeout: 200,
1463
+ });
1464
+ await rejects(loader(url), (e) => isTimeoutFetchError(e, url));
1465
+ });
1466
+ });