@fedify/fedify 2.3.15-dev.2482 → 2.3.15

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 (87) hide show
  1. package/dist/{builder-CocMrVkL.mjs → builder-B6vSXYxn.mjs} +2 -2
  2. package/dist/{client-CAM_bQXx.d.cts → client-BIBzqEIp.d.cts} +0 -1
  3. package/dist/{client-1He2YuBF.mjs → client-DpUNYKla.mjs} +113 -64
  4. package/dist/{client-CSddvgWN.d.ts → client-jE7oGvRh.d.ts} +0 -1
  5. package/dist/compat/mod.d.cts +1 -1
  6. package/dist/compat/mod.d.ts +1 -1
  7. package/dist/compat/transformers.test.mjs +1 -1
  8. package/dist/{context-MOrCC7yl.d.cts → context-CE_Tj_Oc.d.cts} +28 -2
  9. package/dist/{context-BWbdE_MZ.d.ts → context-H_5tZXg-.d.ts} +28 -2
  10. package/dist/{deno-po8iekMB.mjs → deno-BCuL3mid.mjs} +1 -1
  11. package/dist/docloader-D2dr1_6B.mjs +125 -0
  12. package/dist/federation/builder.test.mjs +1 -1
  13. package/dist/federation/handler.test.mjs +5 -5
  14. package/dist/federation/idempotency.test.mjs +2 -2
  15. package/dist/federation/inbox.test.mjs +1 -1
  16. package/dist/federation/metrics.test.mjs +1 -1
  17. package/dist/federation/middleware.test.mjs +390 -29
  18. package/dist/federation/mod.cjs +1 -1
  19. package/dist/federation/mod.d.cts +2 -2
  20. package/dist/federation/mod.d.ts +2 -2
  21. package/dist/federation/mod.js +1 -1
  22. package/dist/federation/send.test.mjs +498 -88
  23. package/dist/federation/temporal.test.mjs +1 -1
  24. package/dist/federation/webfinger.test.mjs +1 -1
  25. package/dist/{http-Cdu8BGtv.js → http-BkjxY3U0.js} +172 -103
  26. package/dist/{http-BNCLeQJ-.mjs → http-CSDcThvz.mjs} +196 -98
  27. package/dist/{http-DQmNpfym.d.ts → http-CZPYFK3b.d.cts} +1 -2
  28. package/dist/{http-DiWQB8Mr.d.cts → http-DmP0sr2l.d.ts} +1 -2
  29. package/dist/{http-WcSdFbt1.cjs → http-gOkAiIe1.cjs} +173 -104
  30. package/dist/{inbox-g1sd_Vjn.mjs → inbox-BHxez4d5.mjs} +2 -2
  31. package/dist/{key-BR2TRTXR.mjs → key-D8IMQ72M.mjs} +2 -2
  32. package/dist/{kv-cache-Bw_gduSm.cjs → kv-cache-Alm5UmJa.cjs} +59 -17
  33. package/dist/{kv-cache-8SigUtvX.mjs → kv-cache-Blr9nDDp.mjs} +1 -1
  34. package/dist/{kv-cache-C0AqpjoS.js → kv-cache-CkXgU-ak.js} +60 -18
  35. package/dist/{ld-CSNsWdYE.mjs → ld-CVxxE1Pp.mjs} +3 -3
  36. package/dist/{metrics-BS7YzLVA.mjs → metrics-SYIZHFf_.mjs} +1 -1
  37. package/dist/{middleware-D-SL8Bkj.cjs → middleware-4yPyyoF5.cjs} +236 -98
  38. package/dist/{middleware-B1RRLN7m.mjs → middleware-CfskJGfV.mjs} +80 -40
  39. package/dist/{middleware-Dlo7NnZl.mjs → middleware-D3JWfiCu.mjs} +1 -1
  40. package/dist/{middleware-WL-JfDiK.js → middleware-DhN7c65B.js} +237 -99
  41. package/dist/{mod-LmD03uRA.d.ts → mod-CDFFESfL.d.ts} +1 -1
  42. package/dist/{mod-Bid79LFU.d.cts → mod-C_gFmeNq.d.cts} +1 -1
  43. package/dist/{mod-Co1M3Ux0.d.cts → mod-DDVkmZ_h.d.cts} +2 -8
  44. package/dist/{mod-B6PwkUMU.d.cts → mod-DDjtbkZO.d.cts} +1 -1
  45. package/dist/{mod-Br8yJNcE.d.ts → mod-DMjhnFbg.d.ts} +2 -8
  46. package/dist/{mod-TLEvgT1i.d.ts → mod-HQXFvOHs.d.ts} +1 -1
  47. package/dist/mod.cjs +5 -5
  48. package/dist/mod.d.cts +6 -6
  49. package/dist/mod.d.ts +6 -6
  50. package/dist/mod.js +5 -5
  51. package/dist/nodeinfo/client.test.mjs +337 -19
  52. package/dist/nodeinfo/handler.test.mjs +1 -1
  53. package/dist/nodeinfo/mod.cjs +1 -1
  54. package/dist/nodeinfo/mod.d.cts +1 -1
  55. package/dist/nodeinfo/mod.d.ts +1 -1
  56. package/dist/nodeinfo/mod.js +1 -1
  57. package/dist/otel/exporter.test.mjs +292 -30
  58. package/dist/otel/mod.cjs +36 -30
  59. package/dist/otel/mod.d.cts +13 -0
  60. package/dist/otel/mod.d.ts +13 -0
  61. package/dist/otel/mod.js +36 -30
  62. package/dist/{owner-DeggvxcG.mjs → owner-BluLVPf0.mjs} +2 -2
  63. package/dist/{proof-teNNYmFh.cjs → proof-CR-HwI0l.cjs} +1 -1
  64. package/dist/{proof-DMcUrrp8.mjs → proof-Co6nQBVR.mjs} +3 -3
  65. package/dist/{proof-v-IzWx7x.js → proof-Dg0d9QBo.js} +1 -1
  66. package/dist/{send-BsthcvPG.mjs → send-D_DnqOFA.mjs} +171 -73
  67. package/dist/sig/http.test.mjs +528 -2
  68. package/dist/sig/key.test.mjs +2 -2
  69. package/dist/sig/ld.test.mjs +2 -2
  70. package/dist/sig/mod.cjs +2 -2
  71. package/dist/sig/mod.d.cts +2 -2
  72. package/dist/sig/mod.d.ts +2 -2
  73. package/dist/sig/mod.js +2 -2
  74. package/dist/sig/owner.test.mjs +1 -1
  75. package/dist/sig/proof.test.mjs +1 -1
  76. package/dist/{temporal-Cji8Y8eQ.mjs → temporal-B8FUNkXB.mjs} +1 -1
  77. package/dist/testing/mod.d.mts +26 -0
  78. package/dist/{types-miAYfiGO.js → types-B1_pRFE_.js} +112 -63
  79. package/dist/{types-DRPjmHVV.cjs → types-DOvyymM4.cjs} +111 -62
  80. package/dist/utils/docloader.test.mjs +318 -34
  81. package/dist/utils/kv-cache.test.mjs +1 -1
  82. package/dist/utils/mod.cjs +1 -1
  83. package/dist/utils/mod.d.cts +1 -1
  84. package/dist/utils/mod.d.ts +1 -1
  85. package/dist/utils/mod.js +1 -1
  86. package/package.json +8 -8
  87. package/dist/docloader-BVj35vgR.mjs +0 -83
