@fedify/fedify 2.4.5-dev.2462 → 2.4.6-dev.2628

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +3 -0
  2. package/dist/{builder-BBUGd9wr.mjs → builder-kj5boG2l.mjs} +2 -2
  3. package/dist/{client-D-XP_Ccb.d.cts → client-3InDZbba.d.cts} +0 -1
  4. package/dist/{client-D-XP_Ccb.d.ts → client-3InDZbba.d.ts} +0 -1
  5. package/dist/{client-CEnNz8O7.mjs → client-5SIFzW3q.mjs} +113 -64
  6. package/dist/compat/mod.d.cts +1 -1
  7. package/dist/compat/mod.d.ts +1 -1
  8. package/dist/compat/transformers.test.mjs +1 -1
  9. package/dist/{compound-proof-CariICma.mjs → compound-proof-BoNAYSHt.mjs} +2 -2
  10. package/dist/{context-7NyiqIcg.d.ts → context-CVOOd9pD.d.ts} +28 -2
  11. package/dist/{context-tsYPJ2Ew.d.cts → context-D0gFIgev.d.cts} +28 -2
  12. package/dist/{deno-BO7-a3hz.mjs → deno-ksnVeCA7.mjs} +1 -1
  13. package/dist/docloader-BhVNi06k.mjs +136 -0
  14. package/dist/federation/builder.test.mjs +1 -1
  15. package/dist/federation/compound-fanout.test.mjs +6 -3
  16. package/dist/federation/handler-compound-proof.test.mjs +3 -3
  17. package/dist/federation/handler.test.mjs +4 -4
  18. package/dist/federation/hashlink-media.test.mjs +2 -2
  19. package/dist/federation/idempotency.test.mjs +2 -2
  20. package/dist/federation/inbox-report.test.mjs +5 -5
  21. package/dist/federation/inbox.test.mjs +1 -1
  22. package/dist/federation/metrics.test.mjs +1 -1
  23. package/dist/federation/middleware.test.mjs +465 -32
  24. package/dist/federation/mod.cjs +1 -1
  25. package/dist/federation/mod.d.cts +3 -3
  26. package/dist/federation/mod.d.ts +3 -3
  27. package/dist/federation/mod.js +1 -1
  28. package/dist/federation/outgoing-proof.test.mjs +9 -6
  29. package/dist/federation/portable-actor.test.mjs +3 -3
  30. package/dist/federation/portable-chain.test.mjs +3 -3
  31. package/dist/federation/portable-collection.test.mjs +4 -4
  32. package/dist/federation/portable-delivery.test.mjs +2 -2
  33. package/dist/federation/portable-gateway-keys.test.mjs +11 -5
  34. package/dist/federation/portable-inbox.test.mjs +4 -4
  35. package/dist/federation/portable-parse-uri.test.mjs +1 -1
  36. package/dist/federation/portable-route-activity.test.mjs +2 -2
  37. package/dist/federation/portable.test.mjs +3 -3
  38. package/dist/federation/send.test.mjs +498 -88
  39. package/dist/federation/tasks/enqueue.test.mjs +1 -1
  40. package/dist/federation/tasks/tasks.test.mjs +2 -2
  41. package/dist/federation/temporal.test.mjs +1 -1
  42. package/dist/federation/webfinger.test.mjs +1 -1
  43. package/dist/{federation-Dd9IwOEi.cjs → federation-BtPz9sr3.cjs} +240 -102
  44. package/dist/{http-D7L2pqLT.mjs → http-BSARoDPh.mjs} +204 -106
  45. package/dist/{http-DLf8QYD6.cjs → http-CeVcKQMZ.cjs} +179 -110
  46. package/dist/{http-BVrOR6MG.js → http-rH230xCD.js} +178 -109
  47. package/dist/{inbox-DtEKOr3W.mjs → inbox-BdZenpZQ.mjs} +2 -2
  48. package/dist/{key-DYxuyd-s.d.cts → key-CyCcut3h.d.ts} +1 -2
  49. package/dist/{key-C1nv7_jD.d.ts → key-VEL1QqHF.d.cts} +1 -2
  50. package/dist/{kv-cache-Dkzuqs8B.cjs → kv-cache-BpGIONmd.cjs} +72 -27
  51. package/dist/{kv-cache-DoZoRS9p.js → kv-cache-CNfNI_95.js} +73 -28
  52. package/dist/{kv-cache-m3RIr811.mjs → kv-cache-Cxn7pNMx.mjs} +1 -1
  53. package/dist/{ld-PJLGA2MJ.mjs → ld-DJZrjNuN.mjs} +2 -2
  54. package/dist/{metrics-CGR9cI-Y.mjs → metrics-DtxsYIkN.mjs} +1 -1
  55. package/dist/{middleware-BxhBd7pw.js → middleware-DDvck2q9.js} +241 -103
  56. package/dist/{middleware-ByanhTvb.mjs → middleware-DblGj642.mjs} +82 -42
  57. package/dist/{middleware-DPI-Jp9w.mjs → middleware-DyEjt33n.mjs} +1 -1
  58. package/dist/{mod-DEZXwV6J.d.ts → mod-B1Qt5cQQ.d.ts} +2 -9
  59. package/dist/{mod-Xj5P4s4d.d.ts → mod-B5COx5Wd.d.ts} +1 -1
  60. package/dist/{mod-CavhAgLw.d.cts → mod-BTxJG7ZH.d.cts} +1 -1
  61. package/dist/{mod-CtpF2BU5.d.ts → mod-CpyjFA9s.d.ts} +1 -1
  62. package/dist/{mod-BojP2Bw3.d.cts → mod-makPMVMg.d.cts} +1 -1
  63. package/dist/{mod-Dw-nPxQh.d.cts → mod-ss0wBUH1.d.cts} +2 -9
  64. package/dist/mod.cjs +5 -5
  65. package/dist/mod.d.cts +6 -6
  66. package/dist/mod.d.ts +6 -6
  67. package/dist/mod.js +5 -5
  68. package/dist/nodeinfo/client.test.mjs +337 -19
  69. package/dist/nodeinfo/handler.test.mjs +1 -1
  70. package/dist/nodeinfo/mod.cjs +1 -1
  71. package/dist/nodeinfo/mod.d.cts +1 -1
  72. package/dist/nodeinfo/mod.d.ts +1 -1
  73. package/dist/nodeinfo/mod.js +1 -1
  74. package/dist/otel/exporter.test.mjs +292 -30
  75. package/dist/otel/mod.cjs +36 -30
  76. package/dist/otel/mod.d.cts +13 -0
  77. package/dist/otel/mod.d.ts +13 -0
  78. package/dist/otel/mod.js +36 -30
  79. package/dist/{owner-B5c276hH.mjs → owner-2vfqhUJw.mjs} +3 -3
  80. package/dist/{portable-collection-BMZY9qT-.js → portable-collection-B7AQ_dW7.js} +1 -1
  81. package/dist/{portable-collection-CR5uwjws.cjs → portable-collection-Cs--KD5A.cjs} +1 -1
  82. package/dist/{portable-collection-C88tBiy0.mjs → portable-collection-DKJwGO8S.mjs} +2 -2
  83. package/dist/{portable-key-DxTXUpCK.mjs → portable-key-DXGepvtD.mjs} +1 -1
  84. package/dist/{proof-DNu-qP0i.mjs → proof-DW0FK6Cp.mjs} +3 -3
  85. package/dist/{send-CdCifQs4.mjs → send-CqsocpvF.mjs} +174 -76
  86. package/dist/sig/compound-proof-discovery.test.mjs +1 -1
  87. package/dist/sig/compound-proof-producer.test.mjs +1 -1
  88. package/dist/sig/compound-proof-verification.test.mjs +2 -2
  89. package/dist/sig/compound-proof.test.mjs +2 -2
  90. package/dist/sig/http.test.mjs +553 -2
  91. package/dist/sig/key.test.mjs +1 -1
  92. package/dist/sig/ld.test.mjs +1 -1
  93. package/dist/sig/mitra-receive-interop.test.mjs +4 -4
  94. package/dist/sig/mitra-send-interop.test.mjs +3 -3
  95. package/dist/sig/mod.cjs +2 -2
  96. package/dist/sig/mod.d.cts +2 -2
  97. package/dist/sig/mod.d.ts +2 -2
  98. package/dist/sig/mod.js +2 -2
  99. package/dist/sig/owner.test.mjs +1 -1
  100. package/dist/sig/portable-collection.test.mjs +1 -1
  101. package/dist/sig/portable-interop.test.mjs +3 -3
  102. package/dist/sig/portable-key.test.mjs +5 -5
  103. package/dist/sig/proof.test.mjs +4 -4
  104. package/dist/sig/tootik-request-interop.test.mjs +4 -4
  105. package/dist/sig/tootik-send-interop.test.mjs +3 -3
  106. package/dist/sig/verification.test.mjs +3 -3
  107. package/dist/{temporal-BuMu2bgP.mjs → temporal-Dm2E1-vs.mjs} +1 -1
  108. package/dist/testing/kv.test.mjs +1 -1
  109. package/dist/testing/mod.d.mts +26 -0
  110. package/dist/{types-_S2_MeLF.js → types-DUjZb4i6.js} +112 -63
  111. package/dist/{types-poIao40f.cjs → types-hKCCjii0.cjs} +111 -62
  112. package/dist/utils/docloader.test.mjs +344 -36
  113. package/dist/utils/kv-cache.test.mjs +1 -1
  114. package/dist/utils/mod.cjs +1 -1
  115. package/dist/utils/mod.d.cts +1 -1
  116. package/dist/utils/mod.d.ts +1 -1
  117. package/dist/utils/mod.js +1 -1
  118. package/package.json +7 -7
  119. package/dist/docloader-DQNRDYDb.mjs +0 -91
