@fedify/fedify 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.
Files changed (86) hide show
  1. package/dist/{builder-Mg2G3qj8.mjs → builder-isMRh_I6.mjs} +3 -3
  2. package/dist/{client-CAM_bQXx.d.cts → client-BIBzqEIp.d.cts} +0 -1
  3. package/dist/{client-CjDMsn4a.mjs → client-BZr7TGyu.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-DLLaDau8.d.ts → context-C0uGVzgk.d.ts} +28 -2
  9. package/dist/{context-BNwv9Q1v.d.cts → context-CLaLxi49.d.cts} +28 -2
  10. package/dist/{deno-BiYv5RT5.mjs → deno-CiaJqTcu.mjs} +1 -1
  11. package/dist/docloader-DOrwedT7.mjs +124 -0
  12. package/dist/federation/builder.test.mjs +1 -1
  13. package/dist/federation/handler.test.mjs +6 -6
  14. package/dist/federation/idempotency.test.mjs +2 -2
  15. package/dist/federation/inbox.test.mjs +1 -1
  16. package/dist/federation/keycache.test.mjs +18 -7
  17. package/dist/federation/middleware.test.mjs +380 -24
  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 +445 -70
  23. package/dist/federation/temporal.test.mjs +1 -1
  24. package/dist/federation/webfinger.test.mjs +1 -1
  25. package/dist/{http-Dl00HXr4.d.ts → http-9IFUxdkp.d.cts} +1 -2
  26. package/dist/{http-C1ga9WQu.mjs → http-A9mP2uM7.mjs} +194 -96
  27. package/dist/{http-Cginz7L0.cjs → http-CRiVlZd5.cjs} +188 -105
  28. package/dist/{http-B3rSb2GQ.js → http-Crml3gei.js} +187 -104
  29. package/dist/{http-DqZhyEso.d.cts → http-SbfezERn.d.ts} +1 -2
  30. package/dist/{inbox-BWzL0xMU.mjs → inbox-KESLudq4.mjs} +1 -1
  31. package/dist/{key-O-ATwIGS.mjs → key-Dg6-DV_O.mjs} +17 -3
  32. package/dist/{keycache-RFlZObU3.mjs → keycache-DahCaG_m.mjs} +1 -1
  33. package/dist/{kv-cache-BdylAE9w.cjs → kv-cache-Cqmm-yQa.cjs} +59 -15
  34. package/dist/{kv-cache-8Kg2CHiD.js → kv-cache-DQcyZL5T.js} +60 -16
  35. package/dist/{ld-C-fCGM-z.mjs → ld-COjDN6Nb.mjs} +2 -2
  36. package/dist/{middleware-CVRFS7PZ.cjs → middleware-BbgR7xOI.cjs} +185 -60
  37. package/dist/{middleware-VvrqPwHL.cjs → middleware-BtDQUCcz.cjs} +1 -1
  38. package/dist/{middleware-C9UCWOqw.mjs → middleware-COFlKeYn.mjs} +79 -39
  39. package/dist/{middleware-CxgFiKI3.mjs → middleware-CuQvczZd.mjs} +1 -1
  40. package/dist/{middleware-41MkquTV.js → middleware-DIW5S_5L.js} +185 -60
  41. package/dist/{mod-DNEgTI8M.d.ts → mod-BRnwh2oy.d.ts} +2 -7
  42. package/dist/{mod-BLDHhuW0.d.ts → mod-C7w-QVUv.d.ts} +1 -1
  43. package/dist/{mod-CWJNbyju.d.ts → mod-CFRocFeQ.d.ts} +1 -1
  44. package/dist/{mod-BmlfRBmU.d.cts → mod-CGUH8Ujp.d.cts} +1 -1
  45. package/dist/{mod-Cn9GJ5ex.d.cts → mod-Co7LEK9X.d.cts} +2 -7
  46. package/dist/{mod-DiuFTYUt.d.cts → mod-LOEOKbYd.d.cts} +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-C8IVASD3.mjs → owner-CFHq61cq.mjs} +2 -2
  63. package/dist/{proof-BbHMAkPG.cjs → proof-0JkSC_pT.cjs} +1 -1
  64. package/dist/{proof-cGfRPTiV.js → proof-DzksJFK7.js} +1 -1
  65. package/dist/{proof-CUdcBgvg.mjs → proof-KSHlbPCG.mjs} +2 -2
  66. package/dist/{send-BoV4dyVO.mjs → send-DaDe2YCb.mjs} +117 -32
  67. package/dist/sig/http.test.mjs +507 -3
  68. package/dist/sig/key.test.mjs +197 -3
  69. package/dist/sig/ld.test.mjs +47 -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 +46 -1
  75. package/dist/sig/proof.test.mjs +52 -1
  76. package/dist/{temporal-BJlnku_d.mjs → temporal-DNGH8o2E.mjs} +1 -1
  77. package/dist/testing/mod.d.mts +26 -1
  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 +380 -32
  81. package/dist/utils/mod.cjs +1 -1
  82. package/dist/utils/mod.d.cts +1 -1
  83. package/dist/utils/mod.d.ts +1 -1
  84. package/dist/utils/mod.js +1 -1
  85. package/package.json +7 -7
  86. package/dist/docloader-DrxXBFL8.mjs +0 -80
