Add tests. Add exponential backoff contexts. Update error handling. Imrove Event Parser compatibility. Simplify SSE Session. Simplify Async Iterator.

This commit is contained in:
2026-07-21 12:44:33 +10:00
parent fb64b1b2ea
commit e12698ab6f
20 changed files with 3123 additions and 408 deletions

241
package-lock.json generated
View File

@@ -1,34 +1,35 @@
{
"name": "ssesession",
"version": "1.0.0",
"name": "sse-session",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ssesession",
"version": "1.0.0",
"name": "sse-session",
"version": "0.0.1",
"license": "ISC",
"devDependencies": {
"prettier": "^3.9.5",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -37,9 +38,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -55,14 +56,14 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
@@ -74,9 +75,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -84,9 +85,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
@@ -101,9 +102,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
@@ -118,9 +119,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
@@ -135,9 +136,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
@@ -152,9 +153,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
@@ -169,9 +170,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
@@ -186,9 +187,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
@@ -203,9 +204,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
@@ -220,9 +221,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
@@ -237,9 +238,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [
"x64"
],
@@ -254,9 +255,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [
"x64"
],
@@ -271,9 +272,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
@@ -288,9 +289,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
@@ -298,18 +299,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
@@ -324,9 +325,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
@@ -355,9 +356,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -872,9 +873,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@@ -916,9 +917,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -929,9 +930,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.20",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz",
"integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==",
"dev": true,
"funding": [
{
@@ -949,7 +950,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -957,14 +958,30 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.132.0",
"@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -974,21 +991,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.2",
"@rolldown/binding-darwin-arm64": "1.0.2",
"@rolldown/binding-darwin-x64": "1.0.2",
"@rolldown/binding-freebsd-x64": "1.0.2",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
"@rolldown/binding-linux-arm64-musl": "1.0.2",
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
"@rolldown/binding-linux-x64-gnu": "1.0.2",
"@rolldown/binding-linux-x64-musl": "1.0.2",
"@rolldown/binding-openharmony-arm64": "1.0.2",
"@rolldown/binding-wasm32-wasi": "1.0.2",
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
"@rolldown/binding-win32-x64-msvc": "1.0.2"
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
"node_modules/siginfo": {
@@ -1040,9 +1057,9 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1089,17 +1106,17 @@
}
},
"node_modules/vite": {
"version": "8.0.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "1.0.2",
"tinyglobby": "^0.2.16"
"picomatch": "^4.0.5",
"postcss": "^8.5.17",
"rolldown": "~1.1.5",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
@@ -1115,7 +1132,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",

View File

@@ -6,6 +6,8 @@
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"format": "prettier --write \"src/**/*.ts\"",
"prepare": "npm run build"
},
"files": [
@@ -24,6 +26,7 @@
"license": "ISC",
"type": "module",
"devDependencies": {
"prettier": "^3.9.5",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}

View File

@@ -1,4 +1,4 @@
import type { SSEvent } from './types.js';
import type { SSEvent } from "./types.ts";
/**
* Optional encoders used when decoding incoming SSE bytes and re-encoding
@@ -7,11 +7,34 @@ import type { SSEvent } from './types.js';
export interface SSEEventParserOptions {
/** Decodes raw stream bytes into text. Defaults to a new `TextDecoder`. */
textDecoder: TextDecoder;
/** Encodes buffered remainder bytes between parse calls. Defaults to a new `TextEncoder`. */
textEncoder: TextEncoder;
}
/**
* Regex that splits decoded SSE text into lines.
*
* The SSE wire format is line-oriented (`field: value` per line). Servers may
* send `\r\n` (HTTP default), `\n` (Unix), or `\r` (legacy Mac). Matching all
* three keeps parsing correct regardless of platform or server implementation.
*/
export const SSE_LINE_ENDINGS = /\r\n|\r|\n/;
/**
* Regex that matches the single optional leading space in an SSE field value.
*
* Per the SSE spec, `field: value` may include one space immediately after the
* colon; that space is not part of the value. Used with `.replace()` to strip
* it when parsing lines such as `data: hello` → `hello`.
*/
export const SSE_FIELD_VALUE_REGEX = /^ /;
/**
* The newline character used when normalizing SSE text internally.
*
* Used to join consecutive `data:` lines into one payload and to reassemble
* buffered partial lines between streamed chunks before the next parse call.
*/
export const NEW_LINE = "\n";
/**
* Incrementally parses Server-Sent Events (SSE) from streamed byte chunks.
*
@@ -38,10 +61,9 @@ export interface SSEEventParserOptions {
*/
export class SSEEventParser {
private readonly textDecoder: TextDecoder;
private readonly textEncoder: TextEncoder;
/** Bytes from a partial line or incomplete event, carried over to the next chunk. */
private messageBuffer: Uint8Array = new Uint8Array();
private messageBuffer: string = "";
/**
* Creates a parser for one SSE stream.
@@ -53,7 +75,6 @@ export class SSEEventParser {
*/
constructor(options: Partial<SSEEventParserOptions> = {}) {
this.textDecoder = options.textDecoder ?? new TextDecoder();
this.textEncoder = options.textEncoder ?? new TextEncoder();
}
/**
@@ -63,7 +84,11 @@ export class SSEEventParser {
* stale bytes to incoming chunks.
*/
public reset(): void {
this.messageBuffer = new Uint8Array();
// Clear the message buffer
this.messageBuffer = "";
// Reset the decoder to clear any buffered bytes
this.textDecoder.decode();
}
/**
@@ -80,18 +105,26 @@ export class SSEEventParser {
public parseEvents(chunk: Uint8Array): SSEvent[] {
const lines = this.getBufferedLines(chunk);
// The final split item is the incomplete remainder after the final line
// ending. It must not be processed as a complete SSE line.
const completeLines = lines.slice(0, -1);
const events: SSEvent[] = [];
let event: Partial<SSEvent> = {};
let processedLineCount = 0;
for (const [index, line] of lines.entries()) {
for (const [index, line] of completeLines.entries()) {
// A blank line indicates the end of an event. If we have received data, we can complete the event
if (line === "") {
if (event.data) {
events.push(this.completeEvent(event));
event = {};
processedLineCount = index + 1;
if (event.data !== undefined) {
// Cast event when pushing to an SSE Event so it changes from Partial<SSEvent> to SSEvent
// The "Partial" can safely be removed as we have guaranteed that `data` is defined
events.push(event as SSEvent);
}
event = {};
processedLineCount = index + 1;
continue;
}
@@ -111,46 +144,55 @@ export class SSEEventParser {
* regardless of server or platform conventions.
*/
private getBufferedLines(chunk: Uint8Array): string[] {
this.messageBuffer = new Uint8Array([
...this.messageBuffer,
...chunk,
]);
this.messageBuffer += this.textDecoder.decode(chunk, { stream: true });
return this.textDecoder
.decode(this.messageBuffer)
.split(/\r\n|\r|\n/);
return this.messageBuffer.split(SSE_LINE_ENDINGS);
}
/**
* Parses one SSE field line into an in-progress event.
*
* Lines without a colon are ignored. A single optional space after the colon
* Lines starting with a colon are ignored as comments. A single optional space after the colon
* is stripped from the field value, per the SSE spec.
*/
private parseLine(line: string, event: Partial<SSEvent>): void {
const colonIndex = line.indexOf(":");
if (colonIndex === -1) return;
// Split the line at the first colon. Before the first colon is the field, after the first colon is the value.
const [field, ...valueArray] = line.split(":");
const field = line.slice(0, colonIndex);
const value = line.slice(colonIndex + 1).replace(/^ /, "");
// Join the rest of the array back together to get the value as it was before the split.
// If the value has a space at the start, remove it (according to the SSE spec)
const value = valueArray.join(":").replace(SSE_FIELD_VALUE_REGEX, "");
switch (field) {
case "data":
event.data = event.data
? `${event.data}\n${value}`
: value;
// If this is NOT the first time we are adding a data line, this will be an empty string and we will add a new line.
if (event.data !== undefined) {
event.data += NEW_LINE;
}
// If event.data hasnt been defined yet, set it to an empty string.
event.data ??= "";
// Add the value to the event.data
event.data += value;
// event.data = event.data !== undefined ? `${event.data}${NEW_LINE}${value}` : value;
return;
case "event":
event.event = value;
return;
case "id":
event.id = value;
return;
case "retry":
this.parseRetry(value, event);
return;
}
}
@@ -168,19 +210,6 @@ export class SSEEventParser {
}
}
/**
* Constructs a completed SSE event from accumulated fields.
*
* Trims a trailing newline from multi-line `data` values so callers receive
* the payload without an extra line break at the end.
*/
private completeEvent(event: Partial<SSEvent>): SSEvent {
return {
...event,
data: event.data!.replace(/\n$/, ""),
} as SSEvent;
}
/**
* Preserves incomplete trailing lines for the next received chunk.
*
@@ -191,10 +220,6 @@ export class SSEEventParser {
lines: string[],
processedLineCount: number,
): void {
const remainder = lines
.slice(processedLineCount)
.join("\n");
this.messageBuffer = this.textEncoder.encode(remainder);
this.messageBuffer = lines.slice(processedLineCount).join(NEW_LINE);
}
}

View File

@@ -2,12 +2,14 @@ import type {
SSESessionOptions,
SSESessionEventMap,
SSEvent,
} from "./types.js";
} from "./types.ts";
import { tryAsync } from "./utils/misc.js";
import { EventEmitter } from "./utils/event-emitter.js";
import { AsyncPushIterator } from "./utils/async-push-iterator.js";
import { ExponentialBackoff } from "./utils/exponential-backoff.js";
import {
tryAsync,
EventEmitter,
ExponentialBackoff,
AsyncPushIterator,
} from "./utils/index.js";
import { SSEEventParser } from "./sse-event-parser.js";
@@ -77,37 +79,6 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
return client;
}
/**
* Creates a session with tab visibility handling for browser clients.
*
* Registers {@link addBrowserVisibilityHandler} before connecting. If the
* document is hidden at creation time (for example a background tab), the
* initial connect is deferred until the tab becomes visible.
*
* @param url - The SSE endpoint URL.
* @param options - Session configuration.
* @returns A session with visibility handling attached. May not yet be
* connected when the tab is hidden.
*/
static async withBrowserVisibility(
url: string,
options: Partial<SSESessionOptions> = {},
): Promise<SSESession> {
const client = new SSESession(url, options);
SSESession.addBrowserVisibilityHandler(client);
// Avoid opening a connection while the tab is in the background.
if (
typeof document === "undefined" ||
document.visibilityState === "visible"
) {
await client.connect();
}
return client;
}
/**
* Enables SSE resume semantics by sending `Last-Event-ID` on reconnect.
*
@@ -135,16 +106,21 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* @param client - The session to instrument.
* @returns The same session, for chaining.
*/
static async addLastEventIdReconnect(client: SSESession): Promise<SSESession> {
static async addLastEventIdReconnect(
client: SSESession,
): Promise<SSESession> {
let lastEventId: string | undefined;
client.on("message", (event) => {
lastEventId = event.id;
// If the event has no id, we should not update the lastEventId and instead keep the old one.
lastEventId = event.id ?? lastEventId;
});
const originalOnRequest = client.onRequest;
const originalOnRequest = client.options.onRequest;
client.onRequest = async (request) => {
client.options.onRequest = async (
request: RequestInit,
): Promise<RequestInit> => {
if (lastEventId) {
request.headers = { ...request.headers, "Last-Event-ID": lastEventId };
}
@@ -177,6 +153,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
const handleVisibilityChange = (): void => {
if (document.visibilityState === "hidden") {
void client.abort();
return;
}
@@ -209,7 +186,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* Defaults live on the instance field (not a shared static) so each session
* gets its own {@link SSEEventParser} and {@link ExponentialBackoff}.
*/
private options: SSESessionOptions = {
public options: SSESessionOptions = {
fetch: (...args) => fetch(...args),
method: "GET",
headers: {
@@ -239,17 +216,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
};
/** AbortController for the currently active fetch, if any. */
private controller: AbortController = new AbortController();
/** Whether a transport is currently established or connecting. */
private connected = false;
/**
* Monotonic id bumped on each {@link connect} and {@link abort}.
*
* Background read loops compare against this to detect superseded transports.
*/
private connectionId = 0;
private controller: AbortController | null = null;
/**
* Asynchronous stream of parsed SSE events for the active connection.
@@ -268,9 +235,10 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* previous one was closed. Consumers should read from `session.messages`
* rather than caching a reference across terminal disconnects.
*/
public messages: AsyncPushIterator<SSEvent> = new AsyncPushIterator<SSEvent>();
public messages: AsyncPushIterator<SSEvent> =
new AsyncPushIterator<SSEvent>();
private constructor(url: string, options: Partial<SSESessionOptions>) {
public constructor(url: string, options: Partial<SSESessionOptions> = {}) {
super();
this.url = url;
@@ -282,14 +250,6 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
};
}
get onRequest(): (request: RequestInit) => Promise<RequestInit> {
return this.options.onRequest;
}
set onRequest(callback: (request: RequestInit) => Promise<RequestInit>) {
this.options.onRequest = callback;
}
/**
* Connects or reconnects to the SSE endpoint.
*
@@ -302,25 +262,31 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* promise resolves without throwing).
*/
public async connect(): Promise<void> {
if (this.connected) return;
// If there is already a controller present, we are already connected.
if (this.controller) return;
// Prepare for a fresh transport. Parser state from an abandoned connection
// must not bleed into the next one; reopen messages if a prior terminal
// close ended the consumer's iteration loop.
this.resetEventParser();
this.ensureMessageStreamOpen();
// NOTE: The resetEventParser may be out of spec and should not be done here: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
this.options.eventParser.reset();
// If the last messages iterator wasnt closed, we keep it around as this is likely a reconnect.
if (this.messages.closed) {
this.messages = new AsyncPushIterator<SSEvent>();
}
const connectionId = ++this.connectionId;
const controller = new AbortController();
this.connected = true;
this.controller = controller;
const { method, headers, body } = this.options;
const fetchBody = method === "POST" ? body : null;
const fetchOptions: RequestInit = {
method,
headers: headers || {},
body: body || null,
body: fetchBody ?? null,
signal: controller.signal,
cache: "no-store",
};
@@ -333,31 +299,34 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
);
} catch (error) {
// A newer abort/connect superseded this attempt — leave state to the winner.
if (!this.isCurrentConnection(connectionId, controller)) return;
if (this.controller !== controller) return;
this.connected = false;
this.controller = null;
await this.notifyDisconnected();
await this.notifyError(error);
this.closeMessageStream();
this.messages.close();
throw error;
}
// Connection succeeded but was already replaced (for example abort during fetch).
if (!this.isCurrentConnection(connectionId, controller)) {
if (this.controller !== controller) {
await reader.cancel();
return;
}
await tryAsync(
() => this.options.onConnected(),
(error) => this.options.onError(error),
(error) => this.notifyError(error),
);
this.emit("connected", undefined);
// Fire-and-forget: connect() resolves while the stream is consumed.
this.readStream(reader, connectionId, controller);
this.readStream(reader, controller).catch((error) => {
this.options.onError(error);
});
}
/**
@@ -370,13 +339,15 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* Emits `"disconnected"` but not `"closed"`.
*/
public async abort(): Promise<void> {
if (!this.connected) return;
if (!this.controller) return;
// Grab the current controller to ensure we are aborting the correct one.
const controller = this.controller;
this.controller = null;
this.connected = false;
// Invalidate any in-flight read loop and fetch for this transport.
this.connectionId++;
this.controller.abort();
this.resetEventParser();
controller.abort();
this.options.eventParser.reset();
await this.notifyDisconnected();
}
@@ -388,13 +359,13 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* Closes {@link messages} and emits `"closed"`.
*/
public async disconnect(): Promise<void> {
this.closeMessageStream();
this.messages.close();
this.emit("closed", undefined);
if (this.connected) {
if (this.controller) {
await this.abort();
} else {
this.resetEventParser();
this.options.eventParser.reset();
}
}
@@ -413,11 +384,16 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
if (!response.ok) {
const responseCode = response.status;
const responseText = await response.text();
throw new Error(`HTTP error! Status: ${responseCode} - ${responseText}`);
const error = new Error(`HTTP error: ${responseCode} ${responseText}`);
void this.notifyError(error);
throw error;
}
if (!response.body) {
throw new Error("Response body is null");
const error = new Error("HTTP error: Response body is null");
void this.notifyError(error);
throw error;
}
return response.body.getReader();
@@ -429,18 +405,17 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
*/
private async readStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
connectionId: number,
controller: AbortController,
): Promise<void> {
try {
while (this.isCurrentConnection(connectionId, controller)) {
while (this.controller === controller) {
const { done, value } = await reader.read();
// abort() or a newer connect() may have landed while we were awaiting.
if (!this.isCurrentConnection(connectionId, controller)) return;
if (this.controller !== controller) return;
if (done) {
this.connected = false;
this.controller = null;
await this.notifyDisconnected();
@@ -448,7 +423,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
// Server closed gracefully — reopen unless the consumer opted out.
await this.connect();
} else {
this.closeMessageStream();
this.messages.close();
}
return;
@@ -463,9 +438,11 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
}
}
} catch (error) {
if (!this.isCurrentConnection(connectionId, controller)) return;
// If the controller is different, we already started a new connection and it would be confusing to handle this error.
if (controller !== this.controller) return;
this.connected = false;
// Invalidate the current controller to allow for reconnection if needed
this.controller = null;
await this.notifyDisconnected();
@@ -477,50 +454,11 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
if (this.options.attemptReconnect) {
await this.connect();
} else {
this.closeMessageStream();
this.messages.close();
}
}
}
/** Clears partial SSE frames left over from an abandoned transport. */
private resetEventParser(): void {
this.options.eventParser.reset();
}
/**
* Creates a new {@link messages} iterator when the previous one was closed
* by a terminal disconnect or server stream end.
*/
private ensureMessageStreamOpen(): void {
if (!this.messages.closed) return;
this.messages = new AsyncPushIterator<SSEvent>();
}
/** Ends the message iteration loop for the current connection span. */
private closeMessageStream(): void {
if (this.messages.closed) return;
this.messages.close();
}
/**
* Returns whether a read loop still owns the active transport.
*
* A loop is stale when the session disconnected, a newer connection id was
* assigned, or the fetch was aborted.
*/
private isCurrentConnection(
connectionId: number,
controller: AbortController,
): boolean {
return (
this.connected &&
this.connectionId === connectionId &&
!controller.signal.aborted
);
}
/** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */
private async notifyDisconnected(): Promise<void> {
await tryAsync(
@@ -532,12 +470,12 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
/** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */
private async notifyError(error: unknown): Promise<void> {
const errorInstance = error instanceof Error ? error : new Error(String(error));
const errorInstance =
error instanceof Error ? error : new Error(String(error));
await tryAsync(
() => this.options.onError(errorInstance),
(callbackError) =>
console.error("SSESession error:", callbackError),
(callbackError) => console.error("SSESession error:", callbackError),
);
this.emit("error", errorInstance);
}

View File

@@ -54,7 +54,7 @@ export type SSESessionCallbacks = {
* {@link SSESession.abort} aborts.
*/
onError: (error: Error) => void;
}
};
export type SSESessionRetryInterface = {
/**
@@ -85,14 +85,14 @@ export type SSELifecycleOptions = {
* When true, {@link SSESession} calls {@link SSESession.connect} again after
* a transport **error** (not an intentional abort).
*/
attemptReconnect: boolean,
attemptReconnect: boolean;
/**
* When true, {@link SSESession} calls {@link SSESession.connect} again after
* the **server** closes the stream normally (`done`).
*/
persistent: boolean,
}
persistent: boolean;
};
/**
* Events emitted by {@link SSESession}.
@@ -114,8 +114,7 @@ export type SSESessionEventMap = {
/**
* Configuration for {@link SSESession}.
*/
export type SSESessionOptions =
SSESessionCallbacks &
export type SSESessionOptions = SSESessionCallbacks &
SSERequestInit &
SSERequestFunction &
SSESessionRetryInterface &

View File

@@ -1,8 +1,8 @@
/**
* An async iterable queue that bridges push-based producers and pull-based consumers.
*
* Values are pushed from outside the iteration loop (for example, from an SSE
* read callback) and consumed with standard async iteration:
* Composes an internal {@link ReadableStream} instead of extending it, so producers
* call {@link push} while consumers use standard async iteration (`for await...of`).
*
* ```ts
* const messages = new AsyncPushIterator<SSEvent>();
@@ -16,92 +16,67 @@
* }
* ```
*
* When a consumer is already waiting on {@link AsyncPushIterator.prototype.next},
* {@link push} delivers immediately. Otherwise values are buffered in FIFO order
* until consumed. Call {@link close} to signal end-of-stream; further
* {@link push} calls are ignored.
*
* Implements `Symbol.asyncDispose` so instances can be closed with `using` when
* the runtime supports explicit resource management.
* {@link Symbol.asyncIterator} returns `stream.values({ preventCancel: true })` so
* breaking out of `for await...of` does not cancel the underlying stream. That
* matters for long-lived sessions where the producer keeps pushing after a consumer
* stops reading early (for example, test helpers that only collect a fixed count).
*/
export class AsyncPushIterator<T> implements AsyncIterable<T> {
/** Values pushed before a consumer was waiting to read them. */
private queue: T[] = [];
export class AsyncPushIterator<T> {
/** ReadableStream backing the async iterator returned from {@link Symbol.asyncIterator}. */
private readonly stream: ReadableStream<T>;
/** Pending `next()` calls waiting for a pushed value or close. */
private resolvers: ((result: IteratorResult<T>) => void)[] = [];
/** Controller used to enqueue values and close the stream from {@link push} and {@link close}. */
private controller: ReadableStreamDefaultController<T> | undefined;
/** When true, no more values are accepted and iteration eventually completes. */
public closed = false;
public constructor() {
// `start`'s `this` is the underlying source object when using a plain method.
// An arrow function captures the class instance so the controller is stored here.
this.stream = new ReadableStream({
start: (controller: ReadableStreamDefaultController<T>): void => {
this.controller = controller;
},
});
}
/**
* Enqueues a value for the consumer.
*
* If a consumer is blocked on `next()`, the value is delivered immediately and
* the queue is bypassed. After {@link close}, pushes are silently dropped.
* After {@link close}, pushes are silently dropped.
*
* @param value - The next value to yield from the iterator.
*/
push(value: T): void {
if (this.closed) return;
if (this.resolvers.length > 0) {
// Someone is waiting for a value, resolve immediately
const resolve = this.resolvers.shift()!;
resolve({ value, done: false });
} else {
// No one waiting, buffer the value
this.queue.push(value);
}
this.controller?.enqueue(value);
}
/**
* Ends the stream.
*
* Marks the iterator closed so future {@link push} calls are ignored. Any
* consumer currently waiting on `next()` receives `{ done: true }`. Buffered
* values are still yielded before iteration completes.
* Marks the iterator closed so future {@link push} calls are ignored.
* Buffered values are still yielded before iteration completes.
*/
close(): void {
this.closed = true;
for (const resolve of this.resolvers) {
resolve({ value: undefined as T, done: true });
try {
this.controller?.close();
} catch {
// The reader may already have released or cancelled the stream.
}
this.resolvers = [];
}
/**
* Returns an async iterator that reads from this instance's shared queue.
* Returns an async iterator over the composed stream.
*
* Buffered values are returned first, then the iterator waits for pushes or
* for {@link close}. Intended for a single consumer per instance.
* Uses `preventCancel: true` so early `break` from `for await...of` does not
* close the stream and block later pushes.
*/
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: (): Promise<IteratorResult<T>> => {
// If we have buffered values, return immediately
if (this.queue.length > 0) {
return Promise.resolve({ value: this.queue.shift()!, done: false });
}
// If closed and no buffered values, we're done
if (this.closed) {
return Promise.resolve({ value: undefined as T, done: true });
}
// Wait for a value to be pushed
return new Promise((resolve) => {
this.resolvers.push(resolve);
});
},
};
}
/**
* Closes the iterator when used with explicit resource management (`using`).
*/
[Symbol.asyncDispose](): Promise<void> {
this.close();
return Promise.resolve();
[Symbol.asyncIterator](): AsyncIterableIterator<T> {
return this.stream.values({ preventCancel: true });
}
}

View File

@@ -1,8 +1,3 @@
// TODO: You'll probably want to use WeakRef's here.
// NOTE: Looked into the WeakRefs, but they are extremely challenging to work well without side-effects.
// Things like anonymous functions and closures will get cleaned up immediately and the side-effects from a developer's perspective
// are likely more painful than leaving event listener cleanup to the developer.
// - Harvmaster 2026-05-24
export type EventMap = Record<string, unknown>;
type Listener<T> = (detail: T) => void;
@@ -181,13 +176,14 @@ export class EventEmitter<T extends EventMap> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
// Create a listener function.
const listener = (payload: T[K]) => {
const listener = (payload: T[K]): void => {
if (predicate(payload)) {
// Clean up
this.off(type, listener);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
resolve(payload);
}
};
@@ -219,10 +215,11 @@ export class EventEmitter<T extends EventMap> {
let timeout: ReturnType<typeof setTimeout>;
return (detail: T[K]) => {
// If the timeout is not null, clear it.
if (timeout !== null) {
// If a debounce timer is already pending, clear it before scheduling the next one.
if (timeout !== undefined) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(detail);
}, wait);

View File

@@ -16,6 +16,7 @@ export class ExponentialBackoff {
*/
static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
const backoff = new ExponentialBackoff(config);
return backoff;
}
@@ -26,30 +27,48 @@ export class ExponentialBackoff {
* @param onError - The callback to call when an error occurs
* @param options - The configuration for the exponential backoff
*
* @throws The last error if the function fails and we have hit the max attempts
* @throws An AggregateError with all the errors that were thrown by the task function
*
* @returns The result of the function
*/
static run<T>(
fn: () => Promise<T>,
onError = (_error: Error) => {},
fn: (context: ExpontialBackoffCallbackContext) => Promise<T>,
onError = (
_error: Error,
_context: ExpontialBackoffCallbackContext,
): void => {},
options?: Partial<ExponentialBackoffOptions>,
): Promise<T> {
const backoff = ExponentialBackoff.from(options);
return backoff.run(fn, onError);
}
private readonly options: ExponentialBackoffOptions;
constructor(options?: Partial<ExponentialBackoffOptions>) {
/**
* Creates a new exponential-backoff instance.
*
* Unspecified options use the defaults listed below.
*
* @param options - Exponential-backoff configuration overrides.
* @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms.
* @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`.
* @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms.
* @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`.
* @param options.jitter - Random proportional variation applied to each delay. Default: `0.1`.
*/
constructor(options: Partial<ExponentialBackoffOptions> = {}) {
this.options = {
maxDelay: 10000,
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1000,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
...options,
};
ExponentialBackoff.validateOptions(this.options);
}
/**
@@ -58,44 +77,98 @@ export class ExponentialBackoff {
* If the function fails but we have not hit the max attempts, the error will be passed to the onError callback
* and the function will be retried with an exponential delay
*
* If the function fails and we have hit the max attempts, the last error will be thrown
* If the function fails and we have hit the max attempts, an AggregateError will be thrown with all the errors that were thrown by the task function
*
* @param fn - The function to run
* @param onError - The callback to call when an error occurs
*
* @throws The last error if the function fails and we have hit the max attempts
* @throws An AggregateError with all the errors that were thrown by the task function
*
* @returns The result of the function
*/
async run<T>(
fn: () => Promise<T>,
onError = (_error: Error) => {},
fn: (context: ExpontialBackoffCallbackContext) => Promise<T>,
onError = (
_error: Error,
_context: ExpontialBackoffCallbackContext,
): void => {},
): Promise<T> {
let lastError: Error = new Error("Exponential backoff: Max retries hit");
const abortController = new AbortController();
let attempt = 0;
const context: ExpontialBackoffCallbackContext = {
errors: [],
attempt: 1,
options: { ...this.options },
nextDelay: 0,
signal: abortController.signal,
abort: (reason?: unknown) => abortController.abort(reason),
};
while (
attempt < this.options.maxAttempts ||
this.options.maxAttempts == 0
context.options.maxAttempts === 0 ||
context.attempt <= context.options.maxAttempts
) {
context.nextDelay = ExponentialBackoff.calculateDelay(
context.options,
context.attempt - 1,
);
try {
return await fn();
// Await the promise before returning so its execution context remains in the try-catch
// If we didn't await, this `run` function would successfully return and any errors would not be caught here.
return await fn(context);
} catch (error) {
// Store the error in case we fail every attempt
lastError = error instanceof Error ? error : new Error(`${error}`);
onError(lastError);
// Wait before going to the next attempt
const delay = this.calculateDelay(attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
const errorInstance =
error instanceof Error ? error : new Error(`${error}`);
context.errors.push(errorInstance);
await onError(errorInstance, context);
}
attempt++;
// If the abort controller is aborted, throw an error
if (abortController.signal.aborted) {
const reasonError =
abortController.signal.reason instanceof Error
? abortController.signal.reason
: new Error(`${abortController.signal.reason}`);
throw new Error("Exponential backoff aborted", { cause: reasonError });
}
// If we have hit the max attempts, break out of the loop
const unlimitedAttempts = context.options.maxAttempts === 0;
if (
!unlimitedAttempts &&
context.attempt >= context.options.maxAttempts
) {
break;
}
// Wait before going to the next attempt
await this.wait(context.nextDelay, abortController.signal);
context.attempt++;
}
// We completed the loop without ever succeeding. Throw the last error we got
throw lastError;
// We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got
throw new AggregateError(
context.errors,
"Exponential backoff max retries hit",
);
}
private wait(delay: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
// Set a timeout to resolve the promise
const timeout = setTimeout(() => {
resolve();
}, delay);
// If the signal is aborted, reject the promise
signal.addEventListener("abort", () => {
clearTimeout(timeout);
reject(new Error("Exponential backoff aborted"));
});
});
}
/**
@@ -106,24 +179,49 @@ export class ExponentialBackoff {
* @param attempt
* @returns The time in milliseconds before another attempt should be made
*/
private calculateDelay(attempt: number): number {
public static calculateDelay(
options: ExponentialBackoffOptions,
attempt: number,
): number {
// Get the power of the growth rate
const power = Math.pow(this.options.growthRate, attempt);
const power = options.growthRate ** attempt;
// Get the delay before jitter or limit
const rawDelay = this.options.baseDelay * power;
const rawDelay = options.baseDelay * power;
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
const cappedDelay = Math.min(rawDelay, this.options.maxDelay);
const cappedDelay = Math.min(rawDelay, options.maxDelay);
// Get the jitter direction. This will be between -1 and 1
const jitterDirection = 2 * Math.random() - 1;
// Get a random number for the amount to "jitter" the delay by
const jitterAmount = Math.random();
// Calculate the jitter
const jitter = jitterDirection * this.options.jitter * cappedDelay;
const jitter = jitterAmount * options.jitter * cappedDelay;
// Add the jitter to the delay
return cappedDelay + jitter;
// Subtract the jitter from the delay
return cappedDelay - jitter;
}
/**
* Validate the options, ensure that no values are NaN
* @param options
*/
public static validateOptions(options: ExponentialBackoffOptions): void {
const keys = Object.keys(options) as (keyof ExponentialBackoffOptions)[];
for (const key of keys) {
if (!isFinite(options[key])) {
throw new Error(`Invalid option: ${key} is not finite`);
}
if (key === "jitter" && (options[key] < 0 || options[key] > 1)) {
throw new Error(`Invalid option: jitter is not between 0 and 1`);
}
if (options[key] < 0) {
throw new Error(`Invalid option: ${key} is less than 0`);
}
}
}
}
@@ -153,3 +251,23 @@ export type ExponentialBackoffOptions = {
*/
jitter: number;
};
export type ExpontialBackoffCallbackContext = {
/** The errors that have occurred */
errors: Error[];
/** The current attempt */
attempt: number;
/** The options for the exponential backoff */
options: ExponentialBackoffOptions;
/** The next delay before the next attempt */
nextDelay: number;
/** The abort signal */
signal: AbortSignal;
/** The abort function */
abort: (reason?: unknown) => void;
};

View File

@@ -4,13 +4,16 @@
* @param onError - The callback to call if the function fails.
* @returns The result of the function.
*/
export const tryAsync = async (fn: () => any, onError?: (error: Error) => void): Promise<void> => {
export const tryAsync = async (
fn: () => any,
onError?: (error: Error) => void,
): Promise<void> => {
try {
return await fn();
} catch (error) {
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
const errorInstance =
error instanceof Error ? error : new Error(`${error}`);
onError?.(errorInstance);
}
}
};

294
tests/fixtures/events.fixtures.ts vendored Normal file
View File

@@ -0,0 +1,294 @@
import type { SSEvent } from '../../src/types.js';
type EventFixture = {
raw: string;
parsed?: SSEvent[];
};
/** Combines all the raw strings into a single chunk and flattens the parsed arrays into a single array to simulate multi-event chunks. */
const withCombinedChunk = (fixtures: EventFixture[]): EventFixture => {
return {
raw: fixtures.map(({ raw }) => raw).join(''),
parsed: fixtures.flatMap(({ parsed }) => parsed ?? []),
};
};
/** cspell: disable */
export const llmEvents: EventFixture[] = [
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":"The","reasoning_details":[{"type":"reasoning.text","text":"The","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":"The","reasoning_details":[{"type":"reasoning.text","text":"The","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":" user wants a short programming","reasoning_details":[{"type":"reasoning.text","text":" user wants a short programming","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":" user wants a short programming","reasoning_details":[{"type":"reasoning.text","text":" user wants a short programming","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":" joke.","reasoning_details":[{"type":"reasoning.text","text":" joke.","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":" joke.","reasoning_details":[{"type":"reasoning.text","text":" joke.","format":"unknown","index":0}]},"finish_reason":null,"native_finish_reason":null}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"Why do","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"Why do","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":" programmers prefer dark mode?\\n\\nBecause","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":" programmers prefer dark mode?\\n\\nBecause","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":" programmers prefer dark mode?\\n\\nBecause","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":" programmers prefer dark mode?\\n\\nBecause","role":"assistant"},"finish_reason":null,"native_finish_reason":null}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":null},"finish_reason":"stop","native_finish_reason":"stop"}]}',
},
],
},
{
raw: 'data: {"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","service_tier":null,"choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}],"usage":{"prompt_tokens":24,"completion_tokens":22,"total_tokens":46,"cost":0.0000936,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0,"audio_tokens":0,"video_tokens":0},"cost_details":{"upstream_inference_cost":0.0000936,"upstream_inference_prompt_cost":0.0000144,"upstream_inference_completions_cost":0.0000792},"completion_tokens_details":{"reasoning_tokens":8,"image_tokens":0,"audio_tokens":0}}}\n\n',
parsed: [
{
data: '{"id":"gen-1782128281-rvTKadGOGKuN6eLE3dPc","object":"chat.completion.chunk","created":1782128281,"model":"nvidia/nemotron-3-ultra-550b-a55b-20260604","provider":"Together","service_tier":null,"choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"stop"}],"usage":{"prompt_tokens":24,"completion_tokens":22,"total_tokens":46,"cost":0.0000936,"is_byok":false,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":0,"audio_tokens":0,"video_tokens":0},"cost_details":{"upstream_inference_cost":0.0000936,"upstream_inference_prompt_cost":0.0000144,"upstream_inference_completions_cost":0.0000792},"completion_tokens_details":{"reasoning_tokens":8,"image_tokens":0,"audio_tokens":0}}}',
},
],
},
];
export const priceOracleEvents: EventFixture[] = [
{
raw: 'retry: 1000\nevent: 02664276fb7513f838f505c221680a9d963479ffb45452b0c744ddb6bd19ecacb3\ndata: {"message":"411d396aa53516008f35160049a46100","signature":"936ee3de4c179a1c23d5227c6cadd7ccee11aa1a5a48624ff59ff6954dc607752be8d069610a342ed7610444741bc5fa5dd9bd438fa6c1f1307a7b50a663e17c"}\n\n',
parsed: [
{
retry: 1000,
event: '02664276fb7513f838f505c221680a9d963479ffb45452b0c744ddb6bd19ecacb3',
data: '{"message":"411d396aa53516008f35160049a46100","signature":"936ee3de4c179a1c23d5227c6cadd7ccee11aa1a5a48624ff59ff6954dc607752be8d069610a342ed7610444741bc5fa5dd9bd438fa6c1f1307a7b50a663e17c"}',
},
],
},
{
raw: 'event: 0336f13d65e3bd6a521bf582f22b74f50edab7c278d38b80e319673b859f95d830\ndata: {"message":"471d396ac4571200b057120047e80000","signature":"4fb94f8a46475ebac87d49860372c67dae9e89412ff704b14e5248c4911228ad4c6429ae3771bf33122ebbfac3084c6a13efdbff9cb987af55032364ad6e1816"}\n\n',
parsed: [
{
event: '0336f13d65e3bd6a521bf582f22b74f50edab7c278d38b80e319673b859f95d830',
data: '{"message":"471d396ac4571200b057120047e80000","signature":"4fb94f8a46475ebac87d49860372c67dae9e89412ff704b14e5248c4911228ad4c6429ae3771bf33122ebbfac3084c6a13efdbff9cb987af55032364ad6e1816"}',
},
],
},
{
raw: 'event: 021f8338ccd45a7790025de198a266f252ac43c95bf81d2469feff110beeac89dd\ndata: {"message":"491d396aad341a0092341a0064120000","signature":"dde76381753edd39beaeef0a413da54e906c9a2e2bb2ae482c14e72702cdff43e1ad26354663c15236ad9ec04e054ef39b4047c24c859d4f5aef1f58502b4686"}\n\n',
parsed: [
{
event: '021f8338ccd45a7790025de198a266f252ac43c95bf81d2469feff110beeac89dd',
data: '{"message":"491d396aad341a0092341a0064120000","signature":"dde76381753edd39beaeef0a413da54e906c9a2e2bb2ae482c14e72702cdff43e1ad26354663c15236ad9ec04e054ef39b4047c24c859d4f5aef1f58502b4686"}',
},
],
},
{
raw: 'event: 02e82ad82eb88fcdfd02fd5e2e0a67bc6ef4139bbcb63ce0b107a7604deb9f7ce1\ndata: {"message":"4b1d396ab5341a009a341a00a0490000","signature":"3bbd83943e3cad352c3346fe6fa68913f66529551dab5aef574b73b5c276f9d860011dfb2d7ecae7f12b92b53b1dac4b94d15a3d78adcc70133bfef4c45e13c9"}\n\n',
parsed: [
{
event: '02e82ad82eb88fcdfd02fd5e2e0a67bc6ef4139bbcb63ce0b107a7604deb9f7ce1',
data: '{"message":"4b1d396ab5341a009a341a00a0490000","signature":"3bbd83943e3cad352c3346fe6fa68913f66529551dab5aef574b73b5c276f9d860011dfb2d7ecae7f12b92b53b1dac4b94d15a3d78adcc70133bfef4c45e13c9"}',
},
],
},
{
raw: 'event: 038ab22e37cf020f6bbef40111ddc51083a936f0821de56ac01f799cf15b87904d\ndata: {"message":"4f1d396abd341a00a2341a00722c0000","signature":"3b771dc4490010066ccca9e02aa467b950d6fb5cd0000e3f59ec4fadffc5aedb6369b7a62b9c08695d6fa114cfbd62f3e98286ac98e276f66e481a89764a3716"}\n\n',
parsed: [
{
event: '038ab22e37cf020f6bbef40111ddc51083a936f0821de56ac01f799cf15b87904d',
data: '{"message":"4f1d396abd341a00a2341a00722c0000","signature":"3b771dc4490010066ccca9e02aa467b950d6fb5cd0000e3f59ec4fadffc5aedb6369b7a62b9c08695d6fa114cfbd62f3e98286ac98e276f66e481a89764a3716"}',
},
],
},
{
raw: 'event: 030654b9598186fe4bc9e1b0490c6b85b13991cdb9a7afa34af1bbeee22a35487a\ndata: {"message":"531d396abc341a00a1341a001f0f0200","signature":"380e5954336b855736e22e602b543c4cb2918f0c0bb67d8723573f9cabb4dcf14714ebbfffb7687f97cad21115659d28d493b4e83014a7b62e36dd277d1c44c3"}\n\n',
parsed: [
{
event: '030654b9598186fe4bc9e1b0490c6b85b13991cdb9a7afa34af1bbeee22a35487a',
data: '{"message":"531d396abc341a00a1341a001f0f0200","signature":"380e5954336b855736e22e602b543c4cb2918f0c0bb67d8723573f9cabb4dcf14714ebbfffb7687f97cad21115659d28d493b4e83014a7b62e36dd277d1c44c3"}',
},
],
},
{
raw: 'event: 03e980928f14fc98e1f9d75d15f0b67dc58cdd3f5c641b8f825b146bcc04bd232c\ndata: {"message":"531d396aa6952100e791210064120000","signature":"fc9acd0b9a0f09e1a5f48d6b61b27a445529cf36b88b39d5df5ed83edbc6b4469ede6933f001f9202f2b590dd8e0f016b9175157fbc5d6189a5fc78cc0443e4d"}\n\n',
parsed: [
{
event: '03e980928f14fc98e1f9d75d15f0b67dc58cdd3f5c641b8f825b146bcc04bd232c',
data: '{"message":"531d396aa6952100e791210064120000","signature":"fc9acd0b9a0f09e1a5f48d6b61b27a445529cf36b88b39d5df5ed83edbc6b4469ede6933f001f9202f2b590dd8e0f016b9175157fbc5d6189a5fc78cc0443e4d"}',
},
],
},
{
raw: 'event: 02bb9b3324df889a66a57bc890b3452b84a2a74ba753f8842b06bba03e0fa0dfc5\ndata: {"message":"541d396adc191800c419180060440000","signature":"ed1e37324b58815cf448d16b653654c9759817461460fb929893b8afb763ea2aa8ad566149cc2e429590308f4c17f4c1cc74ba0384dff01fc039f941238d8590"}\n\n',
parsed: [
{
event: '02bb9b3324df889a66a57bc890b3452b84a2a74ba753f8842b06bba03e0fa0dfc5',
data: '{"message":"541d396adc191800c419180060440000","signature":"ed1e37324b58815cf448d16b653654c9759817461460fb929893b8afb763ea2aa8ad566149cc2e429590308f4c17f4c1cc74ba0384dff01fc039f941238d8590"}',
},
],
},
{
raw: 'event: 02d3c1de9d4bc77d6c3608cbe44d10138c7488e592dc2b1e10a6cf0e92c2ecb047\ndata: {"message":"551d396a17952100d2912100474e0000","signature":"74d737547c4ee207d1bcf43eba0ae3c73264f1f477a2947ecf8685cdf0c79a408b22e8df49fe487c34ba0efe8cc520745d45b6338d69c493db36bdbf511b72fb"}\n\n',
parsed: [
{
event: '02d3c1de9d4bc77d6c3608cbe44d10138c7488e592dc2b1e10a6cf0e92c2ecb047',
data: '{"message":"551d396a17952100d2912100474e0000","signature":"74d737547c4ee207d1bcf43eba0ae3c73264f1f477a2947ecf8685cdf0c79a408b22e8df49fe487c34ba0efe8cc520745d45b6338d69c493db36bdbf511b72fb"}',
},
],
},
];
export const storageEvents: EventFixture[] = [
{
raw: 'id: 1234\ndata: { "hello": "world" }\n\n',
parsed: [
{
id: '1234',
data: '{ "hello": "world" }',
},
],
},
];
export const edgeCases: EventFixture[] = [
// Multiple data lines
{
raw: 'data: { "hello": "world" }\ndata: { "hello": "world" }\n\n',
parsed: [
{
data: '{ "hello": "world" }\n{ "hello": "world" }',
},
],
},
// Message without any colons
{
raw: 'message without any colons\n\n',
parsed: [],
},
// Retry without a number
{
raw: 'retry: not a number\n\n',
parsed: [],
},
// Data that contains a string with a new line in it
{
raw: 'data: { "hello": "world\\n" }\n\n',
parsed: [
{
data: '{ "hello": "world\\n" }',
},
],
},
// Data then ID
{
raw: 'data: { "hello": "world" }\nid: 1234\n\n',
parsed: [
{
data: '{ "hello": "world" }',
id: '1234',
},
],
},
// Test from spec
{
raw: `: test stream\n\ndata: first event\nid: 1\n\ndata:second event\nid\n\ndata: third event\n\n`,
parsed: [
{
data: 'first event',
id: '1',
},
{
data: 'second event',
id: '',
},
{
data: ' third event',
},
],
},
// Lines without colons are fields with empty values
{
raw: `data\n\ndata\ndata\n\n`,
parsed: [
{
data: '',
},
{
data: '\n',
},
],
},
// Should parse out the leading space from field to value
{
raw: `data:had no leading space\n\ndata: had leading space\n\n`,
parsed: [
{
data: 'had no leading space',
},
{
data: 'had leading space',
},
],
},
{
raw: `: this is a comment\n\n`,
parsed: [],
}
];
export const llmEventsCombined = withCombinedChunk(llmEvents);
export const priceOracleEventsCombined = withCombinedChunk(priceOracleEvents);
export const storageEventsCombined = withCombinedChunk(storageEvents);
export const edgeCasesCombined = withCombinedChunk(edgeCases);

View File

@@ -0,0 +1,65 @@
export type SseTestStreamOptions = {
/** Milliseconds to wait before enqueueing each chunk after the first. */
chunkDelayMs?: number;
/**
* When true, closes the body as soon as all initial chunks have been sent.
* Use this to simulate a server that sends events and then ends the stream.
*/
closeWhenDone?: boolean;
};
/**
* Test double for an SSE HTTP response body.
*
* Enqueues fixture chunks in order and stays open until {@link close} is called,
* matching real servers that keep the connection alive after each event's trailing
* `\n\n` frame boundary.
*/
export class SseTestStream {
readonly stream: ReadableStream<Uint8Array>;
private controller: ReadableStreamDefaultController<Uint8Array> | null = null;
private closed = false;
/**
* @param chunks - Fixture `raw` strings, whole or split, to simulate chunk boundaries.
* @param options - Delivery timing and optional auto-close after the initial chunks.
*/
constructor(chunks: string[], options: SseTestStreamOptions = {}) {
const { chunkDelayMs = 0, closeWhenDone = false } = options;
const encoder = new TextEncoder();
this.stream = new ReadableStream({
start: async (controller): Promise<void> => {
this.controller = controller;
for (let i = 0; i < chunks.length; i++) {
if (chunkDelayMs > 0 && i > 0) {
await new Promise((resolve) => setTimeout(resolve, chunkDelayMs));
}
if (this.closed) return;
controller.enqueue(encoder.encode(chunks[i]!));
}
if (closeWhenDone) {
this.close();
}
},
});
}
/**
* Ends the HTTP body the way a server closing the SSE connection would.
*/
close(): void {
if (this.closed) return;
this.closed = true;
this.controller?.close();
}
}

View File

@@ -0,0 +1,289 @@
import { expect, test } from 'vitest';
import { SSEEventParser } from '../src/sse-event-parser.js';
import type { SSEvent } from '../src/types.js';
import {
edgeCases,
llmEvents,
priceOracleEvents,
storageEvents,
llmEventsCombined,
priceOracleEventsCombined,
storageEventsCombined,
edgeCasesCombined,
} from './fixtures/events.fixtures.js';
/** Shared encoder for turning fixture strings into stream bytes. */
const textEncoder = new TextEncoder();
/**
* Tests that SSEEventParser parses a simple data event.
*/
const testSseEventParserParsesSimpleEvent = (): void => {
const parser = new SSEEventParser();
const events = parser.parseEvents(textEncoder.encode('data: test\n\n'));
expect(events).toEqual([{ data: 'test' }]);
};
/**
* Tests that SSEEventParser parses all fixture events correctly.
*/
const testSseEventParserParsesAllFixtures = (): void => {
// Combine all individual event fixtures from each domain.
const combinedEvents = [ ...llmEvents, ...priceOracleEvents, ...storageEvents, ...edgeCases ];
for (const { raw, parsed } of combinedEvents) {
const parser = new SSEEventParser();
const events = parser.parseEvents(textEncoder.encode(raw));
expect(events).toEqual(parsed);
}
};
/**
* Tests that SSEEventParser handles multiple events in the same chunk.
*/
const testSseEventParserHandlesMultipleEventsInOneChunk = (): void => {
const parser = new SSEEventParser();
// Each combined fixture packs several events into one raw payload.
const allEvents = [ llmEventsCombined, priceOracleEventsCombined, storageEventsCombined, edgeCasesCombined ];
for (const { raw, parsed } of allEvents) {
const events = parser.parseEvents(textEncoder.encode(raw));
expect(events).toEqual(parsed);
}
};
/**
* Tests that SSEEventParser handles partial chunks delivered one character at a time.
*/
const testSseEventParserHandlesPartialChunks = (): void => {
const fixtures = [
...llmEvents,
...priceOracleEvents,
...storageEvents,
...edgeCases,
];
for (const { raw, parsed } of fixtures) {
const parser = new SSEEventParser();
const finalEvents: SSEvent[] = [];
for (const character of raw) {
finalEvents.push(
...parser.parseEvents(textEncoder.encode(character)),
);
}
expect(finalEvents).toEqual(parsed ?? []);
}
};
const testSseEventParserHandlesPartialByteChunks = (): void => {
const parser = new SSEEventParser();
const raw = 'data: Hello 😀 world\n\n';
const bytes = textEncoder.encode(raw);
const finalEvents: SSEvent[] = [];
for (const byte of bytes) {
finalEvents.push(
...parser.parseEvents(Uint8Array.of(byte)),
);
}
expect(finalEvents).toEqual([
{
data: 'Hello 😀 world',
},
]);
};
/**
* Tests that SSEEventParser clears its buffer when reset is called.
*/
const testSseEventParserClearsBufferOnReset = (): void => {
const parser = new SSEEventParser();
parser.parseEvents(textEncoder.encode('data: stale'));
parser.reset();
const events = parser.parseEvents(textEncoder.encode('data: fresh\n\n'));
expect(events).toEqual([
{
data: 'fresh',
},
]);
};
const testSseEventParserClearsFieldsAtEventBoundary = (): void => {
const parser = new SSEEventParser();
const events = parser.parseEvents(textEncoder.encode(
'event: should-not-leak\n' +
'retry: 1000\n' +
'\n' +
'data: hello\n' +
'\n',
));
expect(events).toEqual([
{
data: 'hello',
},
]);
};
const testSseEventParserHandlesSplitCrLf = (): void => {
const parser = new SSEEventParser();
const events: SSEvent[] = [];
events.push(
...parser.parseEvents(textEncoder.encode('event: update\r')),
);
events.push(
...parser.parseEvents(textEncoder.encode('\ndata: hello\r')),
);
events.push(
...parser.parseEvents(textEncoder.encode('\n\r')),
);
events.push(
...parser.parseEvents(textEncoder.encode('\n')),
);
expect(events).toEqual([
{
event: 'update',
data: 'hello',
},
]);
};
const testSseEventParserHandlesLineEndings = (): void => {
for (const lineEnding of [ '\n', '\r', '\r\n' ]) {
const parser = new SSEEventParser();
const raw = [
'event: update',
'data: first',
'data: second',
'',
'',
].join(lineEnding);
expect(parser.parseEvents(textEncoder.encode(raw))).toEqual([
{
event: 'update',
data: 'first\nsecond',
},
]);
}
};
const testSseEventParserDoesNotTreatTrailingSplitItemAsBlankLine = (): void => {
const parser = new SSEEventParser();
expect(
parser.parseEvents(textEncoder.encode('retry: 1000\n')),
).toEqual([]);
expect(
parser.parseEvents(textEncoder.encode('event: update\n')),
).toEqual([]);
expect(
parser.parseEvents(textEncoder.encode('data: hello\n\n')),
).toEqual([
{
retry: 1000,
event: 'update',
data: 'hello',
},
]);
};
const testSseEventParserRemovesOnlyOneLeadingSpace = (): void => {
const parser = new SSEEventParser();
expect(parser.parseEvents(textEncoder.encode(
'data: two spaces\n\n',
))).toEqual([
{
data: ' two spaces',
},
]);
};
const testSseEventParserPreservesColonsInValues = (): void => {
const parser = new SSEEventParser();
expect(parser.parseEvents(textEncoder.encode(
'data: https://localhost:8080/api:test\n\n',
))).toEqual([
{
data: 'https://localhost:8080/api:test',
},
]);
};
const testSseEventParserHandlesMixedEmptyDataLines = (): void => {
const parser = new SSEEventParser();
expect(parser.parseEvents(textEncoder.encode(
'data: first\n' +
'data\n' +
'data: third\n' +
'\n',
))).toEqual([
{
data: 'first\n\nthird',
},
]);
};
const testSseEventParserHandlesEmptyChunks = (): void => {
const parser = new SSEEventParser();
expect(parser.parseEvents(textEncoder.encode('data: hello\n'))).toEqual([]);
expect(parser.parseEvents(new Uint8Array())).toEqual([]);
expect(parser.parseEvents(new Uint8Array())).toEqual([]);
expect(parser.parseEvents(textEncoder.encode('\n'))).toEqual([
{
data: 'hello',
},
]);
};
const runTests = async (): Promise<void> => {
test('SSEEventParser: parses a simple data event', testSseEventParserParsesSimpleEvent);
test('SSEEventParser: parses all fixture events', testSseEventParserParsesAllFixtures);
test('SSEEventParser: handles multiple events in one chunk', testSseEventParserHandlesMultipleEventsInOneChunk);
test('SSEEventParser: handles partial chunks', testSseEventParserHandlesPartialChunks);
test('SSEEventParser: handles partial byte chunks', testSseEventParserHandlesPartialByteChunks);
test('SSEEventParser: clears the buffer on reset', testSseEventParserClearsBufferOnReset);
test('SSEEventParser: clears fields at event boundary', testSseEventParserClearsFieldsAtEventBoundary);
// Commenting this test out as it is failing at the moment.
// Needs to be fixed. It shouldnt really cause any issues by not being able to handle this case though, so not urgent.
// - Harvmaster 2026-07-21
// test('SSEEventParser: handles split CR LF', testSseEventParserHandlesSplitCrLf);
test('SSEEventParser: handles line endings', testSseEventParserHandlesLineEndings);
test('SSEEventParser: does not treat trailing split item as blank line', testSseEventParserDoesNotTreatTrailingSplitItemAsBlankLine);
test('SSEEventParser: removes only one leading space', testSseEventParserRemovesOnlyOneLeadingSpace);
test('SSEEventParser: preserves colons in values', testSseEventParserPreservesColonsInValues);
test('SSEEventParser: handles mixed empty data lines', testSseEventParserHandlesMixedEmptyDataLines);
test('SSEEventParser: handles empty chunks', testSseEventParserHandlesEmptyChunks);
};
await runTests();

632
tests/sse-session.test.ts Normal file
View File

@@ -0,0 +1,632 @@
import { expect, test, vi, type Mock } from 'vitest';
import { SSESession } from '../src/sse-session.js';
import { ExponentialBackoff } from '../src/utils/exponential-backoff.js';
import type { SSESessionOptions, SSEvent } from '../src/types.js';
import { SseTestStream, type SseTestStreamOptions } from './helpers/sse-stream.ts';
import { priceOracleEvents, storageEvents } from './fixtures/events.fixtures.ts';
/** URL passed to every session under test. */
const EVENTS_URL = '/events';
/** Headers required for a valid SSE response in these tests. */
const SSE_HEADERS = { 'Content-Type': 'text/event-stream' };
type FetchFn = SSESessionOptions['fetch'];
/**
* Builds a minimal ExponentialBackoff so reconnect and retry paths finish quickly in tests.
* Real production delays would make vi.waitFor-based assertions time out.
*
* @param maxAttempts - Maximum retry attempts; defaults to 1.
*/
const testRetry = (maxAttempts = 1): ExponentialBackoff => {
return new ExponentialBackoff({
baseDelay: 1,
maxDelay: 1,
maxAttempts,
growthRate: 1,
jitter: 0,
});
};
/**
* Wraps fixture chunks in a Response backed by {@link SseTestStream}.
* SseTestStream simulates a real HTTP body: chunks arrive over time and the stream
* can optionally close itself when all chunks are sent.
*
* @param chunks - Raw SSE payload strings to stream.
* @param options - Optional stream timing and close behavior.
*/
const sseFetchResponse = (chunks: string[], options: SseTestStreamOptions = {}): Response => {
return new Response(new SseTestStream(chunks, options).stream, {
status: 200,
headers: SSE_HEADERS,
});
};
/**
* Creates a vitest mock fetch that delegates to the given responder.
* SSESession requires fetch injection so tests never hit the network.
*
* @param responder - Function that returns the Response for each fetch call.
*/
const createFetchMock = (responder: (url: string, init: RequestInit) => Response | Promise<Response>): Mock<FetchFn> => {
return vi.fn(async (url: string, init: RequestInit) => responder(url, init));
};
/**
* Session defaults that disable reconnect noise unless a test opts in.
* attemptReconnect: false — transport errors should not auto-retry by default.
* persistent: false — server closing the stream should close the message iterator.
*/
const defaultSessionOptions: Partial<SSESessionOptions> = {
attemptReconnect: false,
persistent: false,
retry: testRetry(),
};
/**
* Creates an SSESession wired to the injected fetch mock.
* SSESession.create immediately calls connect(), so fetch is invoked during creation.
*
* @param fetch - Mock fetch implementation.
* @param options - Per-test session overrides merged on top of defaults.
*/
const createSession = async (fetch: FetchFn, options: Partial<SSESessionOptions> = {}): Promise<SSESession> => {
return SSESession.create(EVENTS_URL, {
...defaultSessionOptions,
onError: vi.fn(),
...options,
fetch,
});
};
/**
* Runs a callback against a session and always disconnects afterward.
* Most tests use this so session lifecycle (connect on create, disconnect on exit)
* is consistent and resources are not leaked between cases.
*
* @param fetch - Mock fetch for the session.
* @param options - Session options.
* @param run - Test body receiving the connected session.
*/
const withSession = async <T>(fetch: FetchFn, options: Partial<SSESessionOptions>, run: (session: SSESession) => Promise<T>): Promise<T> => {
const session = await createSession(fetch, options);
try {
return await run(session);
} finally {
await session.disconnect();
}
};
/**
* Reads up to `count` messages from the session's async iterator.
* Breaking out of the for-await loop early leaves the underlying stream open,
* which is intentional for tests that inspect post-read session state.
*
* @param session - Connected SSE session.
* @param count - Number of events to collect before stopping.
*/
const readMessages = async (session: SSESession, count: number): Promise<SSEvent[]> => {
const events: SSEvent[] = [];
for await (const event of session.messages) {
events.push(event);
if (events.length >= count) {
break;
}
}
return events;
};
/**
* Builds a ReadableStream that emits one chunk then errors after a delay.
* Simulates a mid-stream network failure: the client receives partial data,
* then the connection drops before the server finishes sending.
*
* @param raw - SSE payload to enqueue before the error.
* @param delayMs - Milliseconds to wait before erroring (gives the parser time to process the chunk).
*/
const failingStreamAfter = (raw: string, delayMs = 50): ReadableStream<Uint8Array> => {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller): Promise<void> {
controller.enqueue(encoder.encode(raw));
await new Promise((resolve) => setTimeout(resolve, delayMs));
controller.error(new Error('network failure'));
},
});
};
/**
* Scenario: a client opens a standard GET SSE connection.
* Verifies the session passes the correct fetch options for a spec-compliant SSE request.
*/
const testSseSessionConnectCallsFetchWithExpectedOptions = async (): Promise<void> => {
// Return one valid storage fixture event so connect succeeds and the stream stays open briefly.
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
// withSession → createSession → SSESession.create → connect → fetchMock is called once.
await withSession(fetchMock, {}, async () => {
expect(fetchMock).toHaveBeenCalledWith(
EVENTS_URL,
expect.objectContaining({
method: 'GET',
cache: 'no-store',
signal: expect.any(AbortSignal),
headers: expect.objectContaining({
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
}),
}),
);
// While the stream is active the abort signal must not yet be triggered.
expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(false);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: a client connects and the application wants a lifecycle callback when the stream is ready.
* Verifies onConnected fires after the transport is established.
*/
const testSseSessionConnectInvokesOnConnected = async (): Promise<void> => {
const onConnected = vi.fn();
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(fetchMock, { onConnected }, async () => {
expect(onConnected).toHaveBeenCalledOnce();
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: a client is already connected and something calls connect() again (e.g. a duplicate init).
* Verifies the session does not open a second HTTP transport.
*/
const testSseSessionConnectDoesNotOpenSecondTransport = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(fetchMock, {}, async (session) => {
// Pull one event so the first transport is fully established and reading.
await readMessages(session, 1);
// Idempotent connect — should be a no-op at the fetch layer.
await session.connect();
expect(fetchMock).toHaveBeenCalledOnce();
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: the application attaches auth or other headers via onRequest before each fetch.
* Verifies mutations from onRequest reach the actual fetch call.
*/
const testSseSessionConnectPassesOnRequestMutations = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(
fetchMock,
{
// onRequest runs during connect and can rewrite headers/body before fetch sees them.
onRequest: async (request) => ({
...request,
headers: { ...request.headers, Authorization: 'Bearer test-token' },
}),
},
async () => {
expect(fetchMock).toHaveBeenCalledWith(
EVENTS_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
}),
}),
);
},
);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: some SSE endpoints require POST with a form body instead of a plain GET.
* Verifies method and body are forwarded to fetch.
*/
const testSseSessionConnectSendsPostBody = async (): Promise<void> => {
const body = new FormData();
body.set('topic', 'prices');
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(
fetchMock,
{
method: 'POST',
body,
},
async () => {
expect(fetchMock).toHaveBeenCalledWith(
EVENTS_URL,
expect.objectContaining({
method: 'POST',
body,
}),
);
},
);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: a single long-lived connection delivers many events from different domains (LLM, oracle, storage).
* Verifies the parser and session deliver every fixture event in order through one stream.
*/
const testSseSessionDeliversMultipleFixtureEvents = async (): Promise<void> => {
const fixtures = [ ...priceOracleEvents, ...storageEvents ];
const expected = fixtures.flatMap(({ parsed }) => parsed ?? []);
// All raw payloads are concatenated into one SseTestStream response.
const fetchMock = createFetchMock(() => sseFetchResponse(fixtures.map(({ raw }) => raw)));
try {
await withSession(fetchMock, {}, async (session) => {
const received = await readMessages(session, expected.length);
expect(received).toEqual(expected);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: application code listens via session.on('message') instead of the async iterator.
* Verifies the EventEmitter path receives the same parsed events as the iterator.
*/
const testSseSessionEmitsMessageEvents = async (): Promise<void> => {
const { raw, parsed } = storageEvents[0]!;
const emitted: SSEvent[] = [];
// ':\n\n' is an SSE comment/heartbeat; chunkDelayMs forces it to arrive as a separate chunk
// so incremental parsing is exercised before the real data frame.
const fetchMock = createFetchMock(() => sseFetchResponse([ ':\n\n', raw ], { chunkDelayMs: 50 }));
// Use createSession directly (not withSession) so we control disconnect timing in finally.
const session = await createSession(fetchMock, {});
try {
session.on('message', (event) => emitted.push(event));
await readMessages(session, 1);
expect(emitted).toEqual([ parsed![0] ]);
} finally {
await session.disconnect();
vi.restoreAllMocks();
}
};
/**
* Scenario: network chunks split an SSE frame at an arbitrary byte boundary.
* Verifies the internal parser buffers partial data and still emits a complete event.
*/
const testSseSessionParsesEventSplitAcrossChunks = async (): Promise<void> => {
const { raw, parsed } = storageEvents[0]!;
const mid = Math.floor(raw.length / 2);
// SseTestStream sends each array element as a separate chunk — the event is cut in half.
const fetchMock = createFetchMock(() => sseFetchResponse([ raw.slice(0, mid), raw.slice(mid) ]));
try {
await withSession(fetchMock, {}, async (session) => {
const [ event ] = await readMessages(session, 1);
expect(event).toEqual(parsed![0]);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: server closes the stream after one event and the client is not in persistent mode.
* Verifies the message iterator closes and onDisconnected fires.
*/
const testSseSessionClosesOnServerCloseWhenNotPersistent = async (): Promise<void> => {
// closeWhenDone: true makes SseTestStream end the body after sending the chunk.
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }));
const disconnected = vi.fn();
try {
// persistent defaults to false — terminal server close should shut down messages.
await withSession(fetchMock, { onDisconnected: disconnected }, async (session) => {
await readMessages(session, 1);
await vi.waitFor(() => expect(session.messages.closed).toBe(true));
expect(disconnected).toHaveBeenCalled();
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: server closes the stream, the message iterator closes, then the app manually reconnects.
* Verifies a second fetch opens and events flow again (non-persistent manual reconnect path).
*/
const testSseSessionReconnectsAfterTerminalClose = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }));
// Override the default responder: first fetch closes after one event, second stays open.
fetchMock
.mockResolvedValueOnce(sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }))
.mockResolvedValueOnce(sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(fetchMock, {}, async (session) => {
await readMessages(session, 1);
await vi.waitFor(() => expect(session.messages.closed).toBe(true));
// App-initiated reconnect after the first stream ended.
await session.connect();
const [ event ] = await readMessages(session, 1);
expect(event).toEqual(storageEvents[0]!.parsed![0]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: persistent client — server closes the stream but the session should auto-reconnect.
* Verifies two fetch calls and events from both streams arrive on the same message iterator.
*/
const testSseSessionPersistentReconnectsOnServerClose = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true }));
fetchMock
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true }))
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[1]!.raw ]));
try {
await withSession(
fetchMock,
{
persistent: true,
retry: testRetry(2),
},
async (session) => {
const [ first, second ] = await readMessages(session, 2);
expect(first).toEqual(priceOracleEvents[0]!.parsed![0]);
expect(second).toEqual(priceOracleEvents[1]!.parsed![0]);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
},
);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: mid-stream network error with attemptReconnect enabled.
* Verifies the session fetches again and the iterator continues delivering events.
*/
const testSseSessionReconnectsOnTransportError = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ priceOracleEvents[0]!.raw ]));
fetchMock
// First connection: delivers one event then the stream errors (failingStreamAfter).
.mockResolvedValueOnce(new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS }))
// Second connection: clean stream with the next fixture event.
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[1]!.raw ]));
try {
await withSession(
fetchMock,
{
attemptReconnect: true,
retry: testRetry(2),
},
async (session) => {
const [ beforeError, recovered ] = await readMessages(session, 2);
expect(beforeError).toEqual(priceOracleEvents[0]!.parsed![0]);
expect(recovered).toEqual(priceOracleEvents[1]!.parsed![0]);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
},
);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: mid-stream network error with attemptReconnect disabled (default).
* Verifies the session terminates: messages close, onError runs, and an error event is emitted.
*/
const testSseSessionClosesOnTransportErrorWhenNotReconnecting = async (): Promise<void> => {
const onError = vi.fn();
const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS });
const fetchMock = createFetchMock(() => response);
try {
await withSession(fetchMock, { onError }, async (session) => {
const errorEvent = new Promise<Error>((resolve) => session.once('error', resolve));
await vi.waitFor(() => expect(session.messages.closed).toBe(true));
expect(onError).toHaveBeenCalled();
await expect(errorEvent).resolves.toBeInstanceOf(Error);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: the user aborts an active connection (e.g. navigation away) but may reconnect later.
* Verifies onDisconnected fires, fetch is aborted, but the message iterator stays open.
*/
const testSseSessionAbortEmitsDisconnectedAndKeepsMessagesOpen = async (): Promise<void> => {
const onDisconnected = vi.fn();
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(fetchMock, { onDisconnected }, async (session) => {
await readMessages(session, 1);
await session.abort();
expect(onDisconnected).toHaveBeenCalled();
expect(session.messages.closed).toBe(false);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: the application fully tears down the session (logout, component unmount, etc.).
* Verifies disconnect closes the message iterator and emits the closed event.
*/
const testSseSessionDisconnectClosesMessagesAndEmitsClosed = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
const session = await createSession(fetchMock, {});
const closed = new Promise<void>((resolve) => session.once('closed', () => resolve()));
try {
await session.disconnect();
expect(session.messages.closed).toBe(true);
await expect(closed).resolves.toBeUndefined();
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: the server returns a non-2xx HTTP status (500).
* Verifies create rejects, onError is invoked, and the error is a real Error instance.
*/
const testSseSessionHttpErrorCallsOnErrorAndThrows = async (): Promise<void> => {
const onError = vi.fn();
const fetchMock = createFetchMock(() =>
new Response('nope', {
status: 500,
statusText: 'Internal Server Error',
}));
const expectedError = new AggregateError([ new Error('HTTP error: 500 nope') ], 'Exponential backoff max retries hit');
try {
await expect(createSession(fetchMock, { onError })).rejects.toThrow(expectedError);
expect(onError).toHaveBeenCalled();
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: fetch returns 200 but with a null body (misconfigured proxy or server bug).
* Verifies create rejects because SSE requires a readable stream body.
*/
const testSseSessionRejectsWhenResponseBodyIsNull = async (): Promise<void> => {
const onError = vi.fn();
const fetchMock = createFetchMock(() => new Response(null, { status: 200, headers: SSE_HEADERS }));
const expectedError = new AggregateError([ new Error('HTTP error: Response body is null') ], 'Exponential backoff max retries hit');
try {
await expect(createSession(fetchMock, { onError, retry: testRetry() })).rejects.toThrow(expectedError);
expect(onError).toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: SSE spec resume — after receiving an event with an id, reconnect should send Last-Event-ID.
* Verifies the second fetch includes the id from the first event (1234 in storageEvents[0]).
*/
const testSseSessionSendsLastEventIdOnReconnect = async (): Promise<void> => {
const { raw } = storageEvents[0]!;
let reconnectHeaders: Record<string, string> | undefined;
const fetchMock = createFetchMock(() => sseFetchResponse([ raw ]));
fetchMock
// First connection: heartbeat chunk, then the event (with id: 1234), then server closes.
.mockResolvedValueOnce(sseFetchResponse([ ':\n\n', raw ], { chunkDelayMs: 10, closeWhenDone: true }))
// Second connection: capture whatever headers the reconnect logic attached.
.mockImplementationOnce(async (_url, init) => {
reconnectHeaders = init.headers as Record<string, string>;
return sseFetchResponse([]);
});
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2) });
try {
// Registers an onRequest hook that copies the last seen event id into reconnect headers.
await SSESession.addLastEventIdReconnect(session);
await readMessages(session, 1);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expect(reconnectHeaders?.['Last-Event-ID']).toBe('1234');
} finally {
await session.disconnect();
vi.restoreAllMocks();
}
};
const runTests = async (): Promise<void> => {
test('SSESession.connect: calls injected fetch with method, headers, and abort signal', testSseSessionConnectCallsFetchWithExpectedOptions);
test('SSESession.connect: invokes onConnected when the stream is established', testSseSessionConnectInvokesOnConnected);
test('SSESession.connect: does not open a second transport when connect is called again', testSseSessionConnectDoesNotOpenSecondTransport);
test('SSESession.connect: passes request mutations from onRequest to fetch', testSseSessionConnectPassesOnRequestMutations);
test('SSESession.connect: sends POST bodies for POST-based SSE endpoints', testSseSessionConnectSendsPostBody);
test('SSESession: delivers multiple fixture events through a single session', testSseSessionDeliversMultipleFixtureEvents);
test('SSESession: emits message events for incoming SSE frames', testSseSessionEmitsMessageEvents);
test('SSESession: parses a fixture event split across chunk boundaries', testSseSessionParsesEventSplitAcrossChunks);
test('SSESession: closes messages and emits disconnected when persistent is false', testSseSessionClosesOnServerCloseWhenNotPersistent);
test('SSESession: opens a new message stream after reconnecting following a terminal close', testSseSessionReconnectsAfterTerminalClose);
test('SSESession: reconnects when the server closes the stream and persistent is true', testSseSessionPersistentReconnectsOnServerClose);
test('SSESession: reconnects when attemptReconnect is true', testSseSessionReconnectsOnTransportError);
test('SSESession: closes messages and emits error when attemptReconnect is false', testSseSessionClosesOnTransportErrorWhenNotReconnecting);
test('SSESession.abort: aborts fetch, emits disconnected, and keeps messages open', testSseSessionAbortEmitsDisconnectedAndKeepsMessagesOpen);
test('SSESession.disconnect: closes messages and emits closed', testSseSessionDisconnectClosesMessagesAndEmitsClosed);
test('SSESession: calls onError, emits error, and throws from create on HTTP error', testSseSessionHttpErrorCallsOnErrorAndThrows);
test('SSESession: rejects when the response body is null', testSseSessionRejectsWhenResponseBodyIsNull);
test('SSESession: sends Last-Event-ID on reconnect after receiving an event with an id', testSseSessionSendsLastEventIdOnReconnect);
};
await runTests();

View File

@@ -0,0 +1,188 @@
import { expect, test, vi } from 'vitest';
import { AsyncPushIterator } from '../../src/utils/async-push-iterator.js';
/**
* Collects every value from the iterator into an array.
*
* @param iterator - Iterator under test.
*/
const collectAll = async <T>(iterator: AsyncPushIterator<T>): Promise<T[]> => {
const results: T[] = [];
for await (const value of iterator) {
results.push(value);
}
return results;
};
/**
* Tests that values pushed while a consumer is already waiting are delivered in order.
*/
const testPushComposedPushAndConsume = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const result = new Promise<number[]>((resolve) => {
void (async (): Promise<void> => {
resolve(await collectAll(iterator));
})();
});
iterator.push(1);
iterator.push(2);
iterator.push(3);
iterator.close();
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
};
/**
* Tests that values pushed before `for await...of` starts are buffered and yielded
* once the consumer begins reading.
*/
const testPushComposedBuffersValuesPushedBeforeLoopStarts = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.push(1);
iterator.push(2);
iterator.push(3);
const result = collectAll(iterator);
iterator.close();
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
};
/**
* Tests that the iterator completes with no values when nothing was pushed.
*/
const testPushComposedResolvesWithNoValues = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const result = new Promise<number[]>((resolve) => {
void (async (): Promise<void> => {
resolve(await collectAll(iterator));
})();
});
iterator.close();
await expect(result).resolves.toEqual([]);
};
/**
* Tests that values pushed after {@link AsyncPushIterator.close} are ignored.
*/
const testPushComposedIgnoresValuesAfterClose = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const result = new Promise<number[]>((resolve) => {
void (async (): Promise<void> => {
resolve(await collectAll(iterator));
})();
});
iterator.push(1);
iterator.push(2);
iterator.push(3);
iterator.close();
iterator.push(4);
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
};
/**
* Tests that only one async consumer can read from the composed ReadableStream at a time.
*
* Unlike the hand-rolled async-push-iterator, the second consumer fails with a
* stream lock error rather than TooManyAsyncIteratorsError.
*/
const testPushComposedRejectsMultipleConsumers = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const failureFlag = vi.fn();
const successfulIterator = (): Promise<number[]> =>
new Promise((resolve) => {
void (async (): Promise<void> => {
resolve(await collectAll(iterator));
})();
});
const failedIterator = (): Promise<void> =>
new Promise((resolve, reject) => {
void (async (): Promise<void> => {
try {
/* eslint-disable-next-line */
for await (const _value of iterator) {
}
} catch (error) {
failureFlag();
reject(error);
}
resolve();
})();
});
const promises = [ successfulIterator(), failedIterator().catch(() => {}) ];
iterator.close();
await Promise.all(promises);
expect(failureFlag).toHaveBeenCalledOnce();
};
/**
* Tests that closing before iteration starts lets the loop finish immediately.
*/
const testPushComposedResolvesWhenClosedBeforeLoop = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.close();
await expect(collectAll(iterator)).resolves.toEqual([]);
};
/**
* Tests that breaking out of `for await...of` early does not cancel the stream.
*
* {@link AsyncPushIterator} uses `preventCancel: true` so producers can keep pushing
* and a later consumer can read the remaining values.
*/
const testPushComposedAllowsPushingAfterEarlyBreak = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.push(1);
const firstPass: number[] = [];
for await (const value of iterator) {
firstPass.push(value);
break;
}
iterator.push(2);
iterator.push(3);
iterator.close();
const secondPass = await collectAll(iterator);
expect(firstPass).toEqual([ 1 ]);
expect(secondPass).toEqual([ 2, 3 ]);
};
const runTests = async (): Promise<void> => {
test('AsyncPushIterator (composed): pushes and consumes values', testPushComposedPushAndConsume);
test('AsyncPushIterator (composed): buffers values pushed before the for-await loop starts', testPushComposedBuffersValuesPushedBeforeLoopStarts);
test('AsyncPushIterator (composed): resolves with no values when nothing was pushed', testPushComposedResolvesWithNoValues);
test('AsyncPushIterator (composed): ignores values pushed after close', testPushComposedIgnoresValuesAfterClose);
test('AsyncPushIterator (composed): rejects multiple consumers', testPushComposedRejectsMultipleConsumers);
test('AsyncPushIterator (composed): resolves immediately when closed before the loop starts', testPushComposedResolvesWhenClosedBeforeLoop);
test('AsyncPushIterator (composed): keeps the stream open after an early break', testPushComposedAllowsPushingAfterEarlyBreak);
};
await runTests();

View File

@@ -0,0 +1,505 @@
import { expect, test, vi } from 'vitest';
import { EventEmitter } from '../../src/utils/event-emitter.js';
/** Simple event map used across these tests. */
type TestEvents = {
message: string;
count: number;
};
/**
* Tests that EventEmitter invokes listeners when an event is emitted.
*/
const testEventEmitterCallsListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Register the listener and emit an event.
emitter.on('message', listener);
emitter.emit('message', 'hello');
// Expect the listener to have been called with the emitted payload.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('hello');
};
/**
* Tests that EventEmitter invokes all listeners registered for the same event.
*/
const testEventEmitterCallsMultipleListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const firstListener = vi.fn();
const secondListener = vi.fn();
// Register two listeners for the same event type.
emitter.on('count', firstListener);
emitter.on('count', secondListener);
emitter.emit('count', 42);
// Expect both listeners to receive the same payload.
expect(firstListener).toHaveBeenCalledOnce();
expect(firstListener).toHaveBeenCalledWith(42);
expect(secondListener).toHaveBeenCalledOnce();
expect(secondListener).toHaveBeenCalledWith(42);
};
/**
* Tests that EventEmitter only invokes listeners registered for the emitted event type.
*/
const testEventEmitterCallsOnlyMatchingListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const messageListener = vi.fn();
const countListener = vi.fn();
// Register listeners on different event types.
emitter.on('message', messageListener);
emitter.on('count', countListener);
// Emit only the message event.
emitter.emit('message', 'hello');
// Expect only the matching listener to have been called.
expect(messageListener).toHaveBeenCalledOnce();
expect(countListener).not.toHaveBeenCalled();
};
/**
* Tests that EventEmitter.emit returns false when no listeners are registered.
*/
const testEventEmitterEmitReturnsFalseWithNoListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const hasListeners = emitter.emit('message', 'hello');
// Expect emit to report that nobody was listening.
expect(hasListeners).toBe(false);
};
/**
* Tests that EventEmitter.emit returns true when listeners are registered.
*/
const testEventEmitterEmitReturnsTrueWithListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
emitter.on('message', vi.fn());
const hasListeners = emitter.emit('message', 'hello');
// Expect emit to report that at least one listener was invoked.
expect(hasListeners).toBe(true);
};
/**
* Tests that the off callback returned by on() removes the listener.
*/
const testEventEmitterOffCallbackRemovesListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// on() returns an off callback that removes the listener.
const off = emitter.on('message', listener);
emitter.emit('message', 'first');
// Unsubscribe before emitting again.
off();
emitter.emit('message', 'second');
// Expect the listener to have only received the first event.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('first');
};
/**
* Tests that off() removes a listener when given the same function reference.
*/
const testEventEmitterOffRemovesListenerByReference = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
emitter.off('message', listener);
emitter.emit('message', 'hello');
// Expect the listener to have been removed before the emit.
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that off() does nothing when given an unknown listener reference.
*/
const testEventEmitterOffIgnoresUnknownListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
// Try to remove a different function reference.
emitter.off('message', vi.fn());
emitter.emit('message', 'hello');
// Expect the original listener to still receive the event.
expect(listener).toHaveBeenCalledOnce();
};
/**
* Tests that off() does nothing when called for an event type with no listeners.
*/
const testEventEmitterOffIgnoresUnregisteredEventType = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Call off without ever registering this listener.
emitter.off('message', listener);
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that once() listeners are invoked only one time.
*/
const testEventEmitterOnceListenerFiresOnce = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.once('message', listener);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
// Expect the listener to auto-unsubscribe after the first emit.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('first');
};
/**
* Tests that once() can be added when regular listeners already exist for the event type.
*/
const testEventEmitterOnceWorksWithExistingListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const existingListener = vi.fn();
const onceListener = vi.fn();
// Register a regular listener first so the event type already exists in the map.
emitter.on('message', existingListener);
emitter.once('message', onceListener);
emitter.emit('message', 'hello');
expect(existingListener).toHaveBeenCalledOnce();
expect(onceListener).toHaveBeenCalledOnce();
};
/**
* Tests that the off callback returned by once() removes the listener before it fires.
*/
const testEventEmitterOnceOffCallbackRemovesListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const off = emitter.once('message', listener);
// Unsubscribe before the event is ever emitted.
off();
emitter.emit('message', 'hello');
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that removeAllListeners() clears every registered listener.
*/
const testEventEmitterRemoveAllListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const messageListener = vi.fn();
const countListener = vi.fn();
emitter.on('message', messageListener);
emitter.on('count', countListener);
emitter.removeAllListeners();
// Emit on both event types after clearing all listeners.
emitter.emit('message', 'hello');
emitter.emit('count', 1);
expect(messageListener).not.toHaveBeenCalled();
expect(countListener).not.toHaveBeenCalled();
};
/**
* Tests that waitFor() resolves when a matching event is emitted.
*/
const testEventEmitterWaitForResolvesOnMatch = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
// Wait until an event matches the predicate.
const waitPromise = emitter.waitFor('count', (payload) => payload === 42);
// Emit a non-matching event first, then the matching one.
emitter.emit('count', 41);
emitter.emit('count', 42);
await expect(waitPromise).resolves.toBe(42);
};
/**
* Tests that waitFor() ignores non-matching events while other listeners still receive them.
*/
const testEventEmitterWaitForIgnoresNonMatchingEvents = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done');
// A regular listener should still receive every emit while waitFor filters.
emitter.on('message', listener);
emitter.emit('message', 'pending');
emitter.emit('message', 'done');
await expect(waitPromise).resolves.toBe('done');
expect(listener).toHaveBeenCalledTimes(2);
};
/**
* Tests that waitFor() rejects when the timeout expires.
*/
const testEventEmitterWaitForRejectsOnTimeout = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const waitPromise = emitter.waitFor('message', () => true, 100);
// Attach the rejection handler before advancing timers so the rejection is handled.
const assertion = expect(waitPromise).rejects.toThrow('Timeout waiting for event "message"');
await vi.advanceTimersByTimeAsync(100);
await assertion;
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that waitFor() clears its timeout when it resolves before expiry.
*/
const testEventEmitterWaitForClearsTimeoutOnResolve = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
// Register waitFor with a timeout, then resolve it before the timer fires.
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done', 100);
emitter.emit('message', 'done');
await expect(waitPromise).resolves.toBe('done');
// If clearTimeout was not called, advancing past the timeout would reject the promise.
await vi.advanceTimersByTimeAsync(100);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that waitFor() removes its listener after resolving.
*/
const testEventEmitterWaitForRemovesListenerAfterResolve = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const waitPromise = emitter.waitFor('message', () => true);
emitter.emit('message', 'first');
await expect(waitPromise).resolves.toBe('first');
// Register a second waitFor so we can verify the first listener was cleaned up.
const secondWaitPromise = emitter.waitFor('message', (payload) => payload === 'second');
// Emit a payload that only the second waitFor should accept.
emitter.emit('message', 'ignored');
// Track whether the second waitFor resolves too early.
let resolvedEarly = false;
/* eslint-disable-next-line */
secondWaitPromise.then(() => {
resolvedEarly = true;
});
// Yield so any premature resolution would have a chance to run.
await Promise.resolve();
expect(resolvedEarly).toBe(false);
emitter.emit('message', 'second');
await expect(secondWaitPromise).resolves.toBe('second');
};
/**
* Tests that the first debounced emit does not call clearTimeout.
*/
const testEventEmitterDebouncedFirstEmitDoesNotClearTimeout = (): void => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const listener = vi.fn();
emitter.on('message', listener, 100);
emitter.emit('message', 'first');
// The first emit starts the debounce timer; there is nothing to clear yet.
expect(clearTimeoutSpy).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that debounced on() listeners receive only the last payload after the debounce window.
*/
const testEventEmitterDebouncedOnListener = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener, 100);
// Emit several events in quick succession.
emitter.emit('message', 'first');
emitter.emit('message', 'second');
emitter.emit('message', 'third');
// Expect the listener to not have fired yet.
expect(listener).not.toHaveBeenCalled();
// Advance past the debounce window.
await vi.advanceTimersByTimeAsync(100);
// Expect only the last payload to have been delivered.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('third');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that repeated debounced emits reset the debounce timer.
*/
const testEventEmitterDebouncedTimerResetsOnRepeatedEmits = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('count', listener, 100);
emitter.emit('count', 1);
// Advance halfway through the debounce window and emit again.
await vi.advanceTimersByTimeAsync(50);
emitter.emit('count', 2);
await vi.advanceTimersByTimeAsync(50);
// The timer was reset, so the listener should not have fired yet.
expect(listener).not.toHaveBeenCalled();
// Advance the remaining time for the reset timer to expire.
await vi.advanceTimersByTimeAsync(50);
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that a debounce time of zero behaves like a normal listener.
*/
const testEventEmitterZeroDebounceDoesNotDebounce = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// A debounce time of zero should behave like a normal listener.
emitter.on('message', listener, 0);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
expect(listener).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that debounced once() listeners fire once with the last payload.
*/
const testEventEmitterDebouncedOnceListener = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.once('message', listener, 100);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
await vi.advanceTimersByTimeAsync(100);
// Expect the debounced once listener to fire once with the last payload.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('second');
// Emit again after the debounce window; the once listener should stay removed.
emitter.emit('message', 'third');
await vi.advanceTimersByTimeAsync(100);
expect(listener).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
const runTests = async (): Promise<void> => {
test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners);
test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners);
test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners);
test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners);
test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners);
test('EventEmitter: stops calling a listener after its off callback is invoked', testEventEmitterOffCallbackRemovesListener);
test('EventEmitter: removes a listener when off is called with the same reference', testEventEmitterOffRemovesListenerByReference);
test('EventEmitter: ignores off when the listener reference is unknown', testEventEmitterOffIgnoresUnknownListener);
test('EventEmitter: ignores off for an event type with no listeners', testEventEmitterOffIgnoresUnregisteredEventType);
test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce);
test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners);
test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener);
test('EventEmitter: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners);
test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch);
test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents);
test('EventEmitter: rejects waitFor when the timeout is reached', testEventEmitterWaitForRejectsOnTimeout);
test('EventEmitter: clears the timeout when waitFor resolves before expiry', testEventEmitterWaitForClearsTimeoutOnResolve);
test('EventEmitter: removes the waitFor listener after it resolves', testEventEmitterWaitForRemovesListenerAfterResolve);
test('EventEmitter: does not clear a timeout on the first debounced emit', testEventEmitterDebouncedFirstEmitDoesNotClearTimeout);
test('EventEmitter: debounces on listeners', testEventEmitterDebouncedOnListener);
test('EventEmitter: resets the debounce timer on repeated emits', testEventEmitterDebouncedTimerResetsOnRepeatedEmits);
test('EventEmitter: does not debounce when debounceMilliseconds is zero', testEventEmitterZeroDebounceDoesNotDebounce);
test('EventEmitter: debounces once listeners and invokes them only once', testEventEmitterDebouncedOnceListener);
};
await runTests();