@@ -5,15 +5,30 @@ import { t as esm_default } from "../esm-BHXXx4Wx.mjs";
5
5
  import { t as assertEquals } from "../assert_equals-Z6IcEbWU.mjs";
6
6
  import "../std__assert-DvMDRPes.mjs";
7
7
  import { t as assertRejects } from "../assert_rejects-CTptdYck.mjs";
8
- import { f as verifyRequest } from "../http-D7L2pqLT.mjs";
8
+ import { p as verifyRequest } from "../http-BSARoDPh.mjs";
9
9
  import { i as rsaPrivateKey2 } from "../keys-DIJNHW-Y.mjs";
10
- import { t as getAuthenticatedDocumentLoader } from "../docloader-DQNRDYDb.mjs";
10
+ import { n as getAuthenticatedDocumentLoader, t as createAuthenticatedDocumentLoader } from "../docloader-BhVNi06k.mjs";
11
11
  import { configure, reset } from "@logtape/logtape";
12
12
  import { FetchError, UrlError } from "@fedify/vocab-runtime";
13
13
  import { mockDocumentLoader, test } from "@fedify/fixture";
14
- import dns from "node:dns/promises";
14
+ import { openDirectFetch, openPublicFetch } from "@fedify/vocab-runtime/internal";
15
15
  import { deepStrictEqual, ok, rejects } from "node:assert/strict";
