Add tests. Add exponential backoff contexts. Update error handling. Imrove Event Parser compatibility. Simplify SSE Session. Simplify Async Iterator.
This commit is contained in:
241
package-lock.json
generated
241
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,17 +105,25 @@ 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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,48 +454,9 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
if (this.options.attemptReconnect) {
|
||||
await this.connect();
|
||||
} else {
|
||||
this.closeMessageStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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"`. */
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -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 &
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
const errorInstance =
|
||||
error instanceof Error ? error : new Error(`${error}`);
|
||||
context.errors.push(errorInstance);
|
||||
await onError(errorInstance, context);
|
||||
}
|
||||
|
||||
// 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
|
||||
const delay = this.calculateDelay(attempt);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await this.wait(context.nextDelay, abortController.signal);
|
||||
|
||||
context.attempt++;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
// 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",
|
||||
);
|
||||
}
|
||||
|
||||
// We completed the loop without ever succeeding. Throw the last error we got
|
||||
throw lastError;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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
294
tests/fixtures/events.fixtures.ts
vendored
Normal 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);
|
||||
65
tests/helpers/sse-stream.ts
Normal file
65
tests/helpers/sse-stream.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
289
tests/sse-event-parser.test.ts
Normal file
289
tests/sse-event-parser.test.ts
Normal 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
632
tests/sse-session.test.ts
Normal 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();
|
||||
188
tests/utils/async-push-iterator.test.ts
Normal file
188
tests/utils/async-push-iterator.test.ts
Normal 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();
|
||||
505
tests/utils/event-emitter.test.ts
Normal file
505
tests/utils/event-emitter.test.ts
Normal 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();
|
||||
594
tests/utils/exponential-backoff.test.ts
Normal file
594
tests/utils/exponential-backoff.test.ts
Normal 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
70
tests/utils/misc.test.ts
Normal 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();
|
||||
@@ -33,5 +33,8 @@
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user