@fedify/fedify 2.2.18 → 2.2.20

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-DAbQyxmb.mjs → builder-BolQ56ab.mjs} +2 -2
  2. package/dist/{client-CAM_bQXx.d.cts → client-BIBzqEIp.d.cts} +0 -1
  3. package/dist/{client-9F_u23dc.mjs → client-DhxaVgOv.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-tJbL_Hkw.d.cts → context-CFQGaq5x.d.cts} +28 -2
  9. package/dist/{context-OkwEpc3-.d.ts → context-DdbLVV_k.d.ts} +28 -2
  10. package/dist/{deno-JUTr6LYC.mjs → deno-BUj4roJc.mjs} +1 -1
  11. package/dist/docloader-CbtF_0cD.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/keycache.test.mjs +18 -7
  17. package/dist/federation/middleware.test.mjs +389 -28
  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-OSYYhKM8.js → http-C5g_VrA_.js} +188 -105
  27. package/dist/{http-lCmKCZBI.mjs → http-CQMVisM0.mjs} +195 -97
  28. package/dist/{http-DEH_eppX.cjs → http-D8I7LBfx.cjs} +189 -106
  29. package/dist/{http-DqZhyEso.d.cts → http-SbfezERn.d.ts} +1 -2
  30. package/dist/{inbox-DMATfdEI.mjs → inbox-CD8IDILN.mjs} +1 -1
  31. package/dist/{key-D0ahOVjf.mjs → key-C3o3DGap.mjs} +17 -3
  32. package/dist/{keycache-Dkc_sflV.mjs → keycache-BimEQucx.mjs} +1 -1
  33. package/dist/{kv-cache-B7X4p1y7.js → kv-cache-CSKXXleR.js} +60 -16
  34. package/dist/{kv-cache-CPEV_4nZ.cjs → kv-cache-DC9LcfoC.cjs} +59 -15
  35. package/dist/{ld-CJOXIehz.mjs → ld-ijSr-0AB.mjs} +2 -2
  36. package/dist/{middleware-CHoUjmJm.mjs → middleware-CYAaUTi5.mjs} +79 -39
  37. package/dist/{middleware-QcuVRA-q.cjs → middleware-DNueR0GD.cjs} +185 -60
  38. package/dist/{middleware-D3hx1aiI.cjs → middleware-DmcY2TWS.cjs} +1 -1
  39. package/dist/{middleware-aNUajYPY.js → middleware-sjhz30sm.js} +185 -60
  40. package/dist/{middleware-BgGJEcdp.mjs → middleware-yqqfI7oJ.mjs} +1 -1
  41. package/dist/{mod-B6LmugJ4.d.ts → mod-BRnwh2oy.d.ts} +2 -8
  42. package/dist/{mod-D2JyWtW3.d.cts → mod-BYWp77-z.d.cts} +1 -1
  43. package/dist/{mod-DQGjRJUG.d.ts → mod-BuRRdsfD.d.ts} +1 -1
  44. package/dist/{mod-ej_rLx8h.d.ts → mod-CM5_J_cx.d.ts} +1 -1
  45. package/dist/{mod-a6kmB8xD.d.cts → mod-CVSKw-5H.d.cts} +1 -1
  46. package/dist/{mod-V2vDnYNu.d.cts → mod-Co7LEK9X.d.cts} +2 -8
  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-BUTMEkbn.mjs → owner-B8kXzD0q.mjs} +2 -2
  63. package/dist/{proof-DS1Zl6BX.mjs → proof-CBQOjOgA.mjs} +2 -2
  64. package/dist/{proof-Bm3xyn3D.js → proof-DKX6nASL.js} +1 -1
  65. package/dist/{proof-BeY7TsiS.cjs → proof-qiuGJCD7.cjs} +1 -1
  66. package/dist/{send-fnPrh9Zm.mjs → send-DRpisX_f.mjs} +117 -32
  67. package/dist/sig/http.test.mjs +529 -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-Biz6zK6e.mjs → temporal-BQGEINad.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 +382 -34
  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 +6 -6
  86. package/dist/docloader-BGjq1F5U.mjs +0 -81