16
16
  //#region src/utils/docloader.test.ts
17
+ const identity = {
18
+ keyId: new URL("https://example.com/key2"),
19
+ privateKey: rsaPrivateKey2
20
+ };
21
+ const directDeps = { openHop: openDirectFetch };
22
+ function publicPolicyHop(lookup) {
23
+ return async (url) => {
24
+ await (await openPublicFetch(url, { lookup })).close();
25
+ return openDirectFetch(url);
26
+ };
27
+ }
28
+ const publicLookup = () => Promise.resolve([{
29
+ address: "8.8.8.8",
30
+ family: 4
31
+ }]);
17
32
  test("getAuthenticatedDocumentLoader()", async (t) => {
18
33
  esm_default.spyGlobal();
19
34
  esm_default.get("begin:https://example.com/object", async (cl) => {
@@ -25,10 +40,7 @@ test("getAuthenticatedDocumentLoader()", async (t) => {
25
40
  return new Response(JSON.stringify(v != null), { headers: { "Content-Type": "application/json" } });
26
41
  });
27
42
  await t.step("test", async () => {
28
- assertEquals(await (await getAuthenticatedDocumentLoader({
29
- keyId: new URL("https://example.com/key2"),
30
- privateKey: rsaPrivateKey2
31
- }))("https://example.com/object"), {
43
+ assertEquals(await createAuthenticatedDocumentLoader(identity, {}, directDeps)("https://example.com/object"), {
32
44
  contextUrl: null,
33
45
  documentUrl: "https://example.com/object",
34
46
  document: true
@@ -57,10 +69,10 @@ test("getAuthenticatedDocumentLoader()", async (t) => {
57
69
  const index = Number(cl.url.split("/").at(-1));
58
70
  return Response.redirect(`https://example.com/custom-too-many-redirects/${index + 1}`, 302);
59
71
  });
60
- const loader = getAuthenticatedDocumentLoader({
72
+ const loader = createAuthenticatedDocumentLoader({
61
73
  keyId: new URL("https://example.com/key2"),
62
74
  privateKey: rsaPrivateKey2
63
- }, { maxRedirection: 1 });
75
+ }, { maxRedirection: 1 }, directDeps);
64
76
  await assertRejects(() => loader("https://example.com/custom-too-many-redirects/0"), Error, "Too many redirections");
65
77
  assertEquals(requestCount, 2);
66
78
  esm_default.hardReset();
@@ -74,21 +86,16 @@ test("getAuthenticatedDocumentLoader() validates redirects", async (t) => {
74
86
  privateRequestCount++;
75
87
  return Response.json({ private: true });
76
88
  });
89
+ const policyDeps = { openHop: publicPolicyHop(publicLookup) };
77
90
  await t.step("deny public-to-private redirects", async () => {
78
- const loader = getAuthenticatedDocumentLoader({
79
- keyId: new URL("https://example.com/key2"),
80
- privateKey: rsaPrivateKey2
81
- });
91
+ const loader = createAuthenticatedDocumentLoader(identity, {}, policyDeps);
82
92
  await assertRejects(() => loader("https://example.com/redirect-to-private"), UrlError);
83
93
  assertEquals(privateRequestCount, 0);
84
94
  });
85
95
  esm_default.get("https://example.com/redirect-to-public", () => Response.redirect("https://www.example.com/document", 302));
86
96
  esm_default.get("https://www.example.com/document", () => Response.json({ public: true }));
87
97
  await t.step("allow public-to-public redirects", async () => {
88
- assertEquals((await getAuthenticatedDocumentLoader({
89
- keyId: new URL("https://example.com/key2"),
90
- privateKey: rsaPrivateKey2
91
- })("https://example.com/redirect-to-public")).document, { public: true });
98
+ assertEquals((await createAuthenticatedDocumentLoader(identity, {}, policyDeps)("https://example.com/redirect-to-public")).document, { public: true });
92
99
  });
93
100
  await t.step("allow private redirects when explicitly enabled", async () => {
94
101
  assertEquals((await getAuthenticatedDocumentLoader({
@@ -118,10 +125,7 @@ test("getAuthenticatedDocumentLoader() cancellation", async (t) => {
118
125
  });
119
126
  }, 1e3);
120
127
  }));
121
- const loader = getAuthenticatedDocumentLoader({
122
- keyId: new URL("https://example.com/key2"),
123
- privateKey: rsaPrivateKey2
124
- });
128
+ const loader = createAuthenticatedDocumentLoader(identity, {}, directDeps);
125
129
  const controller = new AbortController();
126
130
  const promise = loader("https://example.com/slow-object", { signal: controller.signal });
127
131
  controller.abort();
@@ -132,10 +136,7 @@ test("getAuthenticatedDocumentLoader() cancellation", async (t) => {
132
136
  }
133
137
  });
134
138
  await t.step("immediate cancellation", async () => {
135
- const loader = getAuthenticatedDocumentLoader({
136
- keyId: new URL("https://example.com/key2"),
137
- privateKey: rsaPrivateKey2
138
- });
139
+ const loader = createAuthenticatedDocumentLoader(identity, {}, directDeps);
139
140
  const controller = new AbortController();
140
141
  controller.abort();
141
142
  await assertRejects(() => loader("https://example.com/object", { signal: controller.signal }), Error);
@@ -206,10 +207,10 @@ test("authenticated document loader suppresses HTTP failure logs", async () => {
206
207
  });
207
208
  test("authenticated document loader suppresses private URL logs", async () => {
208
209
  const records = [];
209
- const loader = getAuthenticatedDocumentLoader({
210
+ const loader = createAuthenticatedDocumentLoader({
210
211
  keyId: new URL("https://example.com/key2"),
211
212
  privateKey: rsaPrivateKey2
212
- });
213
+ }, {}, { openHop: publicPolicyHop(publicLookup) });
213
214
  await reset();
214
215
  await configure({
215
216
  sinks: { capture: (record) => {
@@ -246,21 +247,17 @@ test("authenticated document loader suppresses private URL logs", async () => {
246
247
  await reset();
247
248
  }
248
249
  });
249
- test("getAuthenticatedDocumentLoader() logs DNS failures as such", { ignore: "Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted" }, async (t) => {
250
- const loader = getAuthenticatedDocumentLoader({
251
- keyId: new URL("https://example.com/key2"),
252
- privateKey: rsaPrivateKey2
253
- });
250
+ test("getAuthenticatedDocumentLoader() logs DNS failures as such", async (t) => {
254
251
  for (const result of [
255
252
  "throws",
256
253
  "empty",
257
254
  "private"
258
255
  ]) await t.step(result, async () => {
259
- const originalLookup = dns.lookup;
260
- dns.lookup = (() => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
256
+ const lookup = () => result === "throws" ? Promise.reject(/* @__PURE__ */ new Error("Resolver unavailable")) : Promise.resolve(result === "empty" ? [] : [{
261
257
  address: "127.0.0.1",
262
258
  family: 4
263
- }]));
259
+ }]);
260
+ const loader = createAuthenticatedDocumentLoader(identity, {}, { openHop: (url) => openPublicFetch(url, { lookup }) });
264
261
  const records = [];
265
262
  await configure({
266
263
  sinks: { buffer: (record) => records.push(record) },
@@ -294,7 +291,6 @@ test("getAuthenticatedDocumentLoader() logs DNS failures as such", { ignore: "De
294
291
  assertEquals(records[0].properties.error, error);
295
292
  } finally {
296
293
  await reset();
297
- dns.lookup = originalLookup;
298
294
  }
299
295
  });
300
296
  });
@@ -630,6 +626,211 @@ test("getAuthenticatedDocumentLoader() times out", async (t) => {
630
626
  }
631
627
  });
632
628
  });
629
+ test("getAuthenticatedDocumentLoader() closes each hop before opening the next", async (t) => {
630
+ await t.step("redirect", async () => {
631
+ const start = "https://example.com/lifecycle/start";
632
+ const next = "https://example.com/lifecycle/document";
633
+ const hops = recordHops((request) => request.url === start ? {
634
+ status: 302,
635
+ headers: { Location: next },
636
+ body: "Found"
637
+ } : {
638
+ headers: { "Content-Type": "application/activity+json" },
639
+ body: "{\"done\":true}"
640
+ });
641
+ deepStrictEqual(await createAuthenticatedDocumentLoader(identity, {}, hops)(start), {
642
+ contextUrl: null,
643
+ document: { done: true },
644
+ documentUrl: next
645
+ });
646
+ deepStrictEqual(hops.events, [
647
+ `open ${start}`,
648
+ `fetch #1 ${start}`,
649
+ "cancel #1",
650
+ `close ${start}`,
651
+ `open ${next}`,
652
+ `fetch #2 ${next}`,
653
+ "consumed #2",
654
+ `close ${next}`
655
+ ]);
656
+ hops.assertInvariants();
657
+ });
658
+ await t.step("Link header alternate", async () => {
659
+ const start = "https://example.com/lifecycle/page";
660
+ const next = "https://example.com/lifecycle/alternate";
661
+ const hops = recordHops((request) => request.url === start ? {
662
+ headers: {
663
+ "Content-Type": "text/plain",
664
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`
665
+ },
666
+ body: "not JSON"
667
+ } : {
668
+ headers: { "Content-Type": "application/activity+json" },
669
+ body: "{\"done\":true}"
670
+ });
671
+ deepStrictEqual((await createAuthenticatedDocumentLoader(identity, {}, hops)(start)).document, { done: true });
672
+ deepStrictEqual(hops.events, [
673
+ `open ${start}`,
674
+ `fetch #1 ${start}`,
675
+ "cancel #1",
676
+ `close ${start}`,
677
+ `open ${next}`,
678
+ `fetch #2 ${next}`,
679
+ "consumed #2",
680
+ `close ${next}`
681
+ ]);
682
+ hops.assertInvariants();
683
+ });
684
+ await t.step("one lookup per hop", async () => {
685
+ const start = "https://first.example/start";
686
+ const next = "https://second.example/document";
687
+ const lookups = [];
688
+ const lookup = (hostname) => {
689
+ lookups.push(hostname);
690
+ return Promise.resolve([{
691
+ address: "8.8.8.8",
692
+ family: 4
693
+ }]);
694
+ };
695
+ await withFetchStub((request) => new URL(request.url).pathname === "/start" ? Response.redirect(next, 302) : Response.json({ done: true }), async (fetched) => {
696
+ deepStrictEqual((await createAuthenticatedDocumentLoader(identity, {}, { openHop: (url) => openPublicFetch(url, { lookup }) })(start)).document, { done: true });
697
+ deepStrictEqual(lookups, ["first.example", "second.example"]);
698
+ deepStrictEqual(fetched.length, 2);
699
+ });
700
+ });
701
+ });
702
+ async function captureLogs(fn) {
703
+ const records = [];
704
+ await configure({
705
+ sinks: { buffer: (record) => records.push(record) },
706
+ loggers: [{
707
+ category: "fedify",
708
+ sinks: ["buffer"],
709
+ lowestLevel: "debug"
710
+ }, {
711
+ category: ["logtape", "meta"],
712
+ sinks: []
713
+ }],
714
+ reset: true
715
+ });
716
+ try {
717
+ await fn();
718
+ await new Promise((resolve) => setTimeout(resolve, 0));
719
+ } finally {
720
+ await reset();
721
+ }
722
+ return records;
723
+ }
724
+ function getCancelFailures(records) {
725
+ return records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}").map((r) => r.properties.error.message);
726
+ }
727
+ const linkAlternate = (next) => ({
728
+ headers: {
729
+ "Content-Type": "text/plain",
730
+ Link: `<${next}>; rel="alternate"; type="application/activity+json"`
731
+ },
732
+ body: "not JSON"
733
+ });
734
+ const jsonDocument = {
735
+ headers: { "Content-Type": "application/activity+json" },
736
+ body: "{\"done\":true}"
737
+ };
738
+ test("getAuthenticatedDocumentLoader() logs bodies failing to cancel and loads the next hop", async (t) => {
739
+ for (const kind of ["redirect", "Link header alternate"]) await t.step(kind, async () => {
740
+ const start = "https://example.com/lifecycle/cancel-failure";
741
+ const next = "https://example.com/lifecycle/cancel-failure-next";
742
+ const hops = recordHops((request) => request.url === start ? {
743
+ ...kind === "redirect" ? {
744
+ status: 302,
745
+ headers: { Location: next },
746
+ body: "Found"
747
+ } : linkAlternate(next),
748
+ failCancel: true
749
+ } : jsonDocument);
750
+ const loader = createAuthenticatedDocumentLoader(identity, {}, hops);
751
+ let document;
752
+ const records = await captureLogs(async () => {
753
+ document = (await loader(start)).document;
754
+ });
755
+ deepStrictEqual(document, { done: true });
756
+ deepStrictEqual(hops.events, [
757
+ `open ${start}`,
758
+ `fetch #1 ${start}`,
759
+ "cancel #1",
760
+ `close ${start}`,
761
+ `open ${next}`,
762
+ `fetch #2 ${next}`,
763
+ "consumed #2",
764
+ `close ${next}`
765
+ ]);
766
+ deepStrictEqual(getCancelFailures(records), ["cancel #1 failed"]);
767
+ hops.assertInvariants();
768
+ });
769
+ });
770
+ test("getAuthenticatedDocumentLoader() is not blocked by a body cancellation that never settles", async (t) => {
771
+ await t.step("redirect and Link header alternate", async () => {
772
+ const start = "https://example.com/lifecycle/hanging-start";
773
+ const page = "https://example.com/lifecycle/hanging-page";
774
+ const next = "https://example.com/lifecycle/hanging-document";
775
+ const hops = recordHops((request) => ({
776
+ ...request.url === start ? {
777
+ status: 302,
778
+ headers: { Location: page },
779
+ body: "Found"
780
+ } : request.url === page ? linkAlternate(next) : jsonDocument,
781
+ hangCancel: true
782
+ }));
783
+ deepStrictEqual((await createAuthenticatedDocumentLoader(identity, {}, hops)(start)).document, { done: true });
784
+ deepStrictEqual(hops.events, [
785
+ `open ${start}`,
786
+ `fetch #1 ${start}`,
787
+ "cancel #1",
788
+ `close ${start}`,
789
+ `open ${page}`,
790
+ `fetch #2 ${page}`,
791
+ "cancel #2",
792
+ `close ${page}`,
793
+ `open ${next}`,
794
+ `fetch #3 ${next}`,
795
+ "consumed #3",
796
+ `close ${next}`
797
+ ]);
798
+ hops.assertInvariants();
799
+ });
800
+ await t.step("not found", async () => {
801
+ const url = "https://example.com/lifecycle/hanging-missing";
802
+ const hops = recordHops(() => ({
803
+ status: 404,
804
+ body: "Not Found",
805
+ hangCancel: true
806
+ }));
807
+ await rejects(createAuthenticatedDocumentLoader(identity, {}, hops)(url), FetchError);
808
+ deepStrictEqual(hops.events.slice(0, 2), [`open ${url}`, `fetch #1 ${url}`]);
809
+ deepStrictEqual(hops.events.at(-1), `close ${url}`);
810
+ hops.assertInvariants();
811
+ });
812
+ });
813
+ test("getAuthenticatedDocumentLoader() completes with responses cloned by fetch-mock", async () => {
814
+ esm_default.mockGlobal();
815
+ try {
816
+ const base = "https://example.com/tee/";
817
+ esm_default.get(`${base}start`, () => new Response("Found", {
818
+ status: 302,
819
+ headers: { Location: `${base}page` }
820
+ }));
821
+ esm_default.get(`${base}page`, () => new Response("not JSON", { headers: {
822
+ "Content-Type": "text/plain",
823
+ Link: `<${base}document>; rel="alternate"; type="application/activity+json"`
824
+ } }));
825
+ esm_default.get(`${base}document`, () => new Response("{\"done\":true}", { headers: { "Content-Type": "application/activity+json" } }));
826
+ esm_default.get(`${base}missing`, () => new Response("Not Found", { status: 404 }));
827
+ const loader = createAuthenticatedDocumentLoader(identity, {}, directDeps);
828
+ deepStrictEqual((await loader(`${base}start`)).document, { done: true });
829
+ await rejects(loader(`${base}missing`), FetchError);
830
+ } finally {
831
+ esm_default.hardReset();
832
+ }
833
+ });
633
834
  const unsupportedUrls = [
634
835
  "ftp://example.com/collection",
635
836
  "file:///collection",
@@ -764,5 +965,112 @@ for (const url of unsupportedUrls) {
764
965
  });
765
966
  });
766
967
  }
968
+ function createRecordedResponse(events, index, init, cancelSuffix = () => "") {
969
+ const bytes = new TextEncoder().encode(init.body ?? "");
970
+ const chunkSize = init.chunkSize ?? 256;
971
+ let offset = 0;
972
+ let settled = false;
973
+ const body = new ReadableStream({
974
+ pull(controller) {
975
+ if (offset >= bytes.length) {
976
+ settled = true;
977
+ events.push(`consumed #${index}`);
978
+ controller.close();
979
+ return;
980
+ }
981
+ controller.enqueue(bytes.slice(offset, offset + chunkSize));
982
+ offset += chunkSize;
983
+ },
984
+ cancel() {
985
+ settled = true;
986
+ events.push(`cancel #${index}${cancelSuffix()}`);
987
+ if (init.failCancel) throw new Error(`cancel #${index} failed`);
988
+ if (init.hangCancel) return new Promise(() => {});
989
+ }
990
+ }, { highWaterMark: 0 });
991
+ return {
992
+ response: new Response(body, {
993
+ status: init.status ?? 200,
994
+ headers: init.headers
995
+ }),
996
+ settled: () => settled
997
+ };
998
+ }
999
+ function recordHops(respond) {
1000
+ const events = [];
1001
+ const requests = [];
1002
+ const unsettledAtClose = [];
1003
+ let opens = 0;
1004
+ let closes = 0;
1005
+ let fetches = 0;
1006
+ return {
1007
+ events,
1008
+ requests,
1009
+ openHop(url) {
1010
+ opens++;
1011
+ events.push(`open ${url}`);
1012
+ const tracked = [];
1013
+ let closed = false;
1014
+ let closing = false;
1015
+ return Promise.resolve({
1016
+ url: new URL(url),
1017
+ async fetch(input, init) {
1018
+ if (closed) throw new TypeError("The session is closed.");
1019
+ const request = new Request(input instanceof Request ? input.clone() : input, init);
1020
+ const index = ++fetches;
1021
+ requests.push(request);
1022
+ events.push(`fetch #${index} ${request.url}`);
1023
+ const recorded = createRecordedResponse(events, index, await respond(request, index), () => closing ? " by close" : "");
1024
+ tracked.push({
1025
+ ...recorded,
1026
+ index
1027
+ });
1028
+ return recorded.response;
1029
+ },
1030
+ close() {
1031
+ if (closed) return Promise.resolve();
1032
+ closed = true;
1033
+ closes++;
1034
+ closing = true;
1035
+ for (const { response, settled, index } of tracked) {
1036
+ if (settled()) continue;
1037
+ if (response.body?.locked) unsettledAtClose.push(index);
1038
+ else response.body?.cancel().catch(() => {});
1039
+ }
1040
+ events.push(`close ${url}`);
1041
+ return Promise.resolve();
1042
+ }
1043
+ });
1044
+ },
1045
+ assertInvariants() {
1046
+ deepStrictEqual(opens, closes);
1047
+ deepStrictEqual(unsettledAtClose, []);
1048
+ }
1049
+ };
1050
+ }
1051
+ test("authenticated loader closes a hop that opens after its timeout", async () => {
1052
+ const url = "https://example.com/late-open";
1053
+ let release = () => {};
1054
+ const opened = new Promise((resolve) => release = resolve);
1055
+ let closed = () => {};
1056
+ const closure = new Promise((resolve) => closed = resolve);
1057
+ const hops = recordHops(() => jsonDocument);
1058
+ await rejects(createAuthenticatedDocumentLoader(identity, { timeout: 20 }, { async openHop(url) {
1059
+ await opened;
1060
+ const session = await hops.openHop(url);
1061
+ return {
1062
+ ...session,
1063
+ async close() {
1064
+ await session.close();
1065
+ closed();
1066
+ }
1067
+ };
1068
+ } })(url), (error) => assertTimeoutFetchError(error, url));
1069
+ release();
1070
+ await closure;
1071
+ deepStrictEqual(hops.requests, []);
1072
+ deepStrictEqual(hops.events, [`open ${url}`, `close ${url}`]);
1073
+ hops.assertInvariants();
1074
+ });
767
1075
  //#endregion
768
1076
  export {};
@@ -1,7 +1,7 @@
1
1
  import { Temporal } from "temporal-polyfill";
2
2
  import { URLPattern } from "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { n as kvCache, t as MockKvStore } from "../kv-cache-m3RIr811.mjs";
4
+ import { n as kvCache, t as MockKvStore } from "../kv-cache-Cxn7pNMx.mjs";
5
5
  import { preloadedContexts } from "@fedify/vocab-runtime";
6
6
  import { deepStrictEqual, throws } from "node:assert";
7
7
  import { createTestMeterProvider, mockDocumentLoader, test } from "@fedify/fixture";
@@ -1,5 +1,5 @@
1
1
  const { URLPattern } = require("urlpattern-polyfill");
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const require_kv_cache = require("../kv-cache-Dkzuqs8B.cjs");
3
+ const require_kv_cache = require("../kv-cache-BpGIONmd.cjs");
4
4
  exports.getAuthenticatedDocumentLoader = require_kv_cache.getAuthenticatedDocumentLoader;
5
5
  exports.kvCache = require_kv_cache.kvCache;
@@ -1,3 +1,3 @@
1
1
  /// <reference lib="esnext.temporal" />
2
- import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../mod-Dw-nPxQh.cjs";
2
+ import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../mod-ss0wBUH1.cjs";
3
3
  export { getAuthenticatedDocumentLoader, kvCache };
@@ -1,3 +1,3 @@
1
1
  /// <reference lib="esnext.temporal" />
2
- import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../mod-DEZXwV6J.js";
2
+ import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../mod-B1Qt5cQQ.js";
3
3
  export { getAuthenticatedDocumentLoader, kvCache };
package/dist/utils/mod.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { Temporal } from "temporal-polyfill";
2
2
  import { URLPattern } from "urlpattern-polyfill";
3
- import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../kv-cache-DoZoRS9p.js";
3
+ import { n as getAuthenticatedDocumentLoader, t as kvCache } from "../kv-cache-CNfNI_95.js";
4
4
  export { getAuthenticatedDocumentLoader, kvCache };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/fedify",
3
- "version": "2.4.5-dev.2462+46a3f2c5",
3
+ "version": "2.4.6-dev.2628+2a78382f",
4
4
  "description": "An ActivityPub server framework",
5
5
  "keywords": [
6
6
  "ActivityPub",
@@ -137,7 +137,7 @@
137
137
  },
138
138
  "dependencies": {
139
139
  "@logtape/logtape": "^2.2.0",
140
- "@opentelemetry/api": "^1.9.1",
140
+ "@opentelemetry/api": "~1.9.1",
141
141
  "@opentelemetry/core": "^2.7.1",
142
142
  "@opentelemetry/sdk-metrics": "2.7.1",
143
143
  "@opentelemetry/sdk-trace-base": "^2.7.1",
@@ -151,10 +151,10 @@
151
151
  "structured-field-values": "^2.0.4",
152
152
  "temporal-polyfill": "^1.0.1",
153
153
  "urlpattern-polyfill": "^10.1.0",
154
- "@fedify/uri-template": "2.4.5-dev.2462+46a3f2c5",
155
- "@fedify/vocab": "2.4.5-dev.2462+46a3f2c5",
156
- "@fedify/vocab-runtime": "2.4.5-dev.2462+46a3f2c5",
157
- "@fedify/webfinger": "2.4.5-dev.2462+46a3f2c5"
154
+ "@fedify/uri-template": "2.4.6-dev.2628+2a78382f",
155
+ "@fedify/vocab": "2.4.6-dev.2628+2a78382f",
156
+ "@fedify/webfinger": "2.4.6-dev.2628+2a78382f",
157
+ "@fedify/vocab-runtime": "2.4.6-dev.2628+2a78382f"
158
158
  },
159
159
  "devDependencies": {
160
160
  "@std/assert": "npm:@jsr/std__assert@^0.226.0",
@@ -167,7 +167,7 @@
167
167
  "typescript": "^6.0.0",
168
168
  "wrangler": "^4.17.0",
169
169
  "@fedify/fixture": "2.0.0",
170
- "@fedify/vocab-tools": "^2.4.5-dev.2462+46a3f2c5"
170
+ "@fedify/vocab-tools": "^2.4.6-dev.2628+2a78382f"
171
171
  },
172
172
  "scripts": {
173
173
  "build:self": "tsdown",
@@ -1,91 +0,0 @@
1
- import "temporal-polyfill";
2
- import "urlpattern-polyfill";
3
- globalThis.addEventListener = () => {};
4
- import { T as validateCryptoKey } from "./ld-PJLGA2MJ.mjs";
5
- import { n as doubleKnock } from "./http-D7L2pqLT.mjs";
6
- import { getLogger } from "@logtape/logtape";
7
- import { FetchError, UrlError, createActivityPubRequest, getRemoteDocument, logRequest, resolveDocumentLoaderTimeout, validatePublicUrl, withDocumentLoaderTimeout } from "@fedify/vocab-runtime";
8
- import { curry } from "es-toolkit";
9
- //#region src/utils/docloader.ts
10
- const logger = getLogger([
11
- "fedify",
12
- "utils",
13
- "docloader"
14
- ]);
15
- const DEFAULT_MAX_REDIRECTION = 20;
16
- /**
17
- * Gets an authenticated {@link DocumentLoader} for the given identity.
18
- * Note that an authenticated document loader intentionally does not cache
19
- * the fetched documents.
20
- * At most 20 HTTP redirects and alternate document links are followed in total
21
- * per call. Revisiting a URL within that chain throws a {@link FetchError}.
22
- * Each call, including its double-knocking retries, times out after
23
- * 10 seconds by default; see {@link DocumentLoaderFactoryOptions.timeout}.
24
- * @param identity The identity to get the document loader for.
25
- * The actor's key pair.
26
- * @param options The options for the document loader.
27
- * @returns The authenticated document loader.
28
- * @throws {TypeError} If the key is invalid or unsupported.
29
- * @throws {RangeError} If the `timeout` option is invalid.
30
- * @since 0.4.0
31
- */
32
- function getAuthenticatedDocumentLoader(identity, { allowPrivateAddress, maxRedirection, userAgent, specDeterminer, timeout, tracerProvider } = {}) {
33
- validateCryptoKey(identity.privateKey);
34
- const resolvedTimeout = resolveDocumentLoaderTimeout(timeout);
35
- async function load(url, options, redirected = 0, visited = /* @__PURE__ */ new Set()) {
36
- options?.signal?.throwIfAborted();
37
- let currentUrl = new URL(url).href;
38
- await validateUrl(currentUrl, options);
39
- visited.add(currentUrl);
40
- const originalRequest = createActivityPubRequest(currentUrl, { userAgent });
41
- function follow(nextUrl) {
42
- options?.signal?.throwIfAborted();
43
- if (redirected >= DEFAULT_MAX_REDIRECTION) throw new FetchError(currentUrl, `Too many redirections (${redirected + 1})`);
44
- if (visited.has(nextUrl)) throw new FetchError(currentUrl, `Redirect loop detected: ${nextUrl}`);
45
- redirected++;
46
- }
47
- const response = await doubleKnock(originalRequest, identity, {
48
- maxRedirection,
49
- specDeterminer,
50
- log: curry(logRequest)(logger),
51
- tracerProvider,
52
- signal: options?.signal,
53
- validateRedirect: async (nextUrl) => {
54
- follow(nextUrl);
55
- await validateUrl(nextUrl, options);
56
- visited.add(nextUrl);
57
- currentUrl = nextUrl;
58
- }
59
- });
60
- options?.signal?.throwIfAborted();
61
- return getRemoteDocument(currentUrl, response, (alternateUrl) => {
62
- follow(alternateUrl);
63
- return load(alternateUrl, options, redirected, visited);
64
- }, options);
65
- }
66
- async function validateUrl(url, options) {
67
- const parsedUrl = new URL(url);
68
- const protocol = parsedUrl.protocol;
69
- if (protocol !== "http:" && protocol !== "https:") throw new UrlError(`Unsupported protocol: ${protocol}`);
70
- if (parsedUrl.username !== "" || parsedUrl.password !== "") throw new UrlError("URLs with credentials are not allowed");
71
- if (!allowPrivateAddress) {
72
- try {
73
- await validatePublicUrl(url);
74
- } catch (error) {
75
- if (error instanceof UrlError) if (error.reason === "dns") logger.debug("DNS lookup failed for {url}", {
76
- url,
77
- error
78
- });
79
- else logger[options?.suppressError ? "warn" : "error"]("Disallowed private URL: {url}", {
80
- url,
81
- error
82
- });
83
- throw error;
84
- }
85
- options?.signal?.throwIfAborted();
86
- }
87
- }
88
- return withDocumentLoaderTimeout((url, options) => load(url, options), resolvedTimeout);
89
- }
90
- //#endregion
91
- export { getAuthenticatedDocumentLoader as t };