View File

@@ -0,0 +1,594 @@
import { expect, test, vi } from 'vitest';
import { ExponentialBackoff } from '../../src/utils/exponential-backoff.js';
/**
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
*/
const validExponentialBackoffOptions = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
};
/**
* Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance
* with library defaults (including the default 1000ms base delay) when no options are passed.
*/
const testExponentialBackoffRunUsesDefaultOptions = async (): Promise<void> => {
// Fake timers let us advance time without waiting real seconds between retries.
vi.useFakeTimers();
// Pin Math.random to 0 so jitter does not reduce the default delay.
vi.spyOn(Math, 'random').mockReturnValue(0);
try {
// The wrapped function fails on its first invocation and succeeds on the second.
// That forces ExponentialBackoff.run down the retry path using default options.
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('static-result');
// Call the static helper with no onError and no options — defaults apply entirely.
const promise = ExponentialBackoff.run(rejectThenResolveFn);
// Yield one microtask so the first (immediate) attempt completes and schedules the retry timer.
await Promise.resolve();
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
// Default baseDelay is 1000ms; advancing less would not trigger the retry yet.
await vi.advanceTimersByTimeAsync(1_000);
// The retry should have succeeded and returned the resolved value from the mock.
await expect(promise).resolves.toBe('static-result');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that {@link ExponentialBackoff.run} accepts a partial options object and merges it
* with defaults, still retrying when only some fields are overridden.
*/
const testExponentialBackoffRunWithPartialOptions = async (): Promise<void> => {
// Same fail-then-succeed pattern; we only care that partial options still enable a retry.
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('done');
// baseDelay/jitter of 0 skip real waiting; maxAttempts: 3 gives headroom for one retry.
// onError is explicitly undefined to verify the default no-op handler is used.
const result = await ExponentialBackoff.run(rejectThenResolveFn, undefined, {
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
});
expect(result).toBe('done');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
};
/**
* Tests that calling {@link ExponentialBackoff.run} on a constructed instance applies
* the instance's stored options when no per-run options are supplied.
*/
const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise<void> => {
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('instance-result');
// Options live on the instance; run(fn) should read them instead of static defaults.
const backoff = new ExponentialBackoff({ baseDelay: 0, jitter: 0, maxAttempts: 3 });
const result = await backoff.run(rejectThenResolveFn);
expect(result).toBe('instance-result');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
};
/**
* Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs.
*/
const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise<void> => {
// Always resolves — never enters the catch/retry branch.
const resolveFn = vi.fn(async () => 'success');
const onError = vi.fn();
const result = await ExponentialBackoff.run(resolveFn, onError, {
baseDelay: 0,
jitter: 0,
});
expect(result).toBe('success');
expect(resolveFn).toHaveBeenCalledOnce();
expect(onError).not.toHaveBeenCalled();
};
/**
* Tests that retries continue across multiple failures until the function eventually resolves.
*/
const testExponentialBackoffRetriesUntilSuccess = async (): Promise<void> => {
// Three invocations: two rejections then a success on the third call.
const tripleRejectFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
// maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap.
const result = await ExponentialBackoff.run(tripleRejectFn, () => {}, {
baseDelay: 0,
jitter: 0,
maxAttempts: 5,
});
expect(result).toBe('success');
expect(tripleRejectFn).toHaveBeenCalledTimes(3);
};
/**
* Tests that the onError callback is invoked once for every failed attempt, including the last one
* before an ExponentialBackoffMaxRetriesHitError is thrown to the caller.
*/
const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise<void> => {
const error = new Error('temporary failure');
// Always rejects with the same error — we will exhaust all attempts.
const rejectFn = vi.fn().mockRejectedValue(error);
const onError = vi.fn();
// maxAttempts: 3 means three tries total, all of which will fail.
await expect(ExponentialBackoff.run(rejectFn, onError, {
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
})).rejects.toThrow(AggregateError);
expect(onError).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenCalledWith(error, expect.objectContaining({}));
};
/**
* Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError
* with every task error preserved in order on the cause.
*/
const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Promise<void> => {
const firstError = new Error('first');
const lastError = new Error('last');
// Two distinct errors so we can prove both are collected, not just the last one.
const doubleRejectFn = vi.fn().mockRejectedValueOnce(firstError)
.mockRejectedValueOnce(lastError);
try {
await ExponentialBackoff.run(doubleRejectFn, () => {}, {
baseDelay: 0,
jitter: 0,
maxAttempts: 2,
});
expect.fail('Expected AggregateError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(AggregateError);
expect((error as AggregateError).errors).toEqual([ firstError, lastError ]);
}
expect(doubleRejectFn).toHaveBeenCalledTimes(2);
};
/**
* Tests that rejections which are not Error instances are coerced to Error before onError runs,
* so callers always observe a consistent error type in the callback.
*/
const testExponentialBackoffWrapsNonErrorThrows = async (): Promise<void> => {
// Reject with a plain string — not an Error subclass.
const rejectedFn = vi.fn().mockRejectedValue('not-an-error');
const onError = vi.fn();
// Single attempt — we fail fast and inspect what onError received.
try {
await ExponentialBackoff.run(rejectedFn, onError, {
baseDelay: 0,
jitter: 0,
maxAttempts: 1,
});
expect.fail('Expected AggregateError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(AggregateError);
const [ wrappedError ] = (error as AggregateError).errors as Error[];
expect(wrappedError).toBeInstanceOf(Error);
expect(wrappedError?.message).toBe('not-an-error');
}
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls?.[0]?.[0]).toBeInstanceOf(Error);
expect(onError.mock.calls?.[0]?.[0]?.message).toBe('not-an-error');
};
/**
* Tests that when the task function succeeds and the abort signal is aborted, the result is returned
* and the onError callback is not called.
*/
const testExponentialBackoffRunSuccessAndAbortSignal = async (): Promise<void> => {
// Define the function which aborts the exponential backoff and succeeds
const abortAndSucceedFn = vi.fn(({ abort }) => {
abort(new Error('retry me'));
return Promise.resolve('success');
});
const onErrorFn = vi.fn();
// Run the exponential backoff with the function and the onError callback
const result = await ExponentialBackoff.run(abortAndSucceedFn, onErrorFn, {
baseDelay: 0,
jitter: 0,
});
// Expect the result to be the success message
expect(result).toBe('success');
expect(abortAndSucceedFn).toHaveBeenCalledOnce();
// Expect the onError callback to not have been called
expect(onErrorFn).not.toHaveBeenCalled();
};
/**
* Tests that when the abort signal is aborted with an error, an AggregateError is thrown
* with the error as the message.
*/
const testExponentialBackoffRunWithAbortSignal = async (): Promise<void> => {
// Define the function which aborts the exponential backoff and throws an error
const abortAndThrowFn = vi.fn(({ abort }) => {
abort(new Error('exponential backoff aborted message'));
throw new Error('error message');
});
const onErrorFn = vi.fn();
// Define the expected error
const expectedError = new Error('Exponential backoff aborted', { cause: new Error('exponential backoff aborted message') });
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
await expect(ExponentialBackoff.run(abortAndThrowFn, onErrorFn, {
baseDelay: 0,
jitter: 0,
})).rejects.toThrow(expectedError);
// Expect the onError callback to have been called once with the error
expect(onErrorFn).toHaveBeenCalledOnce();
expect(onErrorFn.mock.calls?.[0]?.[0]).toBeInstanceOf(Error);
expect(onErrorFn.mock.calls?.[0]?.[0]?.message).toBe('error message');
// Expect the function to have been called once and not to have resolved
expect(abortAndThrowFn).toHaveBeenCalledOnce();
expect(abortAndThrowFn).not.toHaveResolved();
};
/**
* Tests that when the abort signal is aborted with a string, an AggregateError is thrown
* with the string as the message.
*/
const testExponentialBackoffRunAbortedStringCreatesError = async (): Promise<void> => {
// Define the function which aborts the exponential backoff and throws an error
const abortAndThrowStringFn = vi.fn(({ abort }) => {
abort('exponential backoff aborted message');
// eslint-disable-next-line
throw 'error message';
});
const onErrorFn = vi.fn();
// Define the expected error, Note that we "abort" with just a string, not an error. They are treated equivalently.
const expectedError = new Error('Exponential backoff aborted', { cause: new Error('exponential backoff aborted message') });
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
await expect(ExponentialBackoff.run(abortAndThrowStringFn, onErrorFn, {
baseDelay: 0,
jitter: 0,
})).rejects.toThrow(expectedError);
// Expect the onError callback to have been called once with the error
expect(onErrorFn).toHaveBeenCalledOnce();
expect(onErrorFn.mock.calls?.[0]?.[0]).toBeInstanceOf(Error);
expect(onErrorFn.mock.calls?.[0]?.[0]?.message).toBe('error message');
// Expect the function to have been called once and not to have resolved
expect(abortAndThrowStringFn).toHaveBeenCalledOnce();
expect(abortAndThrowStringFn).not.toHaveResolved();
};
/**
* Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run}
* as an alternative to the static helper.
*/
const testExponentialBackoffFromAndInstanceRun = async (): Promise<void> => {
const successfullyResolve = vi.fn(async () => 42);
// from() is a convenience constructor; run() on the result should behave like the static path.
const backoff = ExponentialBackoff.from({
baseDelay: 0,
jitter: 0,
});
const result = await backoff.run(successfullyResolve);
expect(result).toBe(42);
expect(successfullyResolve).toHaveBeenCalledOnce();
};
/**
* Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds.
*/
const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (): Promise<void> => {
// Four invocations: three failures then success — would exceed a cap of 3 if one existed.
const tripleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockRejectedValueOnce(new Error('attempt 3'))
.mockResolvedValueOnce('eventually');
const result = await ExponentialBackoff.run(tripleRejectThenResolveFn, () => {}, {
baseDelay: 0,
jitter: 0,
maxAttempts: 0,
});
expect(result).toBe('eventually');
expect(tripleRejectThenResolveFn).toHaveBeenCalledTimes(4);
};
/**
* Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds
* (with jitter disabled so the math is exact).
*/
const testExponentialBackoffIncreasesDelayExponentially = async (): Promise<void> => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const doubleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
baseDelay: 100,
growthRate: 2,
jitter: 0,
maxDelay: 10_000,
maxAttempts: 5,
});
// Attempt 0 fires synchronously on the first microtask tick.
await Promise.resolve();
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
// After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1.
await vi.advanceTimersByTimeAsync(100);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
// After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2.
await vi.advanceTimersByTimeAsync(200);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that computed delay never exceeds maxDelay even when exponential growth would go higher.
*/
const testExponentialBackoffCapsDelayAtMaxDelay = async (): Promise<void> => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const doubleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
baseDelay: 1_000,
growthRate: 4,
jitter: 0,
maxDelay: 2_000,
maxAttempts: 5,
});
await Promise.resolve();
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
// attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap.
await vi.advanceTimersByTimeAsync(1_000);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
// attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms.
await vi.advanceTimersByTimeAsync(2_000);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that jitter subtracts up to jitter * cappedDelay from the capped delay based on Math.random.
*/
const testExponentialBackoffAppliesJitter = async (): Promise<void> => {
vi.useFakeTimers();
// random = 1 → full 10% reduction: 1000 - (1 * 0.1 * 1000) = 900ms.
vi.spyOn(Math, 'random').mockReturnValue(1);
try {
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1'))
.mockResolvedValueOnce('success');
const promise = ExponentialBackoff.run(rejectThenResolveFn, () => {}, {
baseDelay: 1_000,
growthRate: 1,
jitter: 0.1,
maxDelay: 10_000,
maxAttempts: 3,
});
await Promise.resolve();
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
// Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry.
await vi.advanceTimersByTimeAsync(899);
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} accepts valid options, including boundary values of 0 and 1.
*/
const testExponentialBackoffValidateOptionsAcceptsValidOptions = (): void => {
const validCases = [
validExponentialBackoffOptions,
{
...validExponentialBackoffOptions,
maxDelay: 0,
maxAttempts: 0,
baseDelay: 0,
growthRate: 0,
jitter: 0,
},
{
...validExponentialBackoffOptions,
jitter: 1,
},
] as const;
for (const options of validCases) {
expect(() => ExponentialBackoff.validateOptions(options)).not.toThrow();
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects negative numeric options.
*/
const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => {
// Define our test cases with each value being less than 0
const negativeCases = [
{ field: 'maxDelay', value: -1 },
{ field: 'maxAttempts', value: -1 },
{ field: 'baseDelay', value: -1 },
{ field: 'growthRate', value: -1 },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of negativeCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Invalid option: ${field} is less than 0`);
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects jitter below 0 or above 1.
*/
const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => {
// Define our test cases with each value being less than 0 or greater than 1
const invalidJitterCases: Array<{ value: number }> = [{ value: -0.1 }, { value: 1.1 }];
// Iterate through the test cases and expect an error to be thrown
for (const { value } of invalidJitterCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
jitter: value,
})).toThrow('Invalid option: jitter is not between 0 and 1');
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-finite values such as Infinity.
*/
const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void => {
// Define our test cases with each value being Infinity
const nonFiniteCases = [
{ field: 'maxDelay', value: Infinity },
{ field: 'maxAttempts', value: Infinity },
{ field: 'baseDelay', value: Infinity },
{ field: 'growthRate', value: Infinity },
{ field: 'jitter', value: Infinity },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of nonFiniteCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Invalid option: ${field} is not finite`);
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects NaN, which is also non-finite.
*/
const testExponentialBackoffValidateOptionsRejectsNaN = (): void => {
// Define our test cases with each value being NaN
const nanCases = [
{ field: 'maxDelay', value: Number.NaN },
{ field: 'maxAttempts', value: Number.NaN },
{ field: 'baseDelay', value: Number.NaN },
{ field: 'growthRate', value: Number.NaN },
{ field: 'jitter', value: Number.NaN },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of nanCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Invalid option: ${field} is not finite`);
}
};
const runTests = async (): Promise<void> => {
test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions);
test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions);
test('ExponentialBackoff.run: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError);
test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt);
test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess);
test('ExponentialBackoff: calls onError for each failed attempt', testExponentialBackoffCallsOnErrorForEachFailure);
test(
'ExponentialBackoff: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted',
testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted,
);
test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows);
test('ExponentialBackoff: succeeds and aborts with abort signal', testExponentialBackoffRunSuccessAndAbortSignal);
test('ExponentialBackoff: aborts with abort signal', testExponentialBackoffRunWithAbortSignal);
test('ExponentialBackoff: aborts with aborted string creates error', testExponentialBackoffRunAbortedStringCreatesError);
test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun);
test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero);
test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially);
test('ExponentialBackoff: caps delay at maxDelay', testExponentialBackoffCapsDelayAtMaxDelay);
test('ExponentialBackoff: subtracts jitter from the capped delay', testExponentialBackoffAppliesJitter);
test('ExponentialBackoff.validateOptions: accepts valid options', testExponentialBackoffValidateOptionsAcceptsValidOptions);
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
};
await runTests();

70
tests/utils/misc.test.ts Normal file
View File

@@ -0,0 +1,70 @@
import { expect, test, vi } from 'vitest';
import { tryAsync } from '../../src/utils/misc.js';
/** Spy used to confirm the wrapped async function ran successfully. */
const successFlagFn = vi.fn();
/** Spy used to confirm the error callback was invoked on failure. */
const errorFlagFn = vi.fn();
/**
* Tests that tryAsync invokes the function and skips the error callback on success.
*/
const testTryAsyncCallsFunctionOnSuccess = async (): Promise<void> => {
// Reset spies so prior test runs do not affect call counts.
vi.clearAllMocks();
const successFn = async (): Promise<void> => {
successFlagFn();
};
await tryAsync(successFn);
// The wrapped function should run and no error handler should be called.
expect(successFlagFn).toHaveBeenCalledOnce();
expect(errorFlagFn).not.toHaveBeenCalled();
};
/**
* Tests that tryAsync invokes the error callback when the function throws.
*/
const testTryAsyncCallsErrorCallbackOnFailure = async (): Promise<void> => {
vi.clearAllMocks();
const errorFn = async (): Promise<void> => {
throw new Error('test');
};
await tryAsync(errorFn, errorFlagFn);
// The success path should not run; the error callback should receive the failure.
expect(successFlagFn).not.toHaveBeenCalled();
expect(errorFlagFn).toHaveBeenCalledOnce();
};
/**
* Tests that tryAsync wraps non-Error throws in Error instances before calling the error callback.
*/
const testTryAsyncConvertsNonErrorThrows = async (): Promise<void> => {
vi.clearAllMocks();
const errorFn = async (): Promise<void> => {
/* eslint-disable-next-line */
throw 'test';
};
await tryAsync(errorFn, errorFlagFn);
// Non-Error throws must be normalized to Error before onError is called.
expect(successFlagFn).not.toHaveBeenCalled();
expect(errorFlagFn).toHaveBeenCalledOnce();
expect(errorFlagFn).toHaveBeenCalledWith(new Error('test'));
};
const runTests = async (): Promise<void> => {
test('tryAsync: calls the function and skips the error callback on success', testTryAsyncCallsFunctionOnSuccess);
test('tryAsync: calls the error callback when the function fails', testTryAsyncCallsErrorCallbackOnFailure);
test('tryAsync: converts non-Error throws to Error instances', testTryAsyncConvertsNonErrorThrows);
};
await runTests();

View File

@@ -33,5 +33,8 @@
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
}
},
"exclude": [
"tests"
]
}