@@ -8,16 +8,20 @@ import { r as assertFalse } from "../assert_throws-BbSMEFKo.mjs";
8
8
  import { n as assertGreaterOrEqual, t as assertRejects } from "../assert_rejects-D1HYAik3.mjs";
9
9
  import { n as assertInstanceOf, t as assert } from "../assert-CEO06XCw.mjs";
10
10
  import { t as assertNotEquals } from "../assert_not_equals-DkVK8oqV.mjs";
11
- import { l as verifyRequest } from "../http-BNCLeQJ-.mjs";
11
+ import { u as verifyRequest } from "../http-CSDcThvz.mjs";
12
12
  import { i as rsaPrivateKey2, n as ed25519PrivateKey, s as rsaPublicKey2, t as ed25519Multikey } from "../keys-BAK-tUlf.mjs";
13
- import { t as doesActorOwnKey } from "../owner-DeggvxcG.mjs";
14
- import { n as extractInboxes, r as sendActivity, t as SendActivityError } from "../send-BsthcvPG.mjs";
13
+ import { t as doesActorOwnKey } from "../owner-BluLVPf0.mjs";
14
+ import { i as sendActivity, n as extractInboxes, r as fetchWithValidatedRedirects, t as SendActivityError } from "../send-D_DnqOFA.mjs";
15
+ import { configure, reset } from "@logtape/logtape";
15
16
  import { Activity, Application, Endpoints, Group, Person, Service } from "@fedify/vocab";
16
17
  import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
17
18
  import { FetchError, UrlError } from "@fedify/vocab-runtime";
