@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.
- package/deno.json +4 -2
- package/dist/{tests/docloader-Ck8bcBir.mjs → contexts-B09jb9Pi.cjs} +173 -274
- package/dist/{tests/docloader-S_gQZ2tt.cjs → contexts-DWrj4AGR.js} +169 -293
- package/dist/{docloader-C_dir7Xb.d.ts → docloader-BMHljyYb.d.cts} +68 -2
- package/dist/{docloader-D2DTRiyA.d.cts → docloader-BMHljyYb.d.ts} +68 -2
- package/dist/internal/jsonld-cache.cjs +70 -1
- package/dist/internal/jsonld-cache.d.cts +35 -2
- package/dist/internal/jsonld-cache.d.ts +35 -2
- package/dist/internal/jsonld-cache.js +67 -2
- package/dist/internal/portable-dereference.cjs +721 -0
- package/dist/internal/portable-dereference.d.cts +349 -0
- package/dist/internal/portable-dereference.d.ts +349 -0
- package/dist/internal/portable-dereference.js +702 -0
- package/dist/internal/signed-representation.cjs +378 -0
- package/dist/internal/signed-representation.d.cts +144 -0
- package/dist/internal/signed-representation.d.ts +144 -0
- package/dist/internal/signed-representation.js +371 -0
- package/dist/jsonld.cjs +2 -2
- package/dist/mod.cjs +762 -4529
- package/dist/mod.d.cts +427 -9
- package/dist/mod.d.ts +427 -10
- package/dist/mod.js +742 -4524
- package/dist/portable-6Aj-7dZJ.d.cts +160 -0
- package/dist/portable-nkTiqH0w.d.ts +160 -0
- package/dist/tests/body-DpC9p-iG.mjs +130 -0
- package/dist/tests/body-cKLBprYm.cjs +153 -0
- package/dist/tests/body.test.cjs +89 -0
- package/dist/tests/body.test.d.cts +1 -0
- package/dist/tests/body.test.d.mts +1 -0
- package/dist/tests/body.test.mjs +90 -0
- package/dist/tests/contexts-C9Lj11SC.cjs +4533 -0
- package/dist/tests/contexts-duRA_dDC.mjs +4522 -0
- package/dist/tests/decimal.test.cjs +9 -7
- package/dist/tests/decimal.test.mjs +9 -6
- package/dist/tests/digest-3FeH2Y-Q.cjs +176 -0
- package/dist/tests/digest-COC7xDiQ.mjs +141 -0
- package/dist/tests/digest.test.cjs +102 -0
- package/dist/tests/digest.test.d.cts +1 -0
- package/dist/tests/digest.test.d.mts +1 -0
- package/dist/tests/digest.test.mjs +103 -0
- package/dist/tests/docloader-DyZ1JGIl.cjs +398 -0
- package/dist/tests/docloader-tUmMeXjU.mjs +375 -0
- package/dist/tests/docloader.test.cjs +816 -39
- package/dist/tests/docloader.test.mjs +810 -34
- package/dist/tests/internal/multicodec.test.cjs +2 -3
- package/dist/tests/internal/multicodec.test.mjs +2 -2
- package/dist/tests/internal/portable-dereference.test.cjs +172 -0
- package/dist/tests/internal/portable-dereference.test.d.cts +1 -0
- package/dist/tests/internal/portable-dereference.test.d.mts +1 -0
- package/dist/tests/internal/portable-dereference.test.mjs +173 -0
- package/dist/tests/jsonld-CmDhHNTx.cjs +12 -0
- package/dist/tests/jsonld-DweIJ07b.mjs +5 -0
- package/dist/tests/jsonld-cache-BEXiiM-V.cjs +386 -0
- package/dist/tests/jsonld-cache-DFnWsLuZ.mjs +339 -0
- package/dist/tests/jsonld-cache.test.cjs +102 -298
- package/dist/tests/jsonld-cache.test.mjs +95 -289
- package/dist/tests/{key-_wXwomh_.cjs → key-C-AYkdJJ.cjs} +11 -4
- package/dist/tests/{key-CDGDH_vC.mjs → key-C2Db_TAJ.mjs} +11 -3
- package/dist/tests/key.test.cjs +6 -5
- package/dist/tests/key.test.mjs +6 -4
- package/dist/tests/{langstr-Di5AvKpB.mjs → langstr-DYRmJqFn.mjs} +5 -2
- package/dist/tests/{langstr-CbAxaeEZ.cjs → langstr-_PqsL1ae.cjs} +5 -2
- package/dist/tests/langstr.test.cjs +11 -4
- package/dist/tests/langstr.test.mjs +9 -2
- package/dist/tests/langtag-CUppHs_s.mjs +45 -0
- package/dist/tests/langtag-Czjvxfzx.cjs +50 -0
- package/dist/tests/langtag.test.cjs +45 -0
- package/dist/tests/langtag.test.d.cts +1 -0
- package/dist/tests/langtag.test.d.mts +1 -0
- package/dist/tests/langtag.test.mjs +46 -0
- package/dist/tests/link.test.cjs +2 -3
- package/dist/tests/link.test.mjs +2 -2
- package/dist/tests/multibase/multibase.test.cjs +8 -9
- package/dist/tests/multibase/multibase.test.mjs +6 -6
- package/dist/tests/{multibase-Bz_UUDtL.cjs → multibase-B5Mea7Ip.cjs} +19 -2
- package/dist/tests/{multibase-B4bvakyA.mjs → multibase-BPnF_L4e.mjs} +12 -1
- package/dist/tests/portable-dereference-DmTYDy2p.mjs +250 -0
- package/dist/tests/portable-dereference-qTLMqe2_.cjs +279 -0
- package/dist/tests/portable-media-5p8azal0.cjs +153 -0
- package/dist/tests/portable-media-B_lt9MMA.mjs +148 -0
- package/dist/tests/portable-media.test.cjs +222 -0
- package/dist/tests/portable-media.test.d.cts +1 -0
- package/dist/tests/portable-media.test.d.mts +1 -0
- package/dist/tests/portable-media.test.mjs +223 -0
- package/dist/tests/portable-workers.test.cjs +36 -0
- package/dist/tests/portable-workers.test.d.cts +2 -0
- package/dist/tests/portable-workers.test.d.mts +2 -0
- package/dist/tests/portable-workers.test.mjs +35 -0
- package/dist/tests/{request-uk51rkhO.cjs → request-BgjA9wyl.cjs} +10 -4
- package/dist/tests/{request-C8CaGwtt.mjs → request-DodZNukP.mjs} +8 -2
- package/dist/tests/request.test.cjs +7 -4
- package/dist/tests/request.test.mjs +5 -2
- package/dist/tests/signed-representation.test.cjs +588 -0
- package/dist/tests/signed-representation.test.d.cts +1 -0
- package/dist/tests/signed-representation.test.d.mts +1 -0
- package/dist/tests/signed-representation.test.mjs +589 -0
- package/dist/tests/temporal.test.cjs +1 -2
- package/dist/tests/temporal.test.mjs +1 -1
- package/dist/tests/url-BNakuZ8k.cjs +998 -0
- package/dist/tests/url-DMxmp7ZG.mjs +859 -0
- package/dist/tests/url.test.cjs +489 -6
- package/dist/tests/url.test.mjs +489 -5
- package/dist/url-DrGTR8yv.cjs +993 -0
- package/dist/url-Dzyp-NsC.js +860 -0
- package/package.json +27 -4
- package/scripts/test-bun.mjs +17 -0
- package/src/body.test.ts +125 -0
- package/src/body.ts +152 -0
- package/src/contexts/cid-v1.json +114 -0
- package/src/contexts/fep-22cd.json +21 -0
- package/src/contexts/fep-6757.json +12 -0
- package/src/contexts/joinmastodon.json +1 -1
- package/src/contexts/miscellany.json +17 -0
- package/src/contexts.ts +47 -1
- package/src/digest.test.ts +220 -0
- package/src/digest.ts +229 -0
- package/src/docloader.test.ts +1066 -27
- package/src/docloader.ts +322 -105
- package/src/internal/jsonld-cache.ts +97 -1
- package/src/internal/portable-dereference.test.ts +254 -0
- package/src/internal/portable-dereference.ts +1000 -0
- package/src/internal/signed-representation.ts +565 -0
- package/src/jsonld-cache.test.ts +89 -0
- package/src/key.test.ts +9 -0
- package/src/key.ts +11 -1
- package/src/langstr.test.ts +18 -1
- package/src/langstr.ts +6 -2
- package/src/langtag.test.ts +53 -0
- package/src/langtag.ts +76 -0
- package/src/mod.ts +29 -0
- package/src/multibase/multibase.test.ts +5 -5
- package/src/portable-media.test.ts +293 -0
- package/src/portable-media.ts +264 -0
- package/src/portable-workers.test.ts +81 -0
- package/src/portable.ts +180 -0
- package/src/preprocessor.ts +7 -0
- package/src/request.test.ts +10 -1
- package/src/request.ts +9 -1
- package/src/signed-representation.test.ts +338 -0
- package/src/url.test.ts +823 -3
- package/src/url.ts +700 -23
- package/tsdown.config.ts +2 -0
- package/dist/tests/url-CsOV_B_P.cjs +0 -482
- package/dist/tests/url-Du7RQQgP.mjs +0 -392
- package/dist/url-DD4F0ULf.cjs +0 -483
- package/dist/url-DGVbSVVi.js +0 -393
- /package/dist/{chunk-M78iaK0I.cjs → rolldown-runtime-B7lfambq.cjs} +0 -0
- /package/dist/tests/{link-NUUWCdnK.mjs → link-Cevmc87v.mjs} +0 -0
- /package/dist/tests/{link-FguCydMA.cjs → link-DlKm8bEr.cjs} +0 -0
- /package/dist/tests/{multicodec-CxGVGa91.cjs → multicodec-CLRPeW4N.cjs} +0 -0
- /package/dist/tests/{multicodec-CyFp54fI.mjs → multicodec-CRIj_05H.mjs} +0 -0
- /package/dist/tests/{chunk-C2EiDwsr.cjs → rolldown-runtime-emK7D4bc.cjs} +0 -0
package/src/docloader.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|