@@ -7,13 +7,15 @@ import { i as assertExists, t as assertStringIncludes } from "../std__assert-Dfl
7
7
  import { r as assertFalse, t as assertThrows } from "../assert_throws-D4DNWrux.mjs";
8
8
  import { t as assert } from "../assert-BhQd8QHP.mjs";
9
9
  import { t as assertRejects } from "../assert_rejects-WyYSTDDX.mjs";
10
- import { t as exportJwk } from "../key-O-ATwIGS.mjs";
11
- import { a as parseRfc9421Signature, c as timingSafeEqual, i as formatRfc9421SignatureParameters, l as verifyRequest, n as doubleKnock, o as parseRfc9421SignatureInput, r as formatRfc9421Signature, s as signRequest, t as createRfc9421SignatureBase, u as verifyRequestDetailed } from "../http-C1ga9WQu.mjs";
10
+ import { t as exportJwk } from "../key-Dg6-DV_O.mjs";
11
+ import { a as formatRfc9421SignatureParameters, c as signRequest, d as verifyRequestDetailed, i as formatRfc9421Signature, l as timingSafeEqual, n as doubleKnock, o as parseRfc9421Signature, r as doubleKnockScoped, s as parseRfc9421SignatureInput, t as createRfc9421SignatureBase, u as verifyRequest } from "../http-A9mP2uM7.mjs";
12
12
  import { i as rsaPrivateKey2, l as rsaPublicKey5, o as rsaPublicKey1, s as rsaPublicKey2 } from "../keys-CtZLJq76.mjs";
13
- import { t as KvKeyCache } from "../keycache-RFlZObU3.mjs";
13
+ import { t as KvKeyCache } from "../keycache-DahCaG_m.mjs";
14
14
  import { t as MemoryKvStore } from "../kv-HvthQAHr.mjs";