@@ -7,13 +7,15 @@ import { r as assertExists, t as assertStringIncludes } from "../std__assert-DNZ
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-D0ahOVjf.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-lCmKCZBI.mjs";
10
+ import { t as exportJwk } from "../key-C3o3DGap.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-CQMVisM0.mjs";
12
12
  import { i as rsaPrivateKey2, l as rsaPublicKey5, o as rsaPublicKey1, s as rsaPublicKey2 } from "../keys-BAK-tUlf.mjs";
13
- import { t as KvKeyCache } from "../keycache-Dkc_sflV.mjs";
13
+ import { t as KvKeyCache } from "../keycache-BimEQucx.mjs";
14
14
  import { t as MemoryKvStore } from "../kv-mI4Ix2EU.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 () => {
@@ -1259,6 +1261,447 @@ test("doubleKnock() async specDeterminer test", async () => {
1259
1261
  assertEquals(specUsed, "draft-cavage-http-signatures-12", "Should use spec from async determiner");
1260
1262
  esm_default.hardReset();
1261
1263
  });
1264
+ const lifecycleIdentity = {
1265
+ keyId: rsaPublicKey2.id,
1266
+ privateKey: rsaPrivateKey2
1267
+ };
1268
+ test("doubleKnockScoped() keeps challenge retries and fallback in the validated hop", async () => {
1269
+ const url = "https://example.com/lifecycle/challenge-fallback";
1270
+ const hops = recordHops((_, index) => index === 1 ? {
1271
+ status: 401,
1272
+ headers: { "Accept-Signature": "challenge=(\"@method\");nonce=\"challenge-nonce\"" },
1273
+ body: "Challenge"
1274
+ } : index === 2 ? {
1275
+ status: 401,
1276
+ body: "Unauthorized"
1277
+ } : {
1278
+ status: 202,
1279
+ body: "Accepted"
1280
+ });
1281
+ assertEquals(await doubleKnockScoped(new Request(url, {
1282
+ method: "POST",
1283
+ body: "activity"
1284
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), "Accepted");
1285
+ assertEquals(hops.events, [
1286
+ `open ${url}`,
1287
+ `fetch #1 ${url}`,
1288
+ "cancel #1",
1289
+ `fetch #2 ${url}`,
1290
+ "cancel #2",
1291
+ `fetch #3 ${url}`,
1292
+ "consumed #3",
1293
+ `close ${url}`
1294
+ ]);
1295
+ assertStringIncludes(hops.requests[1].headers.get("Signature-Input"), "nonce=\"challenge-nonce\"");
1296
+ assertFalse(hops.requests[2].headers.has("Signature-Input"));
1297
+ hops.assertInvariants();
1298
+ });
1299
+ test("doubleKnockScoped() reopens the session after challenge redirects", async () => {
1300
+ const url = "https://example.com/lifecycle/challenge-redirect";
1301
+ const next = "https://other.example/lifecycle/final";
1302
+ const validated = [];
1303
+ const hops = recordHops((_, index) => index === 1 ? {
1304
+ status: 401,
1305
+ headers: { "Accept-Signature": "challenge=(\"@method\");created" },
1306
+ body: "Challenge"
1307
+ } : index === 2 ? {
1308
+ status: 307,
1309
+ headers: { Location: next },
1310
+ body: "Redirect"
1311
+ } : {
1312
+ status: 200,
1313
+ body: "Done"
1314
+ });
1315
+ assertEquals(await doubleKnockScoped(new Request(url, {
1316
+ method: "POST",
1317
+ body: "activity"
1318
+ }), lifecycleIdentity, { validateRedirect: (target) => {
1319
+ validated.push(target);
1320
+ } }, hops.openHop, (response) => response.text()), "Done");
1321
+ assertEquals(validated, [next]);
1322
+ assertEquals(hops.events, [
1323
+ `open ${url}`,
1324
+ `fetch #1 ${url}`,
1325
+ "cancel #1",
1326
+ `fetch #2 ${url}`,
1327
+ "cancel #2",
1328
+ `close ${url}`,
1329
+ `open ${next}`,
1330
+ `fetch #3 ${next}`,
1331
+ "consumed #3",
1332
+ `close ${next}`
1333
+ ]);
1334
+ assertEquals(await hops.requests[2].text(), "activity");
1335
+ hops.assertInvariants();
1336
+ });
1337
+ test("doubleKnockScoped() preserves maxRedirection across challenge redirects", async (t) => {
1338
+ for (const maxRedirection of [0, 1]) await t.step(`limit ${maxRedirection}`, async () => {
1339
+ const url = "https://example.com/lifecycle/challenge-limit";
1340
+ const hops = recordHops((request, index) => index % 2 === 1 ? {
1341
+ status: 401,
1342
+ headers: { "Accept-Signature": "challenge=(\"@method\");created" },
1343
+ body: "Challenge"
1344
+ } : {
1345
+ status: 307,
1346
+ headers: { Location: `${request.url}/next` },
1347
+ body: "Redirect"
1348
+ });
1349
+ await assertRejects(() => doubleKnockScoped(new Request(url, {
1350
+ method: "POST",
1351
+ body: "activity"
1352
+ }), lifecycleIdentity, { maxRedirection }, hops.openHop, (response) => response.text()), FetchError, `Too many redirections (${maxRedirection + 1})`);
1353
+ assertEquals(hops.requests.length, 2 * (maxRedirection + 1));
1354
+ assertEquals(hops.events.filter((event) => event.startsWith("open ")).length, maxRedirection + 1);
1355
+ assertEquals(hops.events.filter((event) => event.startsWith("cancel #")).length, hops.requests.length);
1356
+ hops.assertInvariants();
1357
+ });
1358
+ });
1359
+ test("doubleKnockScoped() detects loops after challenge redirects", async () => {
1360
+ const url = "https://example.com/lifecycle/challenge-loop";
1361
+ const hops = recordHops((_, index) => index === 1 ? {
1362
+ status: 401,
1363
+ headers: { "Accept-Signature": "challenge=(\"@method\");created" },
1364
+ body: "Challenge"
1365
+ } : {
1366
+ status: 307,
1367
+ headers: { Location: url },
1368
+ body: "Redirect"
1369
+ });
1370
+ await assertRejects(() => doubleKnockScoped(new Request(url, {
1371
+ method: "POST",
1372
+ body: "activity"
1373
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), FetchError, "Redirect loop detected");
1374
+ assertEquals(hops.requests.length, 2);
1375
+ assertEquals(hops.events, [
1376
+ `open ${url}`,
1377
+ `fetch #1 ${url}`,
1378
+ "cancel #1",
1379
+ `fetch #2 ${url}`,
1380
+ "cancel #2",
1381
+ `close ${url}`
1382
+ ]);
1383
+ hops.assertInvariants();
1384
+ });
1385
+ test("doubleKnockScoped() retries another spec on the same hop", async () => {
1386
+ const url = "https://example.com/lifecycle/retry";
1387
+ const hops = recordHops((_, index) => index === 1 ? {
1388
+ status: 401,
1389
+ body: "Unauthorized"
1390
+ } : {
1391
+ status: 202,
1392
+ body: "Accepted"
1393
+ });
1394
+ assertEquals(await doubleKnockScoped(new Request(url, {
1395
+ method: "POST",
1396
+ body: "activity"
1397
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), "Accepted");
1398
+ assertEquals(hops.events, [
1399
+ `open ${url}`,
1400
+ `fetch #1 ${url}`,
1401
+ "cancel #1",
1402
+ `fetch #2 ${url}`,
1403
+ "consumed #2",
1404
+ `close ${url}`
1405
+ ]);
1406
+ assertEquals(hops.requests.map((r) => r.headers.has("Signature-Input")), [true, false]);
1407
+ hops.assertInvariants();
1408
+ });
1409
+ test("doubleKnockScoped() closes the retried hop before a redirect", async () => {
1410
+ const url = "https://example.com/lifecycle/retry-redirect";
1411
+ const next = "https://example.com/lifecycle/final";
1412
+ const hops = recordHops((_, index) => index === 1 ? {
1413
+ status: 401,
1414
+ body: "Unauthorized"
1415
+ } : index === 2 ? {
1416
+ status: 302,
1417
+ headers: { Location: next },
1418
+ body: "Found"
1419
+ } : {
1420
+ status: 200,
1421
+ body: "Done"
1422
+ });
1423
+ assertEquals(await doubleKnockScoped(new Request(url, {
1424
+ method: "POST",
1425
+ body: "activity"
1426
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), "Done");
1427
+ assertEquals(hops.events, [
1428
+ `open ${url}`,
1429
+ `fetch #1 ${url}`,
1430
+ "cancel #1",
1431
+ `fetch #2 ${url}`,
1432
+ "cancel #2",
1433
+ `close ${url}`,
1434
+ `open ${next}`,
1435
+ `fetch #3 ${next}`,
1436
+ "consumed #3",
1437
+ `close ${next}`
1438
+ ]);
1439
+ assertEquals(await hops.requests[2].text(), "activity");
1440
+ hops.assertInvariants();
1441
+ });
1442
+ test("doubleKnockScoped() retries transport errors on the same hop", async (t) => {
1443
+ await t.step("GET", async () => {
1444
+ const url = "https://example.com/lifecycle/flaky";
1445
+ const hops = recordHops((_, index) => {
1446
+ if (index === 1) throw new TypeError("connection reset");
1447
+ return { body: "Success" };
1448
+ });
1449
+ assertEquals(await doubleKnockScoped(new Request(url), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), "Success");
1450
+ assertEquals(hops.events, [
1451
+ `open ${url}`,
1452
+ `fetch #1 ${url}`,
1453
+ `fetch #2 ${url}`,
1454
+ "consumed #2",
1455
+ `close ${url}`
1456
+ ]);
1457
+ hops.assertInvariants();
1458
+ });
1459
+ await t.step("POST", async () => {
1460
+ const url = "https://example.com/lifecycle/flaky-inbox";
1461
+ const hops = recordHops(() => {
1462
+ throw new TypeError("connection reset");
1463
+ });
1464
+ await assertRejects(() => doubleKnockScoped(new Request(url, {
1465
+ method: "POST",
1466
+ body: "activity"
1467
+ }), lifecycleIdentity, {}, hops.openHop, (response) => response.text()), FetchError);
1468
+ assertEquals(hops.events, [
1469
+ `open ${url}`,
1470
+ `fetch #1 ${url}`,
1471
+ `close ${url}`
1472
+ ]);
1473
+ hops.assertInvariants();
1474
+ });
1475
+ });
1476
+ async function captureLogs(fn) {
1477
+ const records = [];
1478
+ await configure({
1479
+ sinks: { buffer: (record) => records.push(record) },
1480
+ loggers: [{
1481
+ category: "fedify",
1482
+ sinks: ["buffer"],
1483
+ lowestLevel: "debug"
1484
+ }, {
1485
+ category: ["logtape", "meta"],
1486
+ sinks: []
1487
+ }],
1488
+ reset: true
1489
+ });
1490
+ try {
1491
+ await fn();
1492
+ await new Promise((resolve) => setTimeout(resolve, 0));
1493
+ } finally {
1494
+ await reset();
1495
+ }
1496
+ return records;
1497
+ }
1498
+ test("doubleKnockScoped() logs a redirect body failing to cancel and follows the redirect", async () => {
1499
+ const url = "https://example.com/lifecycle/cancel-failure";
1500
+ const next = "https://example.com/lifecycle/cancel-failure-final";
1501
+ const hops = recordHops((_, index) => index === 1 ? {
1502
+ status: 302,
1503
+ headers: { Location: next },
1504
+ body: "Found",
1505
+ failCancel: true
1506
+ } : {
1507
+ status: 200,
1508
+ body: "Done"
1509
+ });
1510
+ let result;
1511
+ const records = await captureLogs(async () => {
1512
+ result = await doubleKnockScoped(new Request(url), lifecycleIdentity, {}, hops.openHop, (response) => response.text());
1513
+ });
1514
+ assertEquals(result, "Done");
1515
+ assertEquals(hops.events, [
1516
+ `open ${url}`,
1517
+ `fetch #1 ${url}`,
1518
+ "cancel #1",
1519
+ `close ${url}`,
1520
+ `open ${next}`,
1521
+ `fetch #2 ${next}`,
1522
+ "consumed #2",
1523
+ `close ${next}`
1524
+ ]);
1525
+ const failures = records.filter((r) => r.rawMessage === "Failed to cancel the response body from {url}: {error}");
1526
+ assertEquals(failures.map((r) => [r.level, r.properties.url]), [["debug", url]]);
1527
+ assertEquals(failures[0].properties.error.message, "cancel #1 failed");
1528
+ hops.assertInvariants();
1529
+ });
1530
+ test("doubleKnockScoped() is not blocked by a body cancellation that never settles", async () => {
1531
+ const url = "https://example.com/lifecycle/hanging-cancel";
1532
+ const next = "https://example.com/lifecycle/hanging-cancel-final";
1533
+ const hops = recordHops((_, index) => index === 1 ? {
1534
+ status: 401,
1535
+ body: "Unauthorized",
1536
+ hangCancel: true
1537
+ } : index === 2 ? {
1538
+ status: 302,
1539
+ headers: { Location: next },
1540
+ body: "Found",
1541
+ hangCancel: true
1542
+ } : {
1543
+ status: 200,
1544
+ body: "Done",
1545
+ hangCancel: true
1546
+ });
1547
+ assertEquals(await doubleKnockScoped(new Request(url, {
1548
+ method: "POST",
1549
+ body: "activity"
1550
+ }), lifecycleIdentity, {}, hops.openHop, (response) => Promise.resolve(response.status)), 200);
1551
+ assertEquals(hops.events, [
1552
+ `open ${url}`,
1553
+ `fetch #1 ${url}`,
1554
+ "cancel #1",
1555
+ `fetch #2 ${url}`,
1556
+ "cancel #2",
1557
+ `close ${url}`,
1558
+ `open ${next}`,
1559
+ `fetch #3 ${next}`,
1560
+ "cancel #3 by close",
1561
+ `close ${next}`
1562
+ ]);
1563
+ hops.assertInvariants();
1564
+ });
1565
+ test("doubleKnockScoped() completes with responses cloned by fetch-mock", async () => {
1566
+ esm_default.spyGlobal();
1567
+ try {
1568
+ let attempts = 0;
1569
+ esm_default.post("https://example.com/tee/redirect", () => {
1570
+ attempts++;
1571
+ return attempts === 1 ? new Response("Unauthorized", { status: 401 }) : new Response("Found", {
1572
+ status: 302,
1573
+ headers: { Location: "https://example.com/tee/final" }
1574
+ });
1575
+ });
1576
+ esm_default.post("https://example.com/tee/final", () => new Response("Accepted", { status: 202 }));
1577
+ const request = () => new Request("https://example.com/tee/redirect", {
1578
+ method: "POST",
1579
+ body: "activity"
1580
+ });
1581
+ assertEquals(await doubleKnockScoped(request(), lifecycleIdentity, {}, openDirectFetch, (response) => Promise.resolve(response.status)), 202);
1582
+ assertEquals(attempts, 2);
1583
+ attempts = 0;
1584
+ const response = await doubleKnock(request(), lifecycleIdentity);
1585
+ assertEquals(response.status, 202);
1586
+ assertEquals(await response.text(), "Accepted");
1587
+ assertEquals(attempts, 2);
1588
+ } finally {
1589
+ esm_default.hardReset();
1590
+ }
1591
+ });
1592
+ test("doubleKnockScoped() closes the session when consuming fails", async (t) => {
1593
+ const url = "https://example.com/lifecycle/consume";
1594
+ await t.step("exception", async () => {
1595
+ const hops = recordHops(() => ({ body: "x".repeat(1024) }));
1596
+ const error = /* @__PURE__ */ new Error("consume failed");
1597
+ await assertRejects(() => doubleKnockScoped(new Request(url), lifecycleIdentity, {}, hops.openHop, async (response) => {
1598
+ const reader = response.body.getReader();
1599
+ await reader.read();
1600
+ reader.releaseLock();
1601
+ throw error;
1602
+ }), Error, "consume failed");
1603
+ assertEquals(hops.events, [
1604
+ `open ${url}`,
1605
+ `fetch #1 ${url}`,
1606
+ "cancel #1 by close",
1607
+ `close ${url}`
1608
+ ]);
1609
+ hops.assertInvariants();
1610
+ });
1611
+ await t.step("abort", async () => {
1612
+ const hops = recordHops(() => ({ body: "Success" }));
1613
+ const controller = new AbortController();
1614
+ const reason = /* @__PURE__ */ new Error("aborted while consuming");
1615
+ assert(await assertRejects(() => doubleKnockScoped(new Request(url), lifecycleIdentity, { signal: controller.signal }, hops.openHop, () => {
1616
+ controller.abort(reason);
1617
+ controller.signal.throwIfAborted();
1618
+ return Promise.resolve();
1619
+ })) === reason);
1620
+ assertEquals(hops.events, [
1621
+ `open ${url}`,
1622
+ `fetch #1 ${url}`,
1623
+ "cancel #1 by close",
1624
+ `close ${url}`
1625
+ ]);
1626
+ hops.assertInvariants();
1627
+ });
1628
+ });
1629
+ test("doubleKnock() returns the final response without cancelling its body", async () => {
1630
+ const url = "https://example.com/lifecycle/public";
1631
+ const next = "https://example.com/lifecycle/public-final";
1632
+ const events = [];
1633
+ const responses = [];
1634
+ const originalFetch = globalThis.fetch;
1635
+ globalThis.fetch = ((input) => {
1636
+ const requestUrl = input instanceof Request ? input.url : String(input);
1637
+ const index = responses.length + 1;
1638
+ events.push(`fetch #${index} ${requestUrl}`);
1639
+ const recorded = createRecordedResponse(events, index, requestUrl === url ? {
1640
+ status: 302,
1641
+ headers: { Location: next },
1642
+ body: "Found"
1643
+ } : {
1644
+ status: 200,
1645
+ body: "Done"
1646
+ });
1647
+ responses.push(recorded);
1648
+ return Promise.resolve(recorded.response);
1649
+ });
1650
+ try {
1651
+ const response = await doubleKnock(new Request(url), lifecycleIdentity);
1652
+ assertEquals(events, [
1653
+ `fetch #1 ${url}`,
1654
+ "cancel #1",
1655
+ `fetch #2 ${next}`
1656
+ ]);
1657
+ assertFalse(responses[1].settled());
1658
+ assertEquals(await response.text(), "Done");
1659
+ assertEquals(events.at(-1), "consumed #2");
1660
+ } finally {
1661
+ globalThis.fetch = originalFetch;
1662
+ }
1663
+ });
1664
+ test("doubleKnock() is not blocked by a body cancellation that never settles", async () => {
1665
+ const url = "https://example.com/lifecycle/public-hanging";
1666
+ const next = "https://example.com/lifecycle/public-hanging-final";
1667
+ const events = [];
1668
+ const originalFetch = globalThis.fetch;
1669
+ let index = 0;
1670
+ globalThis.fetch = ((input) => {
1671
+ const requestUrl = input instanceof Request ? input.url : String(input);
1672
+ index++;
1673
+ events.push(`fetch #${index} ${requestUrl}`);
1674
+ return Promise.resolve(createRecordedResponse(events, index, index === 1 ? {
1675
+ status: 401,
1676
+ body: "Unauthorized",
1677
+ hangCancel: true
1678
+ } : index === 2 ? {
1679
+ status: 302,
1680
+ headers: { Location: next },
1681
+ body: "Found",
1682
+ hangCancel: true
1683
+ } : {
1684
+ status: 200,
1685
+ body: "Done"
1686
+ }).response);
1687
+ });
1688
+ try {
1689
+ const response = await doubleKnock(new Request(url, {
1690
+ method: "POST",
1691
+ body: "activity"
1692
+ }), lifecycleIdentity);
1693
+ assertEquals(events, [
1694
+ `fetch #1 ${url}`,
1695
+ "cancel #1",
1696
+ `fetch #2 ${url}`,
1697
+ "cancel #2",
1698
+ `fetch #3 ${next}`
1699
+ ]);
1700
+ assertEquals(await response.text(), "Done");
1701
+ } finally {
1702
+ globalThis.fetch = originalFetch;
1703
+ }
1704
+ });
1262
1705
  test("timingSafeEqual()", async (t) => {
1263
1706
  await t.step("should return true for equal empty arrays", () => {
1264
1707
  assert(timingSafeEqual(new Uint8Array([]), new Uint8Array([])));
@@ -1965,5 +2408,88 @@ test("doubleKnock(): Accept-Signature with multiple compatible entries fulfills
1965
2408
  assertEquals(requestCount, 2);
1966
2409
  esm_default.hardReset();
1967
2410
  });
2411
+ function createRecordedResponse(events, index, init, cancelSuffix = () => "") {
2412
+ const bytes = new TextEncoder().encode(init.body ?? "");
2413
+ const chunkSize = init.chunkSize ?? 256;
2414
+ let offset = 0;
2415
+ let settled = false;
2416
+ const body = new ReadableStream({
2417
+ pull(controller) {
2418
+ if (offset >= bytes.length) {
2419
+ settled = true;
2420
+ events.push(`consumed #${index}`);
2421
+ controller.close();
2422
+ return;
2423
+ }
2424
+ controller.enqueue(bytes.slice(offset, offset + chunkSize));
2425
+ offset += chunkSize;
2426
+ },
2427
+ cancel() {
2428
+ settled = true;
2429
+ events.push(`cancel #${index}${cancelSuffix()}`);
2430
+ if (init.failCancel) throw new Error(`cancel #${index} failed`);
2431
+ if (init.hangCancel) return new Promise(() => {});
2432
+ }
2433
+ }, { highWaterMark: 0 });
2434
+ return {
2435
+ response: new Response(body, {
2436
+ status: init.status ?? 200,
2437
+ headers: init.headers
2438
+ }),
2439
+ settled: () => settled
2440
+ };
2441
+ }
2442
+ function recordHops(respond) {
2443
+ const events = [];
2444
+ const requests = [];
2445
+ const unsettledAtClose = [];
2446
+ let opens = 0;
2447
+ let closes = 0;
2448
+ let fetches = 0;
2449
+ return {
2450
+ events,
2451
+ requests,
2452
+ openHop(url) {
2453
+ opens++;
2454
+ events.push(`open ${url}`);
2455
+ const tracked = [];
2456
+ let closed = false;
2457
+ let closing = false;
2458
+ return Promise.resolve({
2459
+ url: new URL(url),
2460
+ async fetch(input, init) {
2461
+ if (closed) throw new TypeError("The session is closed.");
2462
+ const request = new Request(input instanceof Request ? input.clone() : input, init);
2463
+ const index = ++fetches;
2464
+ requests.push(request);
2465
+ events.push(`fetch #${index} ${request.url}`);
2466
+ const recorded = createRecordedResponse(events, index, await respond(request, index), () => closing ? " by close" : "");
2467
+ tracked.push({
2468
+ ...recorded,
2469
+ index
2470
+ });
2471
+ return recorded.response;
2472
+ },
2473
+ close() {
2474
+ if (closed) return Promise.resolve();
2475
+ closed = true;
2476
+ closes++;
2477
+ closing = true;
2478
+ for (const { response, settled, index } of tracked) {
2479
+ if (settled()) continue;
2480
+ if (response.body?.locked) unsettledAtClose.push(index);
2481
+ else response.body?.cancel().catch(() => {});
2482
+ }
2483
+ events.push(`close ${url}`);
2484
+ return Promise.resolve();
2485
+ }
2486
+ });
2487
+ },
2488
+ assertInvariants() {
2489
+ assertEquals(opens, closes);
2490
+ assertEquals(unsettledAtClose, []);
2491
+ }
2492
+ };
2493
+ }
1968
2494
  //#endregion
1969
2495
  export {};