@fedify/vocab-runtime 2.1.29 → 2.1.31
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 +5 -2
- package/dist/internal.cjs +6 -0
- package/dist/internal.d.cts +3 -0
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +3 -0
- package/dist/mod.cjs +105 -252
- package/dist/mod.d.cts +5 -27
- package/dist/mod.d.ts +6 -27
- package/dist/mod.js +99 -245
- package/dist/public-fetch-BC-nWJNM.d.ts +143 -0
- package/dist/public-fetch-CFnW7XMn.cjs +821 -0
- package/dist/public-fetch-DBpt3xxf.d.cts +143 -0
- package/dist/public-fetch-Dfmc_Kfy.js +772 -0
- package/dist/tests/{body-C-p-RXaQ.mjs → body-B0Z2Vegb.mjs} +1 -1
- package/dist/tests/{body-BI7IK9DK.cjs → body-i_FdQE3f.cjs} +1 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/decimal.test.cjs +6 -6
- package/dist/tests/decimal.test.mjs +6 -6
- package/dist/tests/{docloader-DVamy9FW.mjs → docloader-dwostFF6.mjs} +103 -37
- package/dist/tests/{docloader-Okt4FgM8.cjs → docloader-oZrKg7F3.cjs} +107 -35
- package/dist/tests/docloader.test.cjs +568 -1272
- package/dist/tests/docloader.test.mjs +514 -1237
- package/dist/tests/esm-CnLt0wAF.mjs +1225 -0
- package/dist/tests/esm-CxbqfkLP.cjs +1208 -0
- package/dist/tests/internal/multicodec.test.cjs +1 -1
- package/dist/tests/internal/multicodec.test.mjs +1 -1
- package/dist/tests/{key-BDIsC6hV.mjs → key-GEdpxA4M.mjs} +2 -2
- package/dist/tests/{key-BarKGLQz.cjs → key-L6ReMuUb.cjs} +2 -2
- package/dist/tests/key.test.cjs +2 -2
- package/dist/tests/key.test.mjs +2 -2
- package/dist/tests/{langstr-BsqFJupv.cjs → langstr-CZpWmpo5.cjs} +1 -1
- package/dist/tests/{langstr-ABdAOKlF.mjs → langstr-DTB4sl0a.mjs} +1 -1
- package/dist/tests/langstr.test.cjs +1 -1
- package/dist/tests/langstr.test.mjs +1 -1
- package/dist/tests/langtag.test.cjs +1 -1
- package/dist/tests/langtag.test.mjs +1 -1
- package/dist/tests/link.test.mjs +1 -1
- package/dist/tests/multibase/multibase.test.cjs +1 -1
- package/dist/tests/multibase/multibase.test.mjs +1 -1
- package/dist/tests/node-C3bjFjL1.mjs +84 -0
- package/dist/tests/node-DWD76vPo.cjs +90 -0
- package/dist/tests/pin-grant-CU-o367k.cjs +70 -0
- package/dist/tests/pin-grant-HW3B6SKv.mjs +53 -0
- package/dist/tests/pin-grant.test.cjs +88 -0
- package/dist/tests/pin-grant.test.d.cts +1 -0
- package/dist/tests/pin-grant.test.d.mts +1 -0
- package/dist/tests/pin-grant.test.mjs +88 -0
- package/dist/tests/pinners/bun.test.cjs +167 -0
- package/dist/tests/pinners/bun.test.d.cts +1 -0
- package/dist/tests/pinners/bun.test.d.mts +1 -0
- package/dist/tests/pinners/bun.test.mjs +167 -0
- package/dist/tests/pinners/node.test.cjs +269 -0
- package/dist/tests/pinners/node.test.d.cts +1 -0
- package/dist/tests/pinners/node.test.d.mts +1 -0
- package/dist/tests/pinners/node.test.mjs +268 -0
- package/dist/tests/pinners/pinners.test.cjs +197 -0
- package/dist/tests/pinners/pinners.test.d.cts +1 -0
- package/dist/tests/pinners/pinners.test.d.mts +1 -0
- package/dist/tests/pinners/pinners.test.mjs +197 -0
- package/dist/tests/pinners/testdata/node-child.cjs +135 -0
- package/dist/tests/pinners/testdata/node-child.d.cts +1 -0
- package/dist/tests/pinners/testdata/node-child.d.mts +1 -0
- package/dist/tests/pinners/testdata/node-child.mjs +134 -0
- package/dist/tests/pinners-BjE-YJLL.cjs +221 -0
- package/dist/tests/pinners-ZgZDx9y6.mjs +203 -0
- package/dist/tests/public-fetch-Ddjfxbwc.cjs +187 -0
- package/dist/tests/public-fetch-Yi7Vks9s.mjs +170 -0
- package/dist/tests/public-fetch.test.cjs +1074 -0
- package/dist/tests/public-fetch.test.d.cts +1 -0
- package/dist/tests/public-fetch.test.d.mts +1 -0
- package/dist/tests/public-fetch.test.mjs +1074 -0
- package/dist/tests/{request-C_PrlGe_.cjs → request-CzYw8aEp.cjs} +1 -1
- package/dist/tests/{request-B9R4Y9qS.mjs → request-DY2AaRpH.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/dist/tests/support-D5-KVjWZ.cjs +363 -0
- package/dist/tests/support-M5ClN-no.mjs +272 -0
- package/dist/tests/{url-vZTgkIZg.cjs → url-DLuSj57U.cjs} +88 -12
- package/dist/tests/{url-3fyJuqC0.mjs → url-DQWrMGhu.mjs} +65 -13
- package/dist/tests/url.test.cjs +4 -1
- package/dist/tests/url.test.mjs +4 -1
- package/package.json +14 -3
- package/src/docloader.test.ts +780 -31
- package/src/docloader.ts +179 -67
- package/src/internal.ts +13 -0
- package/src/pin-grant.test.ts +118 -0
- package/src/pin-grant.ts +99 -0
- package/src/pinners/bun.test.ts +278 -0
- package/src/pinners/bun.ts +223 -0
- package/src/pinners/mod.ts +59 -0
- package/src/pinners/node.test.ts +415 -0
- package/src/pinners/node.ts +144 -0
- package/src/pinners/pinners.test.ts +316 -0
- package/src/pinners/testdata/node-child.ts +147 -0
- package/src/pinners/testdata/proxy-child.ts +82 -0
- package/src/pinners/testdata/support.ts +343 -0
- package/src/pinners/testdata/tls-cert.pem +12 -0
- package/src/pinners/testdata/tls-child.ts +88 -0
- package/src/pinners/testdata/tls-key.pem +5 -0
- package/src/pinners/types.ts +48 -0
- package/src/public-fetch.test.ts +1286 -0
- package/src/public-fetch.ts +338 -0
- package/src/testdata/public-fetch-deno-worker.ts +74 -0
- package/src/url.test.ts +11 -0
- package/src/url.ts +113 -22
- package/tsdown.config.ts +12 -3
- /package/dist/tests/{langtag-LDBqlxHv.mjs → langtag-D8FWxjv8.mjs} +0 -0
- /package/dist/tests/{langtag-DgySESmk.cjs → langtag-a2-s9uyi.cjs} +0 -0
- /package/dist/tests/{link-CYszIegt.mjs → link-CHMtH2-k.mjs} +0 -0
- /package/dist/tests/{multibase-8zhYPa7o.cjs → multibase-BmWaK56H.cjs} +0 -0
- /package/dist/tests/{multibase-Ds75BvXs.mjs → multibase-D642Uv3S.mjs} +0 -0
- /package/dist/tests/{multicodec-ByS9XLsw.cjs → multicodec-B-_CSt1M.cjs} +0 -0
- /package/dist/tests/{multicodec-BBPZjRfg.mjs → multicodec-caWdG98t.mjs} +0 -0
package/src/docloader.test.ts
CHANGED
|
@@ -1,17 +1,50 @@
|
|
|
1
1
|
import { configure, type LogRecord, reset } from "@logtape/logtape";
|
|
2
2
|
import fetchMock from "fetch-mock";
|
|
3
3
|
import { deepStrictEqual, ok, rejects } from "node:assert";
|
|
4
|
-
import
|
|
4
|
+
import type { LookupAddress } from "node:dns";
|
|
5
5
|
import { test } from "@fedify/fixture";
|
|
6
6
|
import { createServer } from "node:http";
|
|
7
7
|
import { gzipSync } from "node:zlib";
|
|
8
8
|
import preloadedContexts from "./contexts.ts";
|
|
9
9
|
import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createDocumentLoader,
|
|
12
|
+
getDocumentLoader,
|
|
13
|
+
getRemoteDocument,
|
|
14
|
+
type RemoteDocument,
|
|
15
|
+
} from "./docloader.ts";
|
|
11
16
|
import jsonld from "./jsonld.ts";
|
|
17
|
+
import type { ConnectionPinner } from "./pinners/mod.ts";
|
|
18
|
+
import { createPublicFetch, type PublicFetchSession } from "./public-fetch.ts";
|
|
12
19
|
import { FetchError } from "./request.ts";
|
|
13
20
|
import { UrlError } from "./url.ts";
|
|
14
21
|
|
|
22
|
+
// Validates addresses like openPublicFetch() but sends requests through the
|
|
23
|
+
// global fetch() without pinning, so that fetch-mock's hostname routes match
|
|
24
|
+
// on every runtime (the Bun pinner would substitute IP addresses). The lookup
|
|
25
|
+
// is always injected, so tests do not depend on the network's DNS.
|
|
26
|
+
const unpinnedPublicFetch = createPublicFetch({
|
|
27
|
+
pinner: () => Promise.resolve(null),
|
|
28
|
+
lookup: () => Promise.reject(new Error("Unexpected default lookup")),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const PUBLIC_ADDRESS: LookupAddress = { address: "93.184.215.14", family: 4 };
|
|
32
|
+
const PRIVATE_ADDRESS: LookupAddress = { address: "10.0.0.1", family: 4 };
|
|
33
|
+
|
|
34
|
+
function lookupTestAddresses(
|
|
35
|
+
hostname: string,
|
|
36
|
+
): Promise<readonly LookupAddress[]> {
|
|
37
|
+
return Promise.resolve([
|
|
38
|
+
hostname === "private.example" ? PRIVATE_ADDRESS : PUBLIC_ADDRESS,
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function openUnpinnedHop(url: string): Promise<PublicFetchSession> {
|
|
43
|
+
return unpinnedPublicFetch.openPublicFetch(url, {
|
|
44
|
+
lookup: lookupTestAddresses,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
15
48
|
test("new FetchError()", () => {
|
|
16
49
|
const e = new FetchError("https://example.com/", "An error message.");
|
|
17
50
|
deepStrictEqual(e.name, "FetchError");
|
|
@@ -32,7 +65,9 @@ test("new FetchError()", () => {
|
|
|
32
65
|
});
|
|
33
66
|
|
|
34
67
|
test("getDocumentLoader()", async (t) => {
|
|
35
|
-
const fetchDocumentLoader =
|
|
68
|
+
const fetchDocumentLoader = createDocumentLoader({}, {
|
|
69
|
+
openHop: openUnpinnedHop,
|
|
70
|
+
});
|
|
36
71
|
|
|
37
72
|
fetchMock.spyGlobal();
|
|
38
73
|
|
|
@@ -557,6 +592,19 @@ test("getDocumentLoader()", async (t) => {
|
|
|
557
592
|
},
|
|
558
593
|
});
|
|
559
594
|
|
|
595
|
+
fetchMock.get("https://example.com/private-redirect", {
|
|
596
|
+
status: 302,
|
|
597
|
+
headers: { Location: "https://private.example/object" },
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
fetchMock.get("https://private.example/object", {
|
|
601
|
+
body: {
|
|
602
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
603
|
+
id: "https://private.example/object",
|
|
604
|
+
type: "Object",
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
560
608
|
await t.step("allowPrivateAddress: false", async () => {
|
|
561
609
|
await rejects(
|
|
562
610
|
() => fetchDocumentLoader("https://localhost/object"),
|
|
@@ -570,6 +618,26 @@ test("getDocumentLoader()", async (t) => {
|
|
|
570
618
|
() => fetchDocumentLoader("https://example.com/localhost-link"),
|
|
571
619
|
UrlError,
|
|
572
620
|
);
|
|
621
|
+
await rejects(
|
|
622
|
+
() => fetchDocumentLoader("https://private.example/object"),
|
|
623
|
+
UrlError,
|
|
624
|
+
);
|
|
625
|
+
await rejects(
|
|
626
|
+
() => fetchDocumentLoader("https://example.com/private-redirect"),
|
|
627
|
+
UrlError,
|
|
628
|
+
);
|
|
629
|
+
deepStrictEqual(
|
|
630
|
+
fetchMock.callHistory.calls("https://private.example/object").length,
|
|
631
|
+
0,
|
|
632
|
+
);
|
|
633
|
+
// The default loader rejects these without resolving any hostname:
|
|
634
|
+
const defaultLoader = getDocumentLoader();
|
|
635
|
+
await rejects(() => defaultLoader("https://localhost/object"), UrlError);
|
|
636
|
+
await rejects(() => defaultLoader("https://127.0.0.1/object"), UrlError);
|
|
637
|
+
deepStrictEqual(
|
|
638
|
+
fetchMock.callHistory.calls("https://localhost/object").length,
|
|
639
|
+
0,
|
|
640
|
+
);
|
|
573
641
|
});
|
|
574
642
|
|
|
575
643
|
const fetchDocumentLoader2 = getDocumentLoader({ allowPrivateAddress: true });
|
|
@@ -819,33 +887,24 @@ test("getRemoteDocument() bounds JSON by default", async () => {
|
|
|
819
887
|
});
|
|
820
888
|
|
|
821
889
|
test("getDocumentLoader() logs DNS failures as such", async (t) => {
|
|
822
|
-
|
|
823
|
-
// here rather than with top-level await, which the CommonJS build rejects.
|
|
824
|
-
if (
|
|
825
|
-
"Deno" in globalThis &&
|
|
826
|
-
(await Deno.permissions.query({ name: "net" })).state !== "granted"
|
|
827
|
-
) {
|
|
828
|
-
throw new Error(
|
|
829
|
-
"This DNS test requires Deno network permission (--allow-net).",
|
|
830
|
-
);
|
|
831
|
-
}
|
|
832
|
-
const loader = getDocumentLoader();
|
|
833
|
-
for (const result of ["throws", "empty", "private"] as const) {
|
|
890
|
+
for (const result of ["throws", "empty", "private", "default"] as const) {
|
|
834
891
|
await t.step(result, async () => {
|
|
835
|
-
|
|
836
|
-
// import; see the FIXME there.
|
|
837
|
-
const originalLookup = dns.lookup;
|
|
838
|
-
dns.lookup = (() =>
|
|
892
|
+
const lookup = (): Promise<readonly LookupAddress[]> =>
|
|
839
893
|
result === "throws"
|
|
840
894
|
? Promise.reject(new Error("Resolver unavailable"))
|
|
841
|
-
: Promise.resolve(
|
|
842
|
-
|
|
843
|
-
|
|
895
|
+
: Promise.resolve(result === "empty" ? [] : [PRIVATE_ADDRESS]);
|
|
896
|
+
// The default loader rejects IP literals without resolving them, so
|
|
897
|
+
// the "default" step checks that its logging matches the injected one:
|
|
898
|
+
const loader = result === "default"
|
|
899
|
+
? getDocumentLoader()
|
|
900
|
+
: createDocumentLoader({}, {
|
|
901
|
+
openHop: (url) =>
|
|
902
|
+
unpinnedPublicFetch.openPublicFetch(url, { lookup }),
|
|
903
|
+
});
|
|
844
904
|
const records: LogRecord[] = [];
|
|
845
905
|
await configure({
|
|
846
906
|
sinks: {
|
|
847
|
-
buffer: (record) =>
|
|
848
|
-
records.push(record),
|
|
907
|
+
buffer: (record) => records.push(record),
|
|
849
908
|
},
|
|
850
909
|
loggers: [
|
|
851
910
|
{ category: "fedify", sinks: ["buffer"], lowestLevel: "debug" },
|
|
@@ -854,21 +913,21 @@ test("getDocumentLoader() logs DNS failures as such", async (t) => {
|
|
|
854
913
|
reset: true,
|
|
855
914
|
});
|
|
856
915
|
try {
|
|
857
|
-
const url = "
|
|
916
|
+
const url = result === "default"
|
|
917
|
+
? "https://10.0.0.1/object"
|
|
918
|
+
: "https://dns-failure.invalid/object";
|
|
858
919
|
let error: unknown;
|
|
859
920
|
await rejects(() => loader(url), (e) => {
|
|
860
921
|
error = e;
|
|
861
922
|
return true;
|
|
862
923
|
});
|
|
863
924
|
ok(error instanceof UrlError);
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
result === "private" ? "disallowed" : "dns",
|
|
867
|
-
);
|
|
925
|
+
const disallowed = result === "private" || result === "default";
|
|
926
|
+
deepStrictEqual(error.reason, disallowed ? "disallowed" : "dns");
|
|
868
927
|
deepStrictEqual(
|
|
869
928
|
records.map((r) => [r.level, r.rawMessage, r.properties.url]),
|
|
870
929
|
[
|
|
871
|
-
|
|
930
|
+
disallowed
|
|
872
931
|
? ["error", "Disallowed private URL: {url}", url]
|
|
873
932
|
: ["debug", "DNS lookup failed for {url}", url],
|
|
874
933
|
],
|
|
@@ -876,7 +935,6 @@ test("getDocumentLoader() logs DNS failures as such", async (t) => {
|
|
|
876
935
|
ok(records[0].properties.error === error);
|
|
877
936
|
} finally {
|
|
878
937
|
await reset();
|
|
879
|
-
dns.lookup = originalLookup;
|
|
880
938
|
}
|
|
881
939
|
});
|
|
882
940
|
}
|
|
@@ -1101,6 +1159,90 @@ const unsupportedUrls = [
|
|
|
1101
1159
|
}`,
|
|
1102
1160
|
];
|
|
1103
1161
|
|
|
1162
|
+
function isCredentialUrlError(error: unknown): boolean {
|
|
1163
|
+
return error instanceof UrlError && error.reason === "disallowed" &&
|
|
1164
|
+
error.message === "URLs with credentials are not allowed";
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
test("getDocumentLoader() rejects URLs with credentials", async (t) => {
|
|
1168
|
+
const start = "https://8.8.8.8/start";
|
|
1169
|
+
const target = "https://user:pass@8.8.8.8/document";
|
|
1170
|
+
for (const allowPrivateAddress of [false, true]) {
|
|
1171
|
+
const loader = getDocumentLoader({ allowPrivateAddress });
|
|
1172
|
+
for (const path of ["direct", "redirect", "Link", "HTML"]) {
|
|
1173
|
+
await t.step(
|
|
1174
|
+
`${path}, allowPrivateAddress=${allowPrivateAddress}`,
|
|
1175
|
+
async () => {
|
|
1176
|
+
await withFetchStub(
|
|
1177
|
+
(url) => {
|
|
1178
|
+
if (url !== start) return Response.json({});
|
|
1179
|
+
if (path === "redirect") return Response.redirect(target, 302);
|
|
1180
|
+
if (path === "HTML") {
|
|
1181
|
+
return new Response(
|
|
1182
|
+
`<link rel="alternate" type="application/activity+json" href="${target}">`,
|
|
1183
|
+
{ headers: { "Content-Type": "text/html" } },
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
return new Response("", {
|
|
1187
|
+
headers: {
|
|
1188
|
+
"Content-Type": "text/plain",
|
|
1189
|
+
Link:
|
|
1190
|
+
`<${target}>; rel="alternate"; type="application/activity+json"`,
|
|
1191
|
+
},
|
|
1192
|
+
});
|
|
1193
|
+
},
|
|
1194
|
+
async (fetched) => {
|
|
1195
|
+
await rejects(
|
|
1196
|
+
loader(path === "direct" ? target : start),
|
|
1197
|
+
isCredentialUrlError,
|
|
1198
|
+
);
|
|
1199
|
+
deepStrictEqual(fetched, path === "direct" ? [] : [start]);
|
|
1200
|
+
},
|
|
1201
|
+
);
|
|
1202
|
+
},
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
for (
|
|
1206
|
+
const url of [
|
|
1207
|
+
"https://user@8.8.8.8/document",
|
|
1208
|
+
"https://:pass@8.8.8.8/document",
|
|
1209
|
+
"https://%75ser:p%40ss@8.8.8.8/document",
|
|
1210
|
+
"http://user:pass@8.8.8.8/document",
|
|
1211
|
+
]
|
|
1212
|
+
) {
|
|
1213
|
+
await t.step(
|
|
1214
|
+
`${url}, allowPrivateAddress=${allowPrivateAddress}`,
|
|
1215
|
+
async () => {
|
|
1216
|
+
await withFetchStub(() => Response.json({}), async (fetched) => {
|
|
1217
|
+
await rejects(loader(url), isCredentialUrlError);
|
|
1218
|
+
deepStrictEqual(fetched, []);
|
|
1219
|
+
});
|
|
1220
|
+
},
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
for (
|
|
1224
|
+
const url of [
|
|
1225
|
+
"https://:@8.8.8.8/document",
|
|
1226
|
+
"https://8.8.8.8/@user?q=@pass",
|
|
1227
|
+
]
|
|
1228
|
+
) {
|
|
1229
|
+
await t.step(
|
|
1230
|
+
`allows ${url}, allowPrivateAddress=${allowPrivateAddress}`,
|
|
1231
|
+
async () => {
|
|
1232
|
+
await withFetchStub(
|
|
1233
|
+
() => Response.json({ done: true }),
|
|
1234
|
+
async (fetched) => {
|
|
1235
|
+
const document = await loader(url);
|
|
1236
|
+
deepStrictEqual(document.document, { done: true });
|
|
1237
|
+
deepStrictEqual(fetched, [new URL(url).href]);
|
|
1238
|
+
},
|
|
1239
|
+
);
|
|
1240
|
+
},
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1104
1246
|
function isUnsupportedProtocolError(error: unknown, url: string): boolean {
|
|
1105
1247
|
return error instanceof UrlError &&
|
|
1106
1248
|
error.message === `Unsupported protocol: ${new URL(url).protocol}` &&
|
|
@@ -1182,3 +1324,610 @@ for (const url of unsupportedUrls) {
|
|
|
1182
1324
|
);
|
|
1183
1325
|
}
|
|
1184
1326
|
}
|
|
1327
|
+
|
|
1328
|
+
interface RecordedResponseInit {
|
|
1329
|
+
readonly status?: number;
|
|
1330
|
+
readonly headers?: Record<string, string>;
|
|
1331
|
+
readonly body?: string;
|
|
1332
|
+
/** Whether cancelling the body fails. */
|
|
1333
|
+
readonly failCancel?: boolean;
|
|
1334
|
+
/** Whether cancelling the body never settles, like a tee branch's. */
|
|
1335
|
+
readonly hangCancel?: boolean;
|
|
1336
|
+
/** The `Response.url` to report, like an IP address URL on Bun. */
|
|
1337
|
+
readonly responseUrl?: string;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Records the life cycle of fetch sessions and their responses as events:
|
|
1342
|
+
* `open <url>`, `fetch <url> #n`, `cancel #n`, `consumed #n`, and
|
|
1343
|
+
* `close <url>`.
|
|
1344
|
+
*/
|
|
1345
|
+
interface HopRecorder {
|
|
1346
|
+
readonly events: readonly string[];
|
|
1347
|
+
/** Makes a response whose body records being consumed or cancelled. */
|
|
1348
|
+
respond(url: string): Response;
|
|
1349
|
+
/** Records opening and closing the given session. */
|
|
1350
|
+
record(url: string, session: PublicFetchSession): PublicFetchSession;
|
|
1351
|
+
/** Opens a session that sends requests to {@link HopRecorder.respond}. */
|
|
1352
|
+
openHop(url: string): Promise<PublicFetchSession>;
|
|
1353
|
+
/**
|
|
1354
|
+
* Checks that every opened session is closed, and that every response is
|
|
1355
|
+
* consumed or cancelled before its session is closed.
|
|
1356
|
+
*/
|
|
1357
|
+
assertInvariants(): void;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function createHopRecorder(
|
|
1361
|
+
responder: (url: string) => RecordedResponseInit,
|
|
1362
|
+
): HopRecorder {
|
|
1363
|
+
const events: string[] = [];
|
|
1364
|
+
const states = new Map<Response, { readonly n: number; settled: boolean }>();
|
|
1365
|
+
const violations: string[] = [];
|
|
1366
|
+
let responses = 0;
|
|
1367
|
+
let opens = 0;
|
|
1368
|
+
let closes = 0;
|
|
1369
|
+
|
|
1370
|
+
function respond(url: string): Response {
|
|
1371
|
+
const n = ++responses;
|
|
1372
|
+
events.push(`fetch ${url} #${n}`);
|
|
1373
|
+
const {
|
|
1374
|
+
status = 200,
|
|
1375
|
+
headers,
|
|
1376
|
+
body = "",
|
|
1377
|
+
failCancel,
|
|
1378
|
+
hangCancel,
|
|
1379
|
+
responseUrl,
|
|
1380
|
+
} = responder(url);
|
|
1381
|
+
const state = { n, settled: false };
|
|
1382
|
+
let sent = false;
|
|
1383
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
1384
|
+
pull(controller) {
|
|
1385
|
+
if (!sent && body !== "") {
|
|
1386
|
+
sent = true;
|
|
1387
|
+
controller.enqueue(new TextEncoder().encode(body));
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
controller.close();
|
|
1391
|
+
state.settled = true;
|
|
1392
|
+
events.push(`consumed #${n}`);
|
|
1393
|
+
},
|
|
1394
|
+
cancel() {
|
|
1395
|
+
state.settled = true;
|
|
1396
|
+
events.push(`cancel #${n}`);
|
|
1397
|
+
if (failCancel) throw new Error(`Failed to cancel #${n}`);
|
|
1398
|
+
if (hangCancel) return new Promise<void>(() => {});
|
|
1399
|
+
},
|
|
1400
|
+
}, { highWaterMark: 0 });
|
|
1401
|
+
const response = new Response(stream, { status, headers });
|
|
1402
|
+
if (responseUrl != null) {
|
|
1403
|
+
Object.defineProperty(response, "url", { value: responseUrl });
|
|
1404
|
+
}
|
|
1405
|
+
states.set(response, state);
|
|
1406
|
+
return response;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function record(
|
|
1410
|
+
url: string,
|
|
1411
|
+
session: PublicFetchSession,
|
|
1412
|
+
): PublicFetchSession {
|
|
1413
|
+
opens++;
|
|
1414
|
+
events.push(`open ${url}`);
|
|
1415
|
+
const fetched: Response[] = [];
|
|
1416
|
+
let closed = false;
|
|
1417
|
+
return {
|
|
1418
|
+
get url() {
|
|
1419
|
+
return session.url;
|
|
1420
|
+
},
|
|
1421
|
+
async fetch(input, init) {
|
|
1422
|
+
const response = await session.fetch(input, init);
|
|
1423
|
+
fetched.push(response);
|
|
1424
|
+
return response;
|
|
1425
|
+
},
|
|
1426
|
+
async close() {
|
|
1427
|
+
if (closed) return;
|
|
1428
|
+
closed = true;
|
|
1429
|
+
for (const response of fetched) {
|
|
1430
|
+
const state = states.get(response);
|
|
1431
|
+
if (state != null && !state.settled) {
|
|
1432
|
+
violations.push(`#${state.n} is still open when closing ${url}`);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
closes++;
|
|
1436
|
+
events.push(`close ${url}`);
|
|
1437
|
+
await session.close();
|
|
1438
|
+
},
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function openHop(url: string): Promise<PublicFetchSession> {
|
|
1443
|
+
const origin = new URL(url).origin;
|
|
1444
|
+
return Promise.resolve(record(url, {
|
|
1445
|
+
url: new URL(url),
|
|
1446
|
+
fetch(input) {
|
|
1447
|
+
const target = input instanceof Request ? input.url : String(input);
|
|
1448
|
+
ok(new URL(target).origin === origin, `${target} is not on ${origin}`);
|
|
1449
|
+
return Promise.resolve(respond(target));
|
|
1450
|
+
},
|
|
1451
|
+
close: () => Promise.resolve(),
|
|
1452
|
+
}));
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
return {
|
|
1456
|
+
events,
|
|
1457
|
+
respond,
|
|
1458
|
+
record,
|
|
1459
|
+
openHop,
|
|
1460
|
+
assertInvariants() {
|
|
1461
|
+
deepStrictEqual(violations, []);
|
|
1462
|
+
deepStrictEqual(opens, closes);
|
|
1463
|
+
},
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
const lifecycleDocument = {
|
|
1468
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
1469
|
+
id: "https://b.example/object",
|
|
1470
|
+
type: "Object",
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
test("getDocumentLoader() closes a redirect hop before the next hop", async () => {
|
|
1474
|
+
const recorder = createHopRecorder((url) =>
|
|
1475
|
+
url === "https://a.example/start"
|
|
1476
|
+
? { status: 302, headers: { Location: "https://b.example/object" } }
|
|
1477
|
+
: { body: JSON.stringify(lifecycleDocument) }
|
|
1478
|
+
);
|
|
1479
|
+
const loader = createDocumentLoader({}, { openHop: recorder.openHop });
|
|
1480
|
+
deepStrictEqual(await loader("https://a.example/start"), {
|
|
1481
|
+
contextUrl: null,
|
|
1482
|
+
documentUrl: "https://b.example/object",
|
|
1483
|
+
document: lifecycleDocument,
|
|
1484
|
+
});
|
|
1485
|
+
deepStrictEqual(recorder.events, [
|
|
1486
|
+
"open https://a.example/start",
|
|
1487
|
+
"fetch https://a.example/start #1",
|
|
1488
|
+
"cancel #1",
|
|
1489
|
+
"close https://a.example/start",
|
|
1490
|
+
"open https://b.example/object",
|
|
1491
|
+
"fetch https://b.example/object #2",
|
|
1492
|
+
"consumed #2",
|
|
1493
|
+
"close https://b.example/object",
|
|
1494
|
+
]);
|
|
1495
|
+
recorder.assertInvariants();
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
test("getDocumentLoader() cancels the body before a Link header alternate", async () => {
|
|
1499
|
+
const recorder = createHopRecorder((url) =>
|
|
1500
|
+
url === "https://a.example/start"
|
|
1501
|
+
? {
|
|
1502
|
+
headers: {
|
|
1503
|
+
"Content-Type": "text/plain",
|
|
1504
|
+
Link: '<https://b.example/object>; rel="alternate"; ' +
|
|
1505
|
+
'type="application/activity+json"',
|
|
1506
|
+
},
|
|
1507
|
+
body: "not JSON",
|
|
1508
|
+
}
|
|
1509
|
+
: { body: JSON.stringify(lifecycleDocument) }
|
|
1510
|
+
);
|
|
1511
|
+
const loader = createDocumentLoader({}, { openHop: recorder.openHop });
|
|
1512
|
+
deepStrictEqual(
|
|
1513
|
+
(await loader("https://a.example/start")).documentUrl,
|
|
1514
|
+
"https://b.example/object",
|
|
1515
|
+
);
|
|
1516
|
+
deepStrictEqual(recorder.events, [
|
|
1517
|
+
"open https://a.example/start",
|
|
1518
|
+
"fetch https://a.example/start #1",
|
|
1519
|
+
"cancel #1",
|
|
1520
|
+
"close https://a.example/start",
|
|
1521
|
+
"open https://b.example/object",
|
|
1522
|
+
"fetch https://b.example/object #2",
|
|
1523
|
+
"consumed #2",
|
|
1524
|
+
"close https://b.example/object",
|
|
1525
|
+
]);
|
|
1526
|
+
recorder.assertInvariants();
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
test("getDocumentLoader() reads the body before an HTML alternate", async () => {
|
|
1530
|
+
const recorder = createHopRecorder((url) =>
|
|
1531
|
+
url === "https://a.example/start"
|
|
1532
|
+
? {
|
|
1533
|
+
headers: { "Content-Type": "text/html" },
|
|
1534
|
+
body: '<link rel="alternate" type="application/activity+json" ' +
|
|
1535
|
+
'href="https://b.example/object">',
|
|
1536
|
+
}
|
|
1537
|
+
: { body: JSON.stringify(lifecycleDocument) }
|
|
1538
|
+
);
|
|
1539
|
+
const loader = createDocumentLoader({}, { openHop: recorder.openHop });
|
|
1540
|
+
deepStrictEqual(
|
|
1541
|
+
(await loader("https://a.example/start")).documentUrl,
|
|
1542
|
+
"https://b.example/object",
|
|
1543
|
+
);
|
|
1544
|
+
deepStrictEqual(recorder.events, [
|
|
1545
|
+
"open https://a.example/start",
|
|
1546
|
+
"fetch https://a.example/start #1",
|
|
1547
|
+
"consumed #1",
|
|
1548
|
+
"close https://a.example/start",
|
|
1549
|
+
"open https://b.example/object",
|
|
1550
|
+
"fetch https://b.example/object #2",
|
|
1551
|
+
"consumed #2",
|
|
1552
|
+
"close https://b.example/object",
|
|
1553
|
+
]);
|
|
1554
|
+
recorder.assertInvariants();
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Fails instead of blocking the test if the given promise does not settle
|
|
1559
|
+
* within the given time.
|
|
1560
|
+
*/
|
|
1561
|
+
async function settlesWithin<T>(
|
|
1562
|
+
promise: Promise<T>,
|
|
1563
|
+
milliseconds = 1000,
|
|
1564
|
+
): Promise<T> {
|
|
1565
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1566
|
+
try {
|
|
1567
|
+
return await Promise.race([
|
|
1568
|
+
promise,
|
|
1569
|
+
new Promise<never>((_, reject) => {
|
|
1570
|
+
timer = setTimeout(
|
|
1571
|
+
() => reject(new Error(`Did not settle in ${milliseconds} ms.`)),
|
|
1572
|
+
milliseconds,
|
|
1573
|
+
);
|
|
1574
|
+
}),
|
|
1575
|
+
]);
|
|
1576
|
+
} finally {
|
|
1577
|
+
clearTimeout(timer);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* Runs the given function while capturing the document loader's logs, and
|
|
1583
|
+
* waits until the rejections of body cancellations started without being
|
|
1584
|
+
* awaited have been logged.
|
|
1585
|
+
*/
|
|
1586
|
+
async function captureDocumentLoaderLogs<T>(
|
|
1587
|
+
fn: () => Promise<T>,
|
|
1588
|
+
): Promise<{ result: T; records: LogRecord[] }> {
|
|
1589
|
+
const records: LogRecord[] = [];
|
|
1590
|
+
await configure({
|
|
1591
|
+
sinks: { buffer: (record) => records.push(record) },
|
|
1592
|
+
loggers: [
|
|
1593
|
+
{
|
|
1594
|
+
category: ["fedify", "runtime", "docloader"],
|
|
1595
|
+
sinks: ["buffer"],
|
|
1596
|
+
lowestLevel: "debug",
|
|
1597
|
+
},
|
|
1598
|
+
{ category: ["logtape", "meta"], sinks: [] },
|
|
1599
|
+
],
|
|
1600
|
+
reset: true,
|
|
1601
|
+
});
|
|
1602
|
+
try {
|
|
1603
|
+
const result = await fn();
|
|
1604
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1605
|
+
return { result, records };
|
|
1606
|
+
} finally {
|
|
1607
|
+
await reset();
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function cancelFailureLogs(
|
|
1612
|
+
records: readonly LogRecord[],
|
|
1613
|
+
): [string, unknown, string][] {
|
|
1614
|
+
return records
|
|
1615
|
+
.filter((record) =>
|
|
1616
|
+
record.rawMessage ===
|
|
1617
|
+
"Failed to cancel the response body from {url}: {error}"
|
|
1618
|
+
)
|
|
1619
|
+
.map((record) => [
|
|
1620
|
+
record.level,
|
|
1621
|
+
record.properties.url,
|
|
1622
|
+
String(record.properties.error),
|
|
1623
|
+
]);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function linkHeaderAlternate(next: string): RecordedResponseInit {
|
|
1627
|
+
return {
|
|
1628
|
+
headers: {
|
|
1629
|
+
"Content-Type": "text/plain",
|
|
1630
|
+
Link: `<${next}>; rel="alternate"; type="application/activity+json"`,
|
|
1631
|
+
},
|
|
1632
|
+
body: "not JSON",
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
test("getDocumentLoader() continues if an intermediate body fails to cancel", async (t) => {
|
|
1637
|
+
for (const kind of ["redirect", "Link header alternate"] as const) {
|
|
1638
|
+
await t.step(kind, async () => {
|
|
1639
|
+
const recorder = createHopRecorder((url) =>
|
|
1640
|
+
url === "https://a.example/start"
|
|
1641
|
+
? {
|
|
1642
|
+
...(kind === "redirect"
|
|
1643
|
+
? {
|
|
1644
|
+
status: 302,
|
|
1645
|
+
headers: { Location: "https://b.example/object" },
|
|
1646
|
+
}
|
|
1647
|
+
: linkHeaderAlternate("https://b.example/object")),
|
|
1648
|
+
failCancel: true,
|
|
1649
|
+
}
|
|
1650
|
+
: { body: JSON.stringify(lifecycleDocument) }
|
|
1651
|
+
);
|
|
1652
|
+
const loader = createDocumentLoader({}, { openHop: recorder.openHop });
|
|
1653
|
+
const { result, records } = await captureDocumentLoaderLogs(() =>
|
|
1654
|
+
loader("https://a.example/start")
|
|
1655
|
+
);
|
|
1656
|
+
deepStrictEqual(result, {
|
|
1657
|
+
contextUrl: null,
|
|
1658
|
+
documentUrl: "https://b.example/object",
|
|
1659
|
+
document: lifecycleDocument,
|
|
1660
|
+
});
|
|
1661
|
+
deepStrictEqual(recorder.events, [
|
|
1662
|
+
"open https://a.example/start",
|
|
1663
|
+
"fetch https://a.example/start #1",
|
|
1664
|
+
"cancel #1",
|
|
1665
|
+
"close https://a.example/start",
|
|
1666
|
+
"open https://b.example/object",
|
|
1667
|
+
"fetch https://b.example/object #2",
|
|
1668
|
+
"consumed #2",
|
|
1669
|
+
"close https://b.example/object",
|
|
1670
|
+
]);
|
|
1671
|
+
recorder.assertInvariants();
|
|
1672
|
+
// The failure is only logged:
|
|
1673
|
+
deepStrictEqual(cancelFailureLogs(records), [[
|
|
1674
|
+
"debug",
|
|
1675
|
+
"https://a.example/start",
|
|
1676
|
+
"Error: Failed to cancel #1",
|
|
1677
|
+
]]);
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
test("getDocumentLoader() does not wait for intermediate bodies to be cancelled", async (t) => {
|
|
1683
|
+
for (const kind of ["redirect", "Link header alternate"] as const) {
|
|
1684
|
+
await t.step(kind, async () => {
|
|
1685
|
+
const recorder = createHopRecorder((url) =>
|
|
1686
|
+
url === "https://a.example/start"
|
|
1687
|
+
? {
|
|
1688
|
+
...(kind === "redirect"
|
|
1689
|
+
? {
|
|
1690
|
+
status: 302,
|
|
1691
|
+
headers: { Location: "https://b.example/object" },
|
|
1692
|
+
}
|
|
1693
|
+
: linkHeaderAlternate("https://b.example/object")),
|
|
1694
|
+
hangCancel: true,
|
|
1695
|
+
}
|
|
1696
|
+
: { body: JSON.stringify(lifecycleDocument) }
|
|
1697
|
+
);
|
|
1698
|
+
const loader = createDocumentLoader({}, { openHop: recorder.openHop });
|
|
1699
|
+
deepStrictEqual(
|
|
1700
|
+
(await settlesWithin(loader("https://a.example/start"))).documentUrl,
|
|
1701
|
+
"https://b.example/object",
|
|
1702
|
+
);
|
|
1703
|
+
deepStrictEqual(recorder.events, [
|
|
1704
|
+
"open https://a.example/start",
|
|
1705
|
+
"fetch https://a.example/start #1",
|
|
1706
|
+
"cancel #1",
|
|
1707
|
+
"close https://a.example/start",
|
|
1708
|
+
"open https://b.example/object",
|
|
1709
|
+
"fetch https://b.example/object #2",
|
|
1710
|
+
"consumed #2",
|
|
1711
|
+
"close https://b.example/object",
|
|
1712
|
+
]);
|
|
1713
|
+
recorder.assertInvariants();
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
await t.step("public session with a never-settling cancel", async () => {
|
|
1718
|
+
// A real session must not wait for a body it cancels on close() either:
|
|
1719
|
+
let cancelled = 0;
|
|
1720
|
+
const pinner: ConnectionPinner = {
|
|
1721
|
+
supports: () => true,
|
|
1722
|
+
fetch: () =>
|
|
1723
|
+
Promise.resolve(
|
|
1724
|
+
new Response(
|
|
1725
|
+
new ReadableStream({
|
|
1726
|
+
cancel() {
|
|
1727
|
+
cancelled++;
|
|
1728
|
+
return new Promise<void>(() => {});
|
|
1729
|
+
},
|
|
1730
|
+
}),
|
|
1731
|
+
{ status: 404 },
|
|
1732
|
+
),
|
|
1733
|
+
),
|
|
1734
|
+
release() {},
|
|
1735
|
+
};
|
|
1736
|
+
const publicFetch = createPublicFetch({
|
|
1737
|
+
pinner: () => Promise.resolve(pinner),
|
|
1738
|
+
lookup: () => Promise.reject(new Error("Unexpected default lookup")),
|
|
1739
|
+
});
|
|
1740
|
+
const loader = createDocumentLoader({}, {
|
|
1741
|
+
openHop: (url) =>
|
|
1742
|
+
publicFetch.openPublicFetch(url, { lookup: lookupTestAddresses }),
|
|
1743
|
+
});
|
|
1744
|
+
await settlesWithin(
|
|
1745
|
+
rejects(loader("https://a.example/missing"), (error) => {
|
|
1746
|
+
ok(error instanceof FetchError);
|
|
1747
|
+
// The error retains a response clone for the caller. Cancel that tee
|
|
1748
|
+
// branch too, so the underlying stream's cancellation can start.
|
|
1749
|
+
error.response?.body?.cancel().catch(() => {});
|
|
1750
|
+
return true;
|
|
1751
|
+
}),
|
|
1752
|
+
);
|
|
1753
|
+
deepStrictEqual(cancelled, 1);
|
|
1754
|
+
});
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
test("getDocumentLoader() completes with tee'd fetch-mock responses", async (t) => {
|
|
1758
|
+
// fetch-mock returns a clone (a tee branch) of a Response instance that a
|
|
1759
|
+
// route returns, and the cancellation of a tee branch settles only after
|
|
1760
|
+
// the other branch is cancelled too. That never happens for a string body
|
|
1761
|
+
// on Node.js, and for a stream body on any runtime:
|
|
1762
|
+
const url = "https://example.com/teed";
|
|
1763
|
+
function stream(text: string): ReadableStream<Uint8Array> {
|
|
1764
|
+
return new ReadableStream({
|
|
1765
|
+
start(controller) {
|
|
1766
|
+
controller.enqueue(new TextEncoder().encode(text));
|
|
1767
|
+
controller.close();
|
|
1768
|
+
},
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
const loaders = {
|
|
1772
|
+
unpinned: createDocumentLoader({}, { openHop: openUnpinnedHop }),
|
|
1773
|
+
"private addresses allowed": getDocumentLoader({
|
|
1774
|
+
allowPrivateAddress: true,
|
|
1775
|
+
}),
|
|
1776
|
+
};
|
|
1777
|
+
for (const [name, loader] of Object.entries(loaders)) {
|
|
1778
|
+
for (const body of ["string", "stream"] as const) {
|
|
1779
|
+
await t.step(`${name}: ${body} bodies`, async () => {
|
|
1780
|
+
const make = (text: string) => body === "string" ? text : stream(text);
|
|
1781
|
+
fetchMock.mockGlobal();
|
|
1782
|
+
try {
|
|
1783
|
+
fetchMock.get(
|
|
1784
|
+
`${url}/object`,
|
|
1785
|
+
() =>
|
|
1786
|
+
new Response(make(JSON.stringify(lifecycleDocument)), {
|
|
1787
|
+
headers: { "Content-Type": "application/activity+json" },
|
|
1788
|
+
}),
|
|
1789
|
+
);
|
|
1790
|
+
fetchMock.get(
|
|
1791
|
+
`${url}/redirect`,
|
|
1792
|
+
() =>
|
|
1793
|
+
new Response(make("redirect"), {
|
|
1794
|
+
status: 302,
|
|
1795
|
+
headers: { Location: `${url}/object` },
|
|
1796
|
+
}),
|
|
1797
|
+
);
|
|
1798
|
+
fetchMock.get(
|
|
1799
|
+
`${url}/alternate`,
|
|
1800
|
+
() =>
|
|
1801
|
+
new Response(make("not JSON"), {
|
|
1802
|
+
headers: {
|
|
1803
|
+
"Content-Type": "text/plain",
|
|
1804
|
+
Link: `<${url}/object>; rel="alternate"; ` +
|
|
1805
|
+
'type="application/activity+json"',
|
|
1806
|
+
},
|
|
1807
|
+
}),
|
|
1808
|
+
);
|
|
1809
|
+
fetchMock.get(
|
|
1810
|
+
`${url}/missing`,
|
|
1811
|
+
() => new Response(make("not found"), { status: 404 }),
|
|
1812
|
+
);
|
|
1813
|
+
for (const path of ["redirect", "alternate"]) {
|
|
1814
|
+
deepStrictEqual(
|
|
1815
|
+
(await settlesWithin(loader(`${url}/${path}`))).document,
|
|
1816
|
+
lifecycleDocument,
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
await settlesWithin(rejects(loader(`${url}/missing`), FetchError));
|
|
1820
|
+
deepStrictEqual(
|
|
1821
|
+
fetchMock.callHistory.calls().map((call) => call.url),
|
|
1822
|
+
[
|
|
1823
|
+
`${url}/redirect`,
|
|
1824
|
+
`${url}/object`,
|
|
1825
|
+
`${url}/alternate`,
|
|
1826
|
+
`${url}/object`,
|
|
1827
|
+
`${url}/missing`,
|
|
1828
|
+
],
|
|
1829
|
+
);
|
|
1830
|
+
} finally {
|
|
1831
|
+
fetchMock.hardReset();
|
|
1832
|
+
}
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
test("getDocumentLoader() resolves each hop once", async () => {
|
|
1839
|
+
const lookups: string[] = [];
|
|
1840
|
+
const recorder = createHopRecorder((url) => {
|
|
1841
|
+
// Pinned responses report the IP address URL on Bun; the request URL must
|
|
1842
|
+
// still be used as the document URL and as the base of Location:
|
|
1843
|
+
const responseUrl = `https://${PUBLIC_ADDRESS.address}${
|
|
1844
|
+
new URL(url).pathname
|
|
1845
|
+
}`;
|
|
1846
|
+
if (url === "https://a.example/start") {
|
|
1847
|
+
return {
|
|
1848
|
+
status: 302,
|
|
1849
|
+
headers: { Location: "https://b.example/next" },
|
|
1850
|
+
responseUrl,
|
|
1851
|
+
};
|
|
1852
|
+
} else if (url === "https://b.example/next") {
|
|
1853
|
+
return { status: 301, headers: { Location: "/object" }, responseUrl };
|
|
1854
|
+
}
|
|
1855
|
+
return { body: JSON.stringify(lifecycleDocument), responseUrl };
|
|
1856
|
+
});
|
|
1857
|
+
const pinner: ConnectionPinner = {
|
|
1858
|
+
supports: () => true,
|
|
1859
|
+
fetch: (_grant, request) => Promise.resolve(recorder.respond(request.url)),
|
|
1860
|
+
release() {},
|
|
1861
|
+
};
|
|
1862
|
+
const publicFetch = createPublicFetch({
|
|
1863
|
+
pinner: () => Promise.resolve(pinner),
|
|
1864
|
+
lookup: () => Promise.reject(new Error("Unexpected default lookup")),
|
|
1865
|
+
});
|
|
1866
|
+
const loader = createDocumentLoader({}, {
|
|
1867
|
+
openHop: async (url) =>
|
|
1868
|
+
recorder.record(
|
|
1869
|
+
url,
|
|
1870
|
+
await publicFetch.openPublicFetch(url, {
|
|
1871
|
+
lookup(hostname) {
|
|
1872
|
+
lookups.push(hostname);
|
|
1873
|
+
return Promise.resolve([PUBLIC_ADDRESS]);
|
|
1874
|
+
},
|
|
1875
|
+
}),
|
|
1876
|
+
),
|
|
1877
|
+
});
|
|
1878
|
+
deepStrictEqual(await loader("https://a.example/start"), {
|
|
1879
|
+
contextUrl: null,
|
|
1880
|
+
documentUrl: "https://b.example/object",
|
|
1881
|
+
document: lifecycleDocument,
|
|
1882
|
+
});
|
|
1883
|
+
deepStrictEqual(lookups, ["a.example", "b.example", "b.example"]);
|
|
1884
|
+
deepStrictEqual(recorder.events, [
|
|
1885
|
+
"open https://a.example/start",
|
|
1886
|
+
"fetch https://a.example/start #1",
|
|
1887
|
+
"cancel #1",
|
|
1888
|
+
"close https://a.example/start",
|
|
1889
|
+
"open https://b.example/next",
|
|
1890
|
+
"fetch https://b.example/next #2",
|
|
1891
|
+
"cancel #2",
|
|
1892
|
+
"close https://b.example/next",
|
|
1893
|
+
"open https://b.example/object",
|
|
1894
|
+
"fetch https://b.example/object #3",
|
|
1895
|
+
"consumed #3",
|
|
1896
|
+
"close https://b.example/object",
|
|
1897
|
+
]);
|
|
1898
|
+
recorder.assertInvariants();
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
test("getRemoteDocument() cancels the body before loading a Link header alternate", async () => {
|
|
1902
|
+
const recorder = createHopRecorder(() => ({
|
|
1903
|
+
headers: {
|
|
1904
|
+
"Content-Type": "text/html",
|
|
1905
|
+
Link: '<https://b.example/object>; rel="alternate"; ' +
|
|
1906
|
+
'type="application/activity+json"',
|
|
1907
|
+
},
|
|
1908
|
+
body: "<html></html>",
|
|
1909
|
+
}));
|
|
1910
|
+
const response = recorder.respond("https://a.example/start");
|
|
1911
|
+
const placeholder: RemoteDocument = {
|
|
1912
|
+
contextUrl: null,
|
|
1913
|
+
document: {},
|
|
1914
|
+
documentUrl: "https://b.example/object",
|
|
1915
|
+
};
|
|
1916
|
+
const alternates: string[] = [];
|
|
1917
|
+
const result = await getRemoteDocument(
|
|
1918
|
+
"https://a.example/start",
|
|
1919
|
+
response,
|
|
1920
|
+
(url) => {
|
|
1921
|
+
alternates.push(url);
|
|
1922
|
+
deepStrictEqual(recorder.events.at(-1), "cancel #1");
|
|
1923
|
+
ok(response.bodyUsed);
|
|
1924
|
+
return Promise.resolve(placeholder);
|
|
1925
|
+
},
|
|
1926
|
+
);
|
|
1927
|
+
ok(result === placeholder);
|
|
1928
|
+
deepStrictEqual(alternates, ["https://b.example/object"]);
|
|
1929
|
+
deepStrictEqual(recorder.events, [
|
|
1930
|
+
"fetch https://a.example/start #1",
|
|
1931
|
+
"cancel #1",
|
|
1932
|
+
]);
|
|
1933
|
+
});
|