15
15
  import { createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
16
16
  import { FetchError, UrlError, exportSpki } from "@fedify/vocab-runtime";
17
+ import { openDirectFetch } from "@fedify/vocab-runtime/internal";
18
+ import { configure, reset } from "@logtape/logtape";
17
19
  import { encodeBase64 } from "byte-encodings/base64";
18
20
  //#region src/sig/http.test.ts
19
21
  test("signRequest() [draft-cavage]", async () => {
@@ -1239,6 +1241,425 @@ test("doubleKnock() async specDeterminer test", async () => {
1239
1241
  assertEquals(specUsed, "draft-cavage-http-signatures-12", "Should use spec from async determiner");
1240
1242
  esm_default.hardReset();
1241
1243
  });
1244
+ const lifecycleIdentity = {
1245
+ keyId: rsaPublicKey2.id,
1246
+ privateKey: rsaPrivateKey2
1247
+ };
1248
+ test("doubleKnockScoped() keeps challenge retries and fallback in the validated hop", async () => {
1249
+ const url = "https://example.com/lifecycle/challenge-fallback";
1250
+ const hops = recordHops((_, index) => index === 1 ? {
1251
+ status: 401,
1252
+ headers: { "Accept-Signature": "challenge=(\"@method\");nonce=\"challenge-nonce\"" },
1253
+ body: "Challenge"
1254
+ } : index === 2 ? {
1255
+ status: 401,
1256
+ body: "Unauthorized"
1257
+ } : {
1258
+ status: 202,
1259
+ body: "Accepted"
1260
+ });
1261
+ assertEquals(await doubleKnockScoped(new Request(url, {
1262
+ method: "POST",
1263
+ body: "activity"
1264
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), "Accepted");
1265
+ assertEquals(hops.events, [
1266
+ `open ${url}`,
1267
+ `fetch #1 ${url}`,
1268
+ "cancel #1",
1269
+ `fetch #2 ${url}`,
1270
+ "cancel #2",
1271
+ `fetch #3 ${url}`,
1272
+ "consumed #3",
1273
+ `close ${url}`
1274
+ ]);
1275
+ assertStringIncludes(hops.requests[1].headers.get("Signature-Input"), "nonce=\"challenge-nonce\"");
1276
+ assertFalse(hops.requests[2].headers.has("Signature-Input"));
1277
+ hops.assertInvariants();
1278
+ });
1279
+ test("doubleKnockScoped() reopens the session after challenge redirects", async () => {
1280
+ const url = "https://example.com/lifecycle/challenge-redirect";
1281
+ const next = "https://other.example/lifecycle/final";
1282
+ const validated = [];
1283
+ const hops = recordHops((_, index) => index === 1 ? {
1284
+ status: 401,
1285
+ headers: { "Accept-Signature": "challenge=(\"@method\");created" },
1286
+ body: "Challenge"
1287
+ } : index === 2 ? {
1288
+ status: 307,
1289
+ headers: { Location: next },
1290
+ body: "Redirect"
1291
+ } : {
1292
+ status: 200,
1293
+ body: "Done"
1294
+ });
1295
+ assertEquals(await doubleKnockScoped(new Request(url, {
1296
+ method: "POST",
1297
+ body: "activity"
1298
+ }), lifecycleIdentity, { validateRedirect: (target) => {
1299
+ validated.push(target);
1300
+ } }, hops.openHop, (response) => response.text()), "Done");
1301
+ assertEquals(validated, [next]);
1302
+ assertEquals(hops.events, [
1303
+ `open ${url}`,
1304
+ `fetch #1 ${url}`,
1305
+ "cancel #1",
1306
+ `fetch #2 ${url}`,
1307
+ "cancel #2",
1308
+ `close ${url}`,
1309
+ `open ${next}`,
1310
+ `fetch #3 ${next}`,
1311
+ "consumed #3",
1312
+ `close ${next}`
1313
+ ]);
1314
+ assertEquals(await hops.requests[2].text(), "activity");
1315
+ hops.assertInvariants();
1316
+ });
1317
+ test("doubleKnockScoped() detects loops after challenge redirects", async () => {
1318
+ const url = "https://example.com/lifecycle/challenge-loop";
1319
+ const hops = recordHops((_, index) => index === 1 ? {
1320
+ status: 401,
1321
+ headers: { "Accept-Signature": "challenge=(\"@method\");created" },
1322
+ body: "Challenge"
1323
+ } : {
1324
+ status: 307,
1325
+ headers: { Location: url },
1326
+ body: "Redirect"
1327
+ });
1328
+ await assertRejects(() => doubleKnockScoped(new Request(url, {
1329
+ method: "POST",
1330
+ body: "activity"
1331
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), FetchError, "Redirect loop detected");
1332
+ assertEquals(hops.requests.length, 2);
1333
+ assertEquals(hops.events, [
1334
+ `open ${url}`,
1335
+ `fetch #1 ${url}`,
1336
+ "cancel #1",
1337
+ `fetch #2 ${url}`,
1338
+ "cancel #2",
1339
+ `close ${url}`
1340
+ ]);
1341
+ hops.assertInvariants();
1342
+ });
1343
+ test("doubleKnockScoped() retries another spec on the same hop", async () => {
1344
+ const url = "https://example.com/lifecycle/retry";
1345
+ const hops = recordHops((_, index) => index === 1 ? {
1346
+ status: 401,
1347
+ body: "Unauthorized"
1348
+ } : {
1349
+ status: 202,
1350
+ body: "Accepted"
1351
+ });
1352
+ assertEquals(await doubleKnockScoped(new Request(url, {
1353
+ method: "POST",
1354
+ body: "activity"
1355
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), "Accepted");
1356
+ assertEquals(hops.events, [
1357
+ `open ${url}`,
1358
+ `fetch #1 ${url}`,
1359
+ "cancel #1",
1360
+ `fetch #2 ${url}`,
1361
+ "consumed #2",
1362
+ `close ${url}`
1363
+ ]);
1364
+ assertEquals(hops.requests.map((r) => r.headers.has("Signature-Input")), [true, false]);
1365
+ hops.assertInvariants();
1366
+ });
1367
+ test("doubleKnockScoped() closes the retried hop before a redirect", async () => {
1368
+ const url = "https://example.com/lifecycle/retry-redirect";
1369
+ const next = "https://example.com/lifecycle/final";
1370
+ const hops = recordHops((_, index) => index === 1 ? {
1371
+ status: 401,
1372
+ body: "Unauthorized"
1373
+ } : index === 2 ? {
1374
+ status: 302,
1375
+ headers: { Location: next },
1376
+ body: "Found"
1377
+ } : {
1378
+ status: 200,
1379
+ body: "Done"
1380
+ });
1381
+ assertEquals(await doubleKnockScoped(new Request(url, {
1382
+ method: "POST",
1383
+ body: "activity"
1384
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), "Done");
1385
+ assertEquals(hops.events, [
1386
+ `open ${url}`,
1387
+ `fetch #1 ${url}`,
1388
+ "cancel #1",
1389
+ `fetch #2 ${url}`,
1390
+ "cancel #2",
1391
+ `close ${url}`,
1392
+ `open ${next}`,
1393
+ `fetch #3 ${next}`,
1394
+ "consumed #3",
1395
+ `close ${next}`
1396
+ ]);
1397
+ assertEquals(await hops.requests[2].text(), "activity");
1398
+ hops.assertInvariants();
1399
+ });
1400
+ test("doubleKnockScoped() retries transport errors on the same hop", async (t) => {
1401
+ await t.step("GET", async () => {
1402
+ const url = "https://example.com/lifecycle/flaky";
1403
+ const hops = recordHops((_, index) => {
1404
+ if (index === 1) throw new TypeError("connection reset");
1405
+ return { body: "Success" };
1406
+ });
1407
+ assertEquals(await doubleKnockScoped(new Request(url), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), "Success");
1408
+ assertEquals(hops.events, [
1409
+ `open ${url}`,
1410
+ `fetch #1 ${url}`,
1411
+ `fetch #2 ${url}`,
1412
+ "consumed #2",
1413
+ `close ${url}`
1414
+ ]);
1415
+ hops.assertInvariants();
1416
+ });
1417
+ await t.step("POST", async () => {
1418
+ const url = "https://example.com/lifecycle/flaky-inbox";
1419
+ const hops = recordHops(() => {
1420
+ throw new TypeError("connection reset");
1421
+ });
1422
+ await assertRejects(() => doubleKnockScoped(new Request(url, {
1423
+ method: "POST",
1424
+ body: "activity"
1425
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), FetchError);
1426
+ assertEquals(hops.events, [
1427
+ `open ${url}`,
1428
+ `fetch #1 ${url}`,
1429
+ `close ${url}`
1430
+ ]);
1431
+ hops.assertInvariants();
1432
+ });
1433
+ });
1434
+ async function captureLogs(fn) {
1435
+ const records = [];
1436
+ await configure({
1437
+ sinks: { buffer: (record) => records.push(record) },
1438
+ loggers: [{
1439
+ category: "fedify",
1440
+ sinks: ["buffer"],
1441
+ lowestLevel: "debug"
1442
+ }, {
1443
+ category: ["logtape", "meta"],
1444
+ sinks: []
1445
+ }],
1446
+ reset: true
1447
+ });
1448
+ try {
1449
+ await fn();
1450
+ await new Promise((resolve) => setTimeout(resolve, 0));
1451
+ } finally {
1452
+ await reset();
1453
+ }
1454
+ return records;
1455
+ }
1456
+ test("doubleKnockScoped() logs a redirect body failing to cancel and follows the redirect", async () => {
1457
+ const url = "https://example.com/lifecycle/cancel-failure";
1458
+ const next = "https://example.com/lifecycle/cancel-failure-final";
1459
+ const hops = recordHops((_, index) => index === 1 ? {
1460
+ status: 302,
1461
+ headers: { Location: next },
1462
+ body: "Found",
1463
+ failCancel: true
1464
+ } : {
1465
+ status: 200,
1466
+ body: "Done"
1467
+ });
1468
+ let result;
1469
+ const records = await captureLogs(async () => {
1470
+ result = await doubleKnockScoped(new Request(url), lifecycleIdentity, {}, hops.openHop, (response) => response.text());
1471
+ });
1472
+ assertEquals(result, "Done");
1473
+ assertEquals(hops.events, [
1474
+ `open ${url}`,
1475
+ `fetch #1 ${url}`,
1476
+ "cancel #1",
1477
+ `close ${url}`,
1478
+ `open ${next}`,
1479
+ `fetch #2 ${next}`,
1480
+ "consumed #2",
1481
+ `close ${next}`
1482
+ ]);
1483
+ const failures = records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}");
1484
+ assertEquals(failures.map((r) => [r.level, r.properties.url]), [["debug", url]]);
1485
+ assertEquals(failures[0].properties.error.message, "cancel #1 failed");
1486
+ hops.assertInvariants();
1487
+ });
1488
+ test("doubleKnockScoped() is not blocked by a body cancellation that never settles", async () => {
1489
+ const url = "https://example.com/lifecycle/hanging-cancel";
1490
+ const next = "https://example.com/lifecycle/hanging-cancel-final";
1491
+ const hops = recordHops((_, index) => index === 1 ? {
1492
+ status: 401,
1493
+ body: "Unauthorized",
1494
+ hangCancel: true
1495
+ } : index === 2 ? {
1496
+ status: 302,
1497
+ headers: { Location: next },
1498
+ body: "Found",
1499
+ hangCancel: true
1500
+ } : {
1501
+ status: 200,
1502
+ body: "Done",
1503
+ hangCancel: true
1504
+ });
1505
+ assertEquals(await doubleKnockScoped(new Request(url, {
1506
+ method: "POST",
1507
+ body: "activity"
1508
+ }), lifecycleIdentity, {}, hops.openHop, (response) => Promise.resolve(response.status)), 200);
1509
+ assertEquals(hops.events, [
1510
+ `open ${url}`,
1511
+ `fetch #1 ${url}`,
1512
+ "cancel #1",
1513
+ `fetch #2 ${url}`,
1514
+ "cancel #2",
1515
+ `close ${url}`,
1516
+ `open ${next}`,
1517
+ `fetch #3 ${next}`,
1518
+ "cancel #3 by close",
1519
+ `close ${next}`
1520
+ ]);
1521
+ hops.assertInvariants();
1522
+ });
1523
+ test("doubleKnockScoped() completes with responses cloned by fetch-mock", async () => {
1524
+ esm_default.spyGlobal();
1525
+ try {
1526
+ let attempts = 0;
1527
+ esm_default.post("https://example.com/tee/redirect", () => {
1528
+ attempts++;
1529
+ return attempts === 1 ? new Response("Unauthorized", { status: 401 }) : new Response("Found", {
1530
+ status: 302,
1531
+ headers: { Location: "https://example.com/tee/final" }
1532
+ });
1533
+ });
1534
+ esm_default.post("https://example.com/tee/final", () => new Response("Accepted", { status: 202 }));
1535
+ const request = () => new Request("https://example.com/tee/redirect", {
1536
+ method: "POST",
1537
+ body: "activity"
1538
+ });
1539
+ assertEquals(await doubleKnockScoped(request(), lifecycleIdentity, {}, openDirectFetch, (response) => Promise.resolve(response.status)), 202);
1540
+ assertEquals(attempts, 2);
1541
+ attempts = 0;
1542
+ const response = await doubleKnock(request(), lifecycleIdentity);
1543
+ assertEquals(response.status, 202);
1544
+ assertEquals(await response.text(), "Accepted");
1545
+ assertEquals(attempts, 2);
1546
+ } finally {
1547
+ esm_default.hardReset();
1548
+ }
1549
+ });
1550
+ test("doubleKnockScoped() closes the session when consuming fails", async (t) => {
1551
+ const url = "https://example.com/lifecycle/consume";
1552
+ await t.step("exception", async () => {
1553
+ const hops = recordHops(() => ({ body: "x".repeat(1024) }));
1554
+ const error = /* @__PURE__ */ new Error("consume failed");
1555
+ await assertRejects(() => doubleKnockScoped(new Request(url), lifecycleIdentity, {}, hops.openHop, async (response) => {
1556
+ const reader = response.body.getReader();
1557
+ await reader.read();
1558
+ reader.releaseLock();
1559
+ throw error;
1560
+ }), Error, "consume failed");
1561
+ assertEquals(hops.events, [
1562
+ `open ${url}`,
1563
+ `fetch #1 ${url}`,
1564
+ "cancel #1 by close",
1565
+ `close ${url}`
1566
+ ]);
1567
+ hops.assertInvariants();
1568
+ });
1569
+ await t.step("abort", async () => {
1570
+ const hops = recordHops(() => ({ body: "Success" }));
1571
+ const controller = new AbortController();
1572
+ const reason = /* @__PURE__ */ new Error("aborted while consuming");
1573
+ assert(await assertRejects(() => doubleKnockScoped(new Request(url), lifecycleIdentity, { signal: controller.signal }, hops.openHop, () => {
1574
+ controller.abort(reason);
1575
+ controller.signal.throwIfAborted();
1576
+ return Promise.resolve();
1577
+ })) === reason);
1578
+ assertEquals(hops.events, [
1579
+ `open ${url}`,
1580
+ `fetch #1 ${url}`,
1581
+ "cancel #1 by close",
1582
+ `close ${url}`
1583
+ ]);
1584
+ hops.assertInvariants();
1585
+ });
1586
+ });
1587
+ test("doubleKnock() returns the final response without cancelling its body", async () => {
1588
+ const url = "https://example.com/lifecycle/public";
1589
+ const next = "https://example.com/lifecycle/public-final";
1590
+ const events = [];
1591
+ const responses = [];
1592
+ const originalFetch = globalThis.fetch;
1593
+ globalThis.fetch = ((input) => {
1594
+ const requestUrl = input instanceof Request ? input.url : String(input);
1595
+ const index = responses.length + 1;
1596
+ events.push(`fetch #${index} ${requestUrl}`);
1597
+ const recorded = createRecordedResponse(events, index, requestUrl === url ? {
1598
+ status: 302,
1599
+ headers: { Location: next },
1600
+ body: "Found"
1601
+ } : {
1602
+ status: 200,
1603
+ body: "Done"
1604
+ });
1605
+ responses.push(recorded);
1606
+ return Promise.resolve(recorded.response);
1607
+ });
1608
+ try {
1609
+ const response = await doubleKnock(new Request(url), lifecycleIdentity);
1610
+ assertEquals(events, [
1611
+ `fetch #1 ${url}`,
1612
+ "cancel #1",
1613
+ `fetch #2 ${next}`
1614
+ ]);
1615
+ assertFalse(responses[1].settled());
1616
+ assertEquals(await response.text(), "Done");
1617
+ assertEquals(events.at(-1), "consumed #2");
1618
+ } finally {
1619
+ globalThis.fetch = originalFetch;
1620
+ }
1621
+ });
1622
+ test("doubleKnock() is not blocked by a body cancellation that never settles", async () => {
1623
+ const url = "https://example.com/lifecycle/public-hanging";
1624
+ const next = "https://example.com/lifecycle/public-hanging-final";
1625
+ const events = [];
1626
+ const originalFetch = globalThis.fetch;
1627
+ let index = 0;
1628
+ globalThis.fetch = ((input) => {
1629
+ const requestUrl = input instanceof Request ? input.url : String(input);
1630
+ index++;
1631
+ events.push(`fetch #${index} ${requestUrl}`);
1632
+ return Promise.resolve(createRecordedResponse(events, index, index === 1 ? {
1633
+ status: 401,
1634
+ body: "Unauthorized",
1635
+ hangCancel: true
1636
+ } : index === 2 ? {
1637
+ status: 302,
1638
+ headers: { Location: next },
1639
+ body: "Found",
1640
+ hangCancel: true
1641
+ } : {
1642
+ status: 200,
1643
+ body: "Done"
1644
+ }).response);
1645
+ });
1646
+ try {
1647
+ const response = await doubleKnock(new Request(url, {
1648
+ method: "POST",
1649
+ body: "activity"
1650
+ }), lifecycleIdentity);
1651
+ assertEquals(events, [
1652
+ `fetch #1 ${url}`,
1653
+ "cancel #1",
1654
+ `fetch #2 ${url}`,
1655
+ "cancel #2",
1656
+ `fetch #3 ${next}`
1657
+ ]);
1658
+ assertEquals(await response.text(), "Done");
1659
+ } finally {
1660
+ globalThis.fetch = originalFetch;
1661
+ }
1662
+ });
1242
1663
  test("timingSafeEqual()", async (t) => {
1243
1664
  await t.step("should return true for equal empty arrays", () => {
1244
1665
  assert(timingSafeEqual(new Uint8Array([]), new Uint8Array([])));
@@ -1945,5 +2366,88 @@ test("doubleKnock(): Accept-Signature with multiple compatible entries fulfills
1945
2366
  assertEquals(requestCount, 2);
1946
2367
  esm_default.hardReset();
1947
2368
  });
2369
+ function createRecordedResponse(events, index, init, cancelSuffix = () => "") {
2370
+ const bytes = new TextEncoder().encode(init.body ?? "");
2371
+ const chunkSize = init.chunkSize ?? 256;
2372
+ let offset = 0;
2373
+ let settled = false;
2374
+ const body = new ReadableStream({
2375
+ pull(controller) {
2376
+ if (offset >= bytes.length) {
2377
+ settled = true;
2378
+ events.push(`consumed #${index}`);
2379
+ controller.close();
2380
+ return;
2381
+ }
2382
+ controller.enqueue(bytes.slice(offset, offset + chunkSize));
2383
+ offset += chunkSize;
2384
+ },
2385
+ cancel() {
2386
+ settled = true;
2387
+ events.push(`cancel #${index}${cancelSuffix()}`);
2388
+ if (init.failCancel) throw new Error(`cancel #${index} failed`);
2389
+ if (init.hangCancel) return new Promise(() => {});
2390
+ }
2391
+ }, { highWaterMark: 0 });
2392
+ return {
2393
+ response: new Response(body, {
2394
+ status: init.status ?? 200,
2395
+ headers: init.headers
2396
+ }),
2397
+ settled: () => settled
2398
+ };
2399
+ }
2400
+ function recordHops(respond) {
2401
+ const events = [];
2402
+ const requests = [];
2403
+ const unsettledAtClose = [];
2404
+ let opens = 0;
2405
+ let closes = 0;
2406
+ let fetches = 0;
2407
+ return {
2408
+ events,
2409
+ requests,
2410
+ openHop(url) {
2411
+ opens++;
2412
+ events.push(`open ${url}`);
2413
+ const tracked = [];
2414
+ let closed = false;
2415
+ let closing = false;
2416
+ return Promise.resolve({
2417
+ url: new URL(url),
2418
+ async fetch(input, init) {
2419
+ if (closed) throw new TypeError("The session is closed.");
2420
+ const request = new Request(input instanceof Request ? input.clone() : input, init);
2421
+ const index = ++fetches;
2422
+ requests.push(request);
2423
+ events.push(`fetch #${index} ${request.url}`);
2424
+ const recorded = createRecordedResponse(events, index, await respond(request, index), () => closing ? " by close" : "");
2425
+ tracked.push({
2426
+ ...recorded,
2427
+ index
2428
+ });
2429
+ return recorded.response;
2430
+ },
2431
+ close() {
2432
+ if (closed) return Promise.resolve();
2433
+ closed = true;
2434
+ closes++;
2435
+ closing = true;
2436
+ for (const { response, settled, index } of tracked) {
2437
+ if (settled()) continue;
2438
+ if (response.body?.locked) unsettledAtClose.push(index);
2439
+ else response.body?.cancel().catch(() => {});
2440
+ }
2441
+ events.push(`close ${url}`);
2442
+ return Promise.resolve();
2443
+ }
2444
+ });
2445
+ },
2446
+ assertInvariants() {
2447
+ assertEquals(opens, closes);
2448
+ assertEquals(unsettledAtClose, []);
2449
+ }
2450
+ };
2451
+ }
1948
2452
  //#endregion
1949
2453
  export {};