18
- import dns from "node:dns/promises";
19
+ import { openDirectFetch, openPublicFetch } from "@fedify/vocab-runtime/internal";
19
20
  import { AggregationTemporality, InMemoryMetricExporter, MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
20
21
  //#region src/federation/send.test.ts
22
+ function sendDirect(options) {
23
+ return sendActivity(options, { openHop: openDirectFetch });
24
+ }
21
25
  test("extractInboxes()", () => {
22
26
  const recipients = [
23
27
  new Person({
@@ -153,7 +157,7 @@ test("sendActivity()", async (t) => {
153
157
  "id": "https://example.com/activity",
154
158
  "actor": "https://example.com/person"
155
159
  };
156
- await sendActivity({
160
+ await sendDirect({
157
161
  activity,
158
162
  keys: [{
159
163
  privateKey: rsaPrivateKey2,
@@ -169,7 +173,7 @@ test("sendActivity()", async (t) => {
169
173
  assertEquals(request?.headers.get("Content-Type"), "application/activity+json");
170
174
  assertEquals(request?.headers.get("X-Test"), "test");
171
175
  httpSigVerified = null;
172
- await assertRejects(() => sendActivity({
176
+ await assertRejects(() => sendDirect({
173
177
  activity: {
174
178
  ...activity,
175
179
  actor: "https://example.com/person2"
@@ -198,7 +202,7 @@ test("sendActivity()", async (t) => {
198
202
  "id": "https://example.com/activity",
199
203
  "actor": "https://example.com/person"
200
204
  };
201
- await assertRejects(() => sendActivity({
205
+ await assertRejects(() => sendDirect({
202
206
  activity,
203
207
  activityId: "https://example.com/activity",
204
208
  keys: [{
@@ -216,7 +220,7 @@ test("sendActivity()", async (t) => {
216
220
  "actor": "https://example.com/person"
217
221
  };
218
222
  try {
219
- await sendActivity({
223
+ await sendDirect({
220
224
  activity,
221
225
  activityId: "https://example.com/activity",
222
226
  keys: [{
@@ -251,7 +255,7 @@ test("sendActivity()", async (t) => {
251
255
  });
252
256
  throw failure;
253
257
  });
254
- const error = await assertRejects(() => sendActivity({
258
+ const error = await assertRejects(() => sendDirect({
255
259
  activity,
256
260
  activityId: "https://example.com/activity",
257
261
  keys: [{
@@ -276,7 +280,7 @@ test("sendActivity()", async (t) => {
276
280
  "actor": "https://example.com/person"
277
281
  };
278
282
  try {
279
- await sendActivity({
283
+ await sendDirect({
280
284
  activity,
281
285
  activityId: "https://example.com/activity",
282
286
  keys: [{
@@ -305,7 +309,7 @@ test("sendActivity()", async (t) => {
305
309
  "actor": "https://example.com/person"
306
310
  };
307
311
  try {
308
- await sendActivity({
312
+ await sendDirect({
309
313
  activity,
310
314
  activityId: "https://example.com/activity",
311
315
  keys: [{
@@ -335,7 +339,7 @@ test("sendActivity()", async (t) => {
335
339
  "actor": "https://example.com/person"
336
340
  };
337
341
  try {
338
- await sendActivity({
342
+ await sendDirect({
339
343
  activity,
340
344
  activityId: "https://example.com/activity",
341
345
  keys: [{
@@ -362,7 +366,7 @@ test("sendActivity()", async (t) => {
362
366
  "actor": "https://example.com/person"
363
367
  };
364
368
  try {
365
- await sendActivity({
369
+ await sendDirect({
366
370
  activity,
367
371
  activityId: "https://example.com/activity",
368
372
  keys: [{
@@ -391,7 +395,7 @@ test("sendActivity()", async (t) => {
391
395
  "actor": "https://example.com/person"
392
396
  };
393
397
  try {
394
- await sendActivity({
398
+ await sendDirect({
395
399
  activity,
396
400
  activityId: "https://example.com/activity",
397
401
  keys: [{
@@ -415,7 +419,7 @@ test("sendActivity() records OpenTelemetry span events", async (t) => {
415
419
  await t.step("successful send", async () => {
416
420
  esm_default.get("https://example.com/", { status: 404 });
417
421
  esm_default.post("https://example.com/inbox", { status: 202 });
418
- await sendActivity({
422
+ await sendDirect({
419
423
  activity: {
420
424
  "@context": "https://www.w3.org/ns/activitystreams",
421
425
  type: "Create",
@@ -449,6 +453,41 @@ test("sendActivity() records OpenTelemetry span events", async (t) => {
449
453
  esm_default.hardReset();
450
454
  });
451
455
  });
456
+ test("sendActivity() validates the initial policy before serializing", async () => {
457
+ const [meterProvider, recorder] = createTestMeterProvider();
458
+ let serialized = false;
459
+ await assertRejects(() => sendActivity({
460
+ activity: { toJSON() {
461
+ serialized = true;
462
+ throw new Error("must not serialize");
463
+ } },
464
+ keys: [],
465
+ inbox: new URL("http://127.0.0.1/inbox"),
466
+ meterProvider
467
+ }), UrlError);
468
+ assertFalse(serialized);
469
+ assertEquals(recorder.getMeasurements("activitypub.delivery.sent"), []);
470
+ });
471
+ test("sendActivity() closes the initial session when serialization fails", async () => {
472
+ const [meterProvider, recorder] = createTestMeterProvider();
473
+ const url = "https://example.com/lifecycle/serialization-failure";
474
+ const hops = recordHops(() => ({
475
+ status: 202,
476
+ body: "Accepted"
477
+ }));
478
+ const failure = /* @__PURE__ */ new Error("serialization failed");
479
+ assertEquals(await assertRejects(() => sendActivity({
480
+ activity: { toJSON() {
481
+ throw failure;
482
+ } },
483
+ keys: [],
484
+ inbox: new URL(url),
485
+ meterProvider
486
+ }, { openHop: hops.openHop }), Error, "serialization failed"), failure);
487
+ assertEquals(hops.events, [`open ${url}`, `close ${url}`]);
488
+ assertEquals(recorder.getMeasurements("activitypub.delivery.sent"), []);
489
+ hops.assertInvariants();
490
+ });
452
491
  test("sendActivity() records OpenTelemetry delivery metrics", async (t) => {
453
492
  const [meterProvider, recorder] = createTestMeterProvider();
454
493
  esm_default.spyGlobal();
@@ -689,85 +728,456 @@ for (const signed of [false, true]) {
689
728
  });
690
729
  });
691
730
  }
692
- for (const signed of [false, true]) test({
693
- name: `sendActivity() classifies DNS failures (signed: ${signed})`,
694
- ignore: "Deno" in globalThis && (await Deno.permissions.query({ name: "net" })).state !== "granted",
695
- async fn(t) {
696
- const activity = {
697
- type: "Create",
698
- id: "https://example.com/activity"
731
+ for (const signed of [false, true]) test(`sendActivity() classifies DNS failures (signed: ${signed})`, async (t) => {
732
+ const activity = {
733
+ type: "Create",
734
+ id: "https://example.com/activity"
735
+ };
736
+ const destination = "https://delivery.invalid/inbox";
737
+ const publicInbox = "https://8.8.8.8/inbox";
738
+ const keys = signed ? [{
739
+ privateKey: rsaPrivateKey2,
740
+ keyId: rsaPublicKey2.id
741
+ }] : [];
742
+ for (const result of [
743
+ "throws",
744
+ "empty",
745
+ "cname",
746
+ "private"
747
+ ]) for (const redirected of [false, true]) await t.step(`${result}, redirected: ${redirected}`, async () => {
748
+ const [meterProvider, recorder] = createTestMeterProvider();
749
+ const resolverError = /* @__PURE__ */ new Error("Resolver unavailable");
750
+ const lookups = [];
751
+ const lookup = (hostname) => {
752
+ lookups.push(hostname);
753
+ assertEquals(hostname, "delivery.invalid");
754
+ if (result === "throws") return Promise.reject(resolverError);
755
+ return Promise.resolve(result === "empty" ? [] : [{
756
+ address: result === "private" ? "127.0.0.1" : "alias.invalid.",
757
+ family: 4
758
+ }]);
699
759
  };
700
- const destination = "https://delivery.invalid/inbox";
701
- const publicInbox = "https://8.8.8.8/inbox";
702
- const keys = signed ? [{
703
- privateKey: rsaPrivateKey2,
704
- keyId: rsaPublicKey2.id
705
- }] : [];
706
- for (const result of [
707
- "throws",
708
- "empty",
709
- "cname",
710
- "private"
711
- ]) for (const redirected of [false, true]) await t.step(`${result}, redirected: ${redirected}`, async () => {
712
- const [meterProvider, recorder] = createTestMeterProvider();
713
- const originalLookup = dns.lookup;
714
- const resolverError = /* @__PURE__ */ new Error("Resolver unavailable");
715
- const lookups = [];
716
- dns.lookup = ((hostname, options) => {
717
- lookups.push(hostname);
718
- assertEquals(hostname, "delivery.invalid");
719
- assertEquals(options, { all: true });
720
- if (result === "throws") return Promise.reject(resolverError);
721
- return Promise.resolve(result === "empty" ? [] : [{
722
- address: result === "private" ? "127.0.0.1" : "alias.invalid.",
723
- family: 4
724
- }]);
760
+ try {
761
+ esm_default.mockGlobal().catch(202);
762
+ if (redirected) esm_default.route(publicInbox, {
763
+ status: 307,
764
+ headers: { Location: destination }
725
765
  });
726
- try {
727
- esm_default.mockGlobal().catch(202);
728
- if (redirected) esm_default.route(publicInbox, {
729
- status: 307,
730
- headers: { Location: destination }
731
- });
732
- const send = () => sendActivity({
733
- activity,
734
- activityType: "https://www.w3.org/ns/activitystreams#Create",
735
- meterProvider,
736
- keys,
737
- inbox: new URL(redirected ? publicInbox : destination)
766
+ const send = () => sendActivity({
767
+ activity,
768
+ activityType: "https://www.w3.org/ns/activitystreams#Create",
769
+ meterProvider,
770
+ keys,
771
+ inbox: new URL(redirected ? publicInbox : destination)
772
+ }, { openHop: (url) => openPublicFetch(url, { lookup }) });
773
+ if (result === "private") assertEquals((await assertRejects(send, UrlError)).reason, "disallowed");
774
+ else {
775
+ const error = await assertRejects(send, FetchError);
776
+ assertEquals(error.url.href, destination);
777
+ assertInstanceOf(error.cause, UrlError);
778
+ assertEquals(error.cause.reason, "dns");
779
+ assertEquals(error.cause.cause, result === "throws" ? resolverError : void 0);
780
+ }
781
+ const expectedCount = result === "private" && !redirected ? 0 : 1;
782
+ const sent = recorder.getMeasurements("activitypub.delivery.sent");
783
+ const durations = recorder.getMeasurements("activitypub.delivery.duration");
784
+ assertEquals(sent.length, expectedCount);
785
+ assertEquals(durations.length, expectedCount);
786
+ if (expectedCount === 1) {
787
+ assertEquals(sent[0].value, 1);
788
+ assertEquals(sent[0].attributes, {
789
+ "activitypub.remote.host": redirected ? "8.8.8.8" : "delivery.invalid",
790
+ "activitypub.activity.type": "https://www.w3.org/ns/activitystreams#Create",
791
+ "activitypub.delivery.success": false
738
792
  });
739
- if (result === "private") assertEquals((await assertRejects(send, UrlError)).reason, "disallowed");
740
- else {
741
- const error = await assertRejects(send, FetchError);
742
- assertEquals(error.url.href, destination);
743
- assertInstanceOf(error.cause, UrlError);
744
- assertEquals(error.cause.reason, "dns");
745
- assertEquals(error.cause.cause, result === "throws" ? resolverError : void 0);
746
- }
747
- const expectedCount = result === "private" && !redirected ? 0 : 1;
748
- const sent = recorder.getMeasurements("activitypub.delivery.sent");
749
- const durations = recorder.getMeasurements("activitypub.delivery.duration");
750
- assertEquals(sent.length, expectedCount);
751
- assertEquals(durations.length, expectedCount);
752
- if (expectedCount === 1) {
753
- assertEquals(sent[0].value, 1);
754
- assertEquals(sent[0].attributes, {
755
- "activitypub.remote.host": redirected ? "8.8.8.8" : "delivery.invalid",
756
- "activitypub.activity.type": "https://www.w3.org/ns/activitystreams#Create",
757
- "activitypub.delivery.success": false
758
- });
759
- assertEquals(durations[0].attributes, sent[0].attributes);
760
- assertGreaterOrEqual(durations[0].value, 0);
761
- }
762
- assertEquals(lookups, ["delivery.invalid"]);
763
- assertEquals(esm_default.callHistory.calls(destination).length, 0);
764
- assertEquals(esm_default.callHistory.calls().length, redirected ? 1 : 0);
765
- } finally {
766
- dns.lookup = originalLookup;
767
- esm_default.hardReset();
793
+ assertEquals(durations[0].attributes, sent[0].attributes);
794
+ assertGreaterOrEqual(durations[0].value, 0);
768
795
  }
796
+ assertEquals(lookups, ["delivery.invalid"]);
797
+ assertEquals(esm_default.callHistory.calls(destination).length, 0);
798
+ assertEquals(esm_default.callHistory.calls().length, redirected ? 1 : 0);
799
+ } finally {
800
+ esm_default.hardReset();
801
+ }
802
+ });
803
+ });
804
+ const lifecycleActivity = {
805
+ "@context": "https://www.w3.org/ns/activitystreams",
806
+ type: "Create",
807
+ id: "https://example.com/activity",
808
+ actor: "https://example.com/person"
809
+ };
810
+ const signedKeys = [{
811
+ privateKey: rsaPrivateKey2,
812
+ keyId: rsaPublicKey2.id
813
+ }];
814
+ const unsignedKeys = [{
815
+ privateKey: ed25519PrivateKey,
816
+ keyId: ed25519Multikey.id
817
+ }];
818
+ test("sendActivity() cancels a successful response body", async (t) => {
819
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
820
+ const inbox = "https://example.com/lifecycle/inbox";
821
+ const hops = recordHops(() => ({
822
+ status: 202,
823
+ body: "Accepted"
824
+ }));
825
+ await sendActivity({
826
+ activity: lifecycleActivity,
827
+ keys: signed ? signedKeys : unsignedKeys,
828
+ inbox: new URL(inbox)
829
+ }, { openHop: hops.openHop });
830
+ assertEquals(hops.events, [
831
+ `open ${inbox}`,
832
+ `fetch #1 ${inbox}`,
833
+ "cancel #1",
834
+ `close ${inbox}`
835
+ ]);
836
+ hops.assertInvariants();
837
+ });
838
+ });
839
+ test("sendActivity() reads at most 1 KiB of an error response body", async (t) => {
840
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
841
+ const inbox = "https://example.com/lifecycle/error";
842
+ const hops = recordHops(() => ({
843
+ status: 500,
844
+ body: "x".repeat(4096)
845
+ }));
846
+ const error = await assertRejects(() => sendActivity({
847
+ activity: lifecycleActivity,
848
+ activityId: lifecycleActivity.id,
849
+ keys: signed ? signedKeys : unsignedKeys,
850
+ inbox: new URL(inbox)
851
+ }, { openHop: hops.openHop }), SendActivityError);
852
+ assertEquals(error.responseBody, "x".repeat(1024) + "… (truncated)");
853
+ assert(error.message.includes("… (truncated)"));
854
+ assertEquals(hops.events, [
855
+ `open ${inbox}`,
856
+ `fetch #1 ${inbox}`,
857
+ "cancel #1",
858
+ ...signed ? [`fetch #2 ${inbox}`, "cancel #2"] : [],
859
+ `close ${inbox}`
860
+ ]);
861
+ hops.assertInvariants();
862
+ });
863
+ });
864
+ test("sendActivity() closes each redirect hop before opening the next", async (t) => {
865
+ for (const [signed, status] of [[true, 307], [false, 308]]) await t.step(`signed: ${signed}, ${status}`, async () => {
866
+ const inbox = "https://example.com/lifecycle/redirect";
867
+ const next = "https://example.com/lifecycle/redirected";
868
+ const hops = recordHops((request) => request.url === inbox ? {
869
+ status,
870
+ headers: { Location: next },
871
+ body: "Moved"
872
+ } : {
873
+ status: 202,
874
+ body: "Accepted"
769
875
  });
876
+ await sendActivity({
877
+ activity: lifecycleActivity,
878
+ keys: signed ? signedKeys : unsignedKeys,
879
+ inbox: new URL(inbox)
880
+ }, { openHop: hops.openHop });
881
+ assertEquals(hops.events, [
882
+ `open ${inbox}`,
883
+ `fetch #1 ${inbox}`,
884
+ "cancel #1",
885
+ `close ${inbox}`,
886
+ `open ${next}`,
887
+ `fetch #2 ${next}`,
888
+ "cancel #2",
889
+ `close ${next}`
890
+ ]);
891
+ assertEquals(hops.requests[1].method, "POST");
892
+ assertEquals(await hops.requests[1].text(), JSON.stringify(lifecycleActivity));
893
+ hops.assertInvariants();
894
+ });
895
+ });
896
+ async function captureLogs(fn) {
897
+ const records = [];
898
+ await configure({
899
+ sinks: { buffer: (record) => records.push(record) },
900
+ loggers: [{
901
+ category: "fedify",
902
+ sinks: ["buffer"],
903
+ lowestLevel: "debug"
904
+ }, {
905
+ category: ["logtape", "meta"],
906
+ sinks: []
907
+ }],
908
+ reset: true
909
+ });
910
+ try {
911
+ await fn();
912
+ await new Promise((resolve) => setTimeout(resolve, 0));
913
+ } finally {
914
+ await reset();
770
915
  }
916
+ return records;
917
+ }
918
+ function getCancelFailureUrls(records) {
919
+ return records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}").map((r) => r.properties.url);
920
+ }
921
+ test("fetchWithValidatedRedirects() logs a redirect body failing to cancel and follows the redirect", async (t) => {
922
+ const url = "https://example.com/lifecycle/cancel-failure";
923
+ const next = "https://example.com/lifecycle/cancel-failure-final";
924
+ const respond = (_, index) => index === 1 ? {
925
+ status: 302,
926
+ headers: { Location: next },
927
+ body: "Found",
928
+ failCancel: true
929
+ } : {
930
+ status: 202,
931
+ body: "Accepted"
932
+ };
933
+ await t.step("fetchWithValidatedRedirects()", async () => {
934
+ const hops = recordHops(respond);
935
+ let result;
936
+ const records = await captureLogs(async () => {
937
+ result = await fetchWithValidatedRedirects(new Request(url, {
938
+ method: "POST",
939
+ body: "activity"
940
+ }), hops.openHop, (response) => response.text());
941
+ });
942
+ assertEquals(result, "Accepted");
943
+ assertEquals(hops.events, [
944
+ `open ${url}`,
945
+ `fetch #1 ${url}`,
946
+ "cancel #1",
947
+ `close ${url}`,
948
+ `open ${next}`,
949
+ `fetch #2 ${next}`,
950
+ "consumed #2",
951
+ `close ${next}`
952
+ ]);
953
+ assertEquals(getCancelFailureUrls(records), [url]);
954
+ hops.assertInvariants();
955
+ });
956
+ for (const signed of [true, false]) await t.step(`signed sendActivity(): ${signed}`, async () => {
957
+ const hops = recordHops(respond);
958
+ const records = await captureLogs(() => sendActivity({
959
+ activity: lifecycleActivity,
960
+ keys: signed ? signedKeys : unsignedKeys,
961
+ inbox: new URL(url)
962
+ }, { openHop: hops.openHop }));
963
+ assertEquals(hops.events, [
964
+ `open ${url}`,
965
+ `fetch #1 ${url}`,
966
+ "cancel #1",
967
+ `close ${url}`,
968
+ `open ${next}`,
969
+ `fetch #2 ${next}`,
970
+ "cancel #2",
971
+ `close ${next}`
972
+ ]);
973
+ assertEquals(getCancelFailureUrls(records), [url]);
974
+ hops.assertInvariants();
975
+ });
976
+ });
977
+ test("fetchWithValidatedRedirects() closes the session when consuming fails", async () => {
978
+ const url = "https://example.com/lifecycle/consume-failure";
979
+ const hops = recordHops(() => ({
980
+ status: 200,
981
+ body: "x".repeat(1024)
982
+ }));
983
+ await assertRejects(() => fetchWithValidatedRedirects(new Request(url, {
984
+ method: "POST",
985
+ body: "activity"
986
+ }), hops.openHop, () => Promise.reject(/* @__PURE__ */ new Error("consume failed"))), Error, "consume failed");
987
+ assertEquals(hops.events, [
988
+ `open ${url}`,
989
+ `fetch #1 ${url}`,
990
+ "cancel #1 by close",
991
+ `close ${url}`
992
+ ]);
993
+ hops.assertInvariants();
994
+ });
995
+ test("sendActivity() logs a successful response body failing to cancel", async (t) => {
996
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
997
+ const inbox = "https://example.com/lifecycle/cancel-failure-inbox";
998
+ const hops = recordHops(() => ({
999
+ status: 202,
1000
+ body: "Accepted",
1001
+ failCancel: true
1002
+ }));
1003
+ const records = await captureLogs(() => sendActivity({
1004
+ activity: lifecycleActivity,
1005
+ keys: signed ? signedKeys : unsignedKeys,
1006
+ inbox: new URL(inbox)
1007
+ }, { openHop: hops.openHop }));
1008
+ assertEquals(hops.events, [
1009
+ `open ${inbox}`,
1010
+ `fetch #1 ${inbox}`,
1011
+ "cancel #1",
1012
+ `close ${inbox}`
1013
+ ]);
1014
+ assertEquals(getCancelFailureUrls(records), [inbox]);
1015
+ hops.assertInvariants();
1016
+ });
771
1017
  });
1018
+ test("sendActivity() is not blocked by a body cancellation that never settles", async (t) => {
1019
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
1020
+ const inbox = "https://example.com/lifecycle/hanging-cancel";
1021
+ const next = "https://example.com/lifecycle/hanging-cancel-final";
1022
+ const hops = recordHops((request) => request.url === inbox ? {
1023
+ status: 302,
1024
+ headers: { Location: next },
1025
+ body: "Found",
1026
+ hangCancel: true
1027
+ } : {
1028
+ status: 202,
1029
+ body: "Accepted",
1030
+ hangCancel: true
1031
+ });
1032
+ await sendActivity({
1033
+ activity: lifecycleActivity,
1034
+ keys: signed ? signedKeys : unsignedKeys,
1035
+ inbox: new URL(inbox)
1036
+ }, { openHop: hops.openHop });
1037
+ assertEquals(hops.events, [
1038
+ `open ${inbox}`,
1039
+ `fetch #1 ${inbox}`,
1040
+ "cancel #1",
1041
+ `close ${inbox}`,
1042
+ `open ${next}`,
1043
+ `fetch #2 ${next}`,
1044
+ "cancel #2",
1045
+ `close ${next}`
1046
+ ]);
1047
+ hops.assertInvariants();
1048
+ });
1049
+ await t.step("truncated error body", async () => {
1050
+ const inbox = "https://example.com/lifecycle/hanging-error";
1051
+ const hops = recordHops(() => ({
1052
+ status: 500,
1053
+ body: "x".repeat(4096),
1054
+ hangCancel: true
1055
+ }));
1056
+ assertEquals((await assertRejects(() => sendActivity({
1057
+ activity: lifecycleActivity,
1058
+ keys: unsignedKeys,
1059
+ inbox: new URL(inbox)
1060
+ }, { openHop: hops.openHop }), SendActivityError)).responseBody, "x".repeat(1024) + "… (truncated)");
1061
+ assertEquals(hops.events, [
1062
+ `open ${inbox}`,
1063
+ `fetch #1 ${inbox}`,
1064
+ "cancel #1",
1065
+ `close ${inbox}`
1066
+ ]);
1067
+ hops.assertInvariants();
1068
+ });
1069
+ });
1070
+ test("sendActivity() completes with responses cloned by fetch-mock", async (t) => {
1071
+ for (const signed of [true, false]) await t.step(`signed: ${signed}`, async () => {
1072
+ esm_default.spyGlobal();
1073
+ try {
1074
+ esm_default.post("https://example.com/tee/inbox", () => new Response("Moved", {
1075
+ status: 307,
1076
+ headers: { Location: "https://example.com/tee/final" }
1077
+ }));
1078
+ esm_default.post("https://example.com/tee/final", () => new Response("Accepted", { status: 202 }));
1079
+ esm_default.post("https://example.com/tee/error", () => new Response("x".repeat(4096), { status: 404 }));
1080
+ const keys = signed ? signedKeys : unsignedKeys;
1081
+ await sendDirect({
1082
+ activity: lifecycleActivity,
1083
+ keys,
1084
+ inbox: new URL("https://example.com/tee/inbox")
1085
+ });
1086
+ assertEquals(esm_default.callHistory.calls("https://example.com/tee/final").length, 1);
1087
+ const error = await assertRejects(() => sendDirect({
1088
+ activity: lifecycleActivity,
1089
+ keys,
1090
+ inbox: new URL("https://example.com/tee/error")
1091
+ }), SendActivityError);
1092
+ assertEquals(error.statusCode, 404);
1093
+ assertEquals(error.responseBody, "x".repeat(1024) + "… (truncated)");
1094
+ } finally {
1095
+ esm_default.hardReset();
1096
+ }
1097
+ });
1098
+ });
1099
+ function createRecordedResponse(events, index, init, cancelSuffix = () => "") {
1100
+ const bytes = new TextEncoder().encode(init.body ?? "");
1101
+ const chunkSize = init.chunkSize ?? 256;
1102
+ let offset = 0;
1103
+ let settled = false;
1104
+ const body = new ReadableStream({
1105
+ pull(controller) {
1106
+ if (offset >= bytes.length) {
1107
+ settled = true;
1108
+ events.push(`consumed #${index}`);
1109
+ controller.close();
1110
+ return;
1111
+ }
1112
+ controller.enqueue(bytes.slice(offset, offset + chunkSize));
1113
+ offset += chunkSize;
1114
+ },
1115
+ cancel() {
1116
+ settled = true;
1117
+ events.push(`cancel #${index}${cancelSuffix()}`);
1118
+ if (init.failCancel) throw new Error(`cancel #${index} failed`);
1119
+ if (init.hangCancel) return new Promise(() => {});
1120
+ }
1121
+ }, { highWaterMark: 0 });
1122
+ return {
1123
+ response: new Response(body, {
1124
+ status: init.status ?? 200,
1125
+ headers: init.headers
1126
+ }),
1127
+ settled: () => settled
1128
+ };
1129
+ }
1130
+ function recordHops(respond) {
1131
+ const events = [];
1132
+ const requests = [];
1133
+ const unsettledAtClose = [];
1134
+ let opens = 0;
1135
+ let closes = 0;
1136
+ let fetches = 0;
1137
+ return {
1138
+ events,
1139
+ requests,
1140
+ openHop(url) {
1141
+ opens++;
1142
+ events.push(`open ${url}`);
1143
+ const tracked = [];
1144
+ let closed = false;
1145
+ let closing = false;
1146
+ return Promise.resolve({
1147
+ url: new URL(url),
1148
+ async fetch(input, init) {
1149
+ if (closed) throw new TypeError("The session is closed.");
1150
+ const request = new Request(input instanceof Request ? input.clone() : input, init);
1151
+ const index = ++fetches;
1152
+ requests.push(request);
1153
+ events.push(`fetch #${index} ${request.url}`);
1154
+ const recorded = createRecordedResponse(events, index, await respond(request, index), () => closing ? " by close" : "");
1155
+ tracked.push({
1156
+ ...recorded,
1157
+ index
1158
+ });
1159
+ return recorded.response;
1160
+ },
1161
+ close() {
1162
+ if (closed) return Promise.resolve();
1163
+ closed = true;
1164
+ closes++;
1165
+ closing = true;
1166
+ for (const { response, settled, index } of tracked) {
1167
+ if (settled()) continue;
1168
+ if (response.body?.locked) unsettledAtClose.push(index);
1169
+ else response.body?.cancel().catch(() => {});
1170
+ }
1171
+ events.push(`close ${url}`);
1172
+ return Promise.resolve();
1173
+ }
1174
+ });
1175
+ },
1176
+ assertInvariants() {
1177
+ assertEquals(opens, closes);
1178
+ assertEquals(unsettledAtClose, []);
1179
+ }
1180
+ };
1181
+ }
772
1182
  //#endregion
773
1183
  export {};