diff --git a/package-lock.json b/package-lock.json index c1e0134..88dddc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7f48d18..f12f284 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/index.ts b/src/index.ts index c01861c..ecdbe29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,4 @@ export * from "./types.js"; export * from "./sse-session.js"; export * from "./sse-event-parser.js"; -export * from "./utils/index.js"; \ No newline at end of file +export * from "./utils/index.js"; diff --git a/src/sse-event-parser.ts b/src/sse-event-parser.ts index eb4695b..10d246e 100644 --- a/src/sse-event-parser.ts +++ b/src/sse-event-parser.ts @@ -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 = {}) { this.textDecoder = options.textDecoder ?? new TextDecoder(); - this.textEncoder = options.textEncoder ?? new TextEncoder(); } /** @@ -63,7 +84,11 @@ export class SSEEventParser { * stale bytes to incoming chunks. */ public reset(): void { - this.messageBuffer = new Uint8Array(); + // Clear the message buffer + this.messageBuffer = ""; + + // Reset the decoder to clear any buffered bytes + this.textDecoder.decode(); } /** @@ -80,18 +105,26 @@ export class SSEEventParser { public parseEvents(chunk: Uint8Array): SSEvent[] { const lines = this.getBufferedLines(chunk); + // The final split item is the incomplete remainder after the final line + // ending. It must not be processed as a complete SSE line. + const completeLines = lines.slice(0, -1); + const events: SSEvent[] = []; let event: Partial = {}; let processedLineCount = 0; - for (const [index, line] of lines.entries()) { + for (const [index, line] of completeLines.entries()) { + // A blank line indicates the end of an event. If we have received data, we can complete the event if (line === "") { - if (event.data) { - events.push(this.completeEvent(event)); - event = {}; - processedLineCount = index + 1; + if (event.data !== undefined) { + // Cast event when pushing to an SSE Event so it changes from Partial 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): 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 { - 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); } -} \ No newline at end of file +} diff --git a/src/sse-session.ts b/src/sse-session.ts index 97a7f55..bd00afa 100644 --- a/src/sse-session.ts +++ b/src/sse-session.ts @@ -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 { 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 = {}, - ): Promise { - 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 { * @param client - The session to instrument. * @returns The same session, for chaining. */ - static async addLastEventIdReconnect(client: SSESession): Promise { + static async addLastEventIdReconnect( + client: SSESession, + ): Promise { 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 => { if (lastEventId) { request.headers = { ...request.headers, "Last-Event-ID": lastEventId }; } @@ -177,6 +153,7 @@ export class SSESession extends EventEmitter { const handleVisibilityChange = (): void => { if (document.visibilityState === "hidden") { void client.abort(); + return; } @@ -209,7 +186,7 @@ export class SSESession extends EventEmitter { * 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 { }; /** 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 { * previous one was closed. Consumers should read from `session.messages` * rather than caching a reference across terminal disconnects. */ - public messages: AsyncPushIterator = new AsyncPushIterator(); + public messages: AsyncPushIterator = + new AsyncPushIterator(); - private constructor(url: string, options: Partial) { + public constructor(url: string, options: Partial = {}) { super(); this.url = url; @@ -282,14 +250,6 @@ export class SSESession extends EventEmitter { }; } - get onRequest(): (request: RequestInit) => Promise { - return this.options.onRequest; - } - - set onRequest(callback: (request: RequestInit) => Promise) { - this.options.onRequest = callback; - } - /** * Connects or reconnects to the SSE endpoint. * @@ -302,25 +262,31 @@ export class SSESession extends EventEmitter { * promise resolves without throwing). */ public async connect(): Promise { - 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(); + } - 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 { ); } 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 { * Emits `"disconnected"` but not `"closed"`. */ public async abort(): Promise { - 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 { * Closes {@link messages} and emits `"closed"`. */ public async disconnect(): Promise { - 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 { 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 { */ private async readStream( reader: ReadableStreamDefaultReader, - connectionId: number, controller: AbortController, ): Promise { 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 { // 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 { } } } catch (error) { - if (!this.isCurrentConnection(connectionId, controller)) return; + // If the controller is different, we already started a new connection and it would be confusing to handle this error. + if (controller !== this.controller) return; - this.connected = false; + // Invalidate the current controller to allow for reconnection if needed + this.controller = null; await this.notifyDisconnected(); @@ -477,50 +454,11 @@ export class SSESession extends EventEmitter { if (this.options.attemptReconnect) { await this.connect(); } else { - this.closeMessageStream(); + this.messages.close(); } } } - /** Clears partial SSE frames left over from an abandoned transport. */ - private resetEventParser(): void { - this.options.eventParser.reset(); - } - - /** - * Creates a new {@link messages} iterator when the previous one was closed - * by a terminal disconnect or server stream end. - */ - private ensureMessageStreamOpen(): void { - if (!this.messages.closed) return; - - this.messages = new AsyncPushIterator(); - } - - /** Ends the message iteration loop for the current connection span. */ - private closeMessageStream(): void { - if (this.messages.closed) return; - - this.messages.close(); - } - - /** - * Returns whether a read loop still owns the active transport. - * - * A loop is stale when the session disconnected, a newer connection id was - * assigned, or the fetch was aborted. - */ - private isCurrentConnection( - connectionId: number, - controller: AbortController, - ): boolean { - return ( - this.connected && - this.connectionId === connectionId && - !controller.signal.aborted - ); - } - /** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */ private async notifyDisconnected(): Promise { await tryAsync( @@ -532,12 +470,12 @@ export class SSESession extends EventEmitter { /** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */ private async notifyError(error: unknown): Promise { - 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); } diff --git a/src/types.ts b/src/types.ts index a6ac4dd..d29b68e 100644 --- a/src/types.ts +++ b/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 & diff --git a/src/utils/async-push-iterator.ts b/src/utils/async-push-iterator.ts index 216aa57..39745a6 100644 --- a/src/utils/async-push-iterator.ts +++ b/src/utils/async-push-iterator.ts @@ -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(); @@ -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 implements AsyncIterable { - /** Values pushed before a consumer was waiting to read them. */ - private queue: T[] = []; +export class AsyncPushIterator { + /** ReadableStream backing the async iterator returned from {@link Symbol.asyncIterator}. */ + private readonly stream: ReadableStream; - /** Pending `next()` calls waiting for a pushed value or close. */ - private resolvers: ((result: IteratorResult) => void)[] = []; + /** Controller used to enqueue values and close the stream from {@link push} and {@link close}. */ + private controller: ReadableStreamDefaultController | 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): 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 { - return { - next: (): Promise> => { - // 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 { - this.close(); - return Promise.resolve(); + [Symbol.asyncIterator](): AsyncIterableIterator { + return this.stream.values({ preventCancel: true }); } } diff --git a/src/utils/event-emitter.ts b/src/utils/event-emitter.ts index 7036020..6539288 100644 --- a/src/utils/event-emitter.ts +++ b/src/utils/event-emitter.ts @@ -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; type Listener = (detail: T) => void; @@ -181,13 +176,14 @@ export class EventEmitter { let timeoutId: ReturnType | 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 { let timeout: ReturnType; 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); diff --git a/src/utils/exponential-backoff.ts b/src/utils/exponential-backoff.ts index 8315a13..d7bcc06 100644 --- a/src/utils/exponential-backoff.ts +++ b/src/utils/exponential-backoff.ts @@ -16,6 +16,7 @@ export class ExponentialBackoff { */ static from(config?: Partial): 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( - fn: () => Promise, - onError = (_error: Error) => {}, + fn: (context: ExpontialBackoffCallbackContext) => Promise, + onError = ( + _error: Error, + _context: ExpontialBackoffCallbackContext, + ): void => {}, options?: Partial, ): Promise { const backoff = ExponentialBackoff.from(options); + return backoff.run(fn, onError); } private readonly options: ExponentialBackoffOptions; - constructor(options?: Partial) { + /** + * 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 = {}) { 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( - fn: () => Promise, - onError = (_error: Error) => {}, + fn: (context: ExpontialBackoffCallbackContext) => Promise, + onError = ( + _error: Error, + _context: ExpontialBackoffCallbackContext, + ): void => {}, ): Promise { - let lastError: Error = new Error("Exponential backoff: Max retries hit"); + const abortController = new AbortController(); - let attempt = 0; + const context: ExpontialBackoffCallbackContext = { + errors: [], + attempt: 1, + options: { ...this.options }, + nextDelay: 0, + signal: abortController.signal, + abort: (reason?: unknown) => abortController.abort(reason), + }; while ( - attempt < this.options.maxAttempts || - this.options.maxAttempts == 0 + context.options.maxAttempts === 0 || + context.attempt <= context.options.maxAttempts ) { + context.nextDelay = ExponentialBackoff.calculateDelay( + context.options, + context.attempt - 1, + ); + try { - return await fn(); + // Await the promise before returning so its execution context remains in the try-catch + // If we didn't await, this `run` function would successfully return and any errors would not be caught here. + return await fn(context); } catch (error) { // Store the error in case we fail every attempt - lastError = error instanceof Error ? error : new Error(`${error}`); - onError(lastError); - - // Wait before going to the next attempt - const delay = this.calculateDelay(attempt); - await new Promise((resolve) => setTimeout(resolve, delay)); + const errorInstance = + error instanceof Error ? error : new Error(`${error}`); + context.errors.push(errorInstance); + await onError(errorInstance, context); } - attempt++; + // If the abort controller is aborted, throw an error + if (abortController.signal.aborted) { + const reasonError = + abortController.signal.reason instanceof Error + ? abortController.signal.reason + : new Error(`${abortController.signal.reason}`); + throw new Error("Exponential backoff aborted", { cause: reasonError }); + } + + // If we have hit the max attempts, break out of the loop + const unlimitedAttempts = context.options.maxAttempts === 0; + if ( + !unlimitedAttempts && + context.attempt >= context.options.maxAttempts + ) { + break; + } + + // Wait before going to the next attempt + await this.wait(context.nextDelay, abortController.signal); + + context.attempt++; } - // We completed the loop without ever succeeding. Throw the last error we got - throw lastError; + // We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got + throw new AggregateError( + context.errors, + "Exponential backoff max retries hit", + ); + } + + private wait(delay: number, signal: AbortSignal): Promise { + 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; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 73906da..8ae485d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ export * from "./async-push-iterator.js"; export * from "./exponential-backoff.js"; export * from "./event-emitter.js"; -export * from "./misc.js"; \ No newline at end of file +export * from "./misc.js"; diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 417eff1..c6309df 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -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 => { +export const tryAsync = async ( + fn: () => any, + onError?: (error: Error) => void, +): Promise => { 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); - } -} \ No newline at end of file +}; diff --git a/tests/fixtures/events.fixtures.ts b/tests/fixtures/events.fixtures.ts new file mode 100644 index 0000000..a19bd30 --- /dev/null +++ b/tests/fixtures/events.fixtures.ts @@ -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); diff --git a/tests/helpers/sse-stream.ts b/tests/helpers/sse-stream.ts new file mode 100644 index 0000000..f2fa094 --- /dev/null +++ b/tests/helpers/sse-stream.ts @@ -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; + + private controller: ReadableStreamDefaultController | 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 => { + 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(); + } +} diff --git a/tests/sse-event-parser.test.ts b/tests/sse-event-parser.test.ts new file mode 100644 index 0000000..be6ab8e --- /dev/null +++ b/tests/sse-event-parser.test.ts @@ -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 => { + 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(); diff --git a/tests/sse-session.test.ts b/tests/sse-session.test.ts new file mode 100644 index 0000000..0f2b014 --- /dev/null +++ b/tests/sse-session.test.ts @@ -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): Mock => { + 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 = { + 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 = {}): Promise => { + 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 (fetch: FetchFn, options: Partial, run: (session: SSESession) => Promise): Promise => { + 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 => { + 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 => { + const encoder = new TextEncoder(); + + return new ReadableStream({ + async start(controller): Promise { + 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 => { + // 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + // 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 => { + 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 => { + 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 => { + 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 => { + 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((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 => { + 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 => { + const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ])); + + const session = await createSession(fetchMock, {}); + const closed = new Promise((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 => { + 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 => { + 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 => { + const { raw } = storageEvents[0]!; + let reconnectHeaders: Record | 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; + + 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 => { + 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(); diff --git a/tests/utils/async-push-iterator.test.ts b/tests/utils/async-push-iterator.test.ts new file mode 100644 index 0000000..f8c536c --- /dev/null +++ b/tests/utils/async-push-iterator.test.ts @@ -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 (iterator: AsyncPushIterator): Promise => { + 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 => { + const iterator = new AsyncPushIterator(); + + const result = new Promise((resolve) => { + void (async (): Promise => { + 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 => { + const iterator = new AsyncPushIterator(); + + 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 => { + const iterator = new AsyncPushIterator(); + + const result = new Promise((resolve) => { + void (async (): Promise => { + 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 => { + const iterator = new AsyncPushIterator(); + + const result = new Promise((resolve) => { + void (async (): Promise => { + 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 => { + const iterator = new AsyncPushIterator(); + + const failureFlag = vi.fn(); + + const successfulIterator = (): Promise => + new Promise((resolve) => { + void (async (): Promise => { + resolve(await collectAll(iterator)); + })(); + }); + + const failedIterator = (): Promise => + new Promise((resolve, reject) => { + void (async (): Promise => { + 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 => { + const iterator = new AsyncPushIterator(); + + 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 => { + const iterator = new AsyncPushIterator(); + + 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 => { + 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(); diff --git a/tests/utils/event-emitter.test.ts b/tests/utils/event-emitter.test.ts new file mode 100644 index 0000000..7ca60bd --- /dev/null +++ b/tests/utils/event-emitter.test.ts @@ -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(); + 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(); + 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(); + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 => { + const emitter = new EventEmitter(); + + // 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 => { + const emitter = new EventEmitter(); + 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 => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + + 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 => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + + // 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 => { + const emitter = new EventEmitter(); + + 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(); + 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 => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + 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 => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + 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 => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + 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 => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + 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 => { + 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(); diff --git a/tests/utils/exponential-backoff.test.ts b/tests/utils/exponential-backoff.test.ts new file mode 100644 index 0000000..6184a6b --- /dev/null +++ b/tests/utils/exponential-backoff.test.ts @@ -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 => { + // 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 => { + // 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 => { + 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 => { + // 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 => { + // 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 => { + 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 => { + 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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + 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 => { + // 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 => { + 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 => { + 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 => { + 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 => { + 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(); diff --git a/tests/utils/misc.test.ts b/tests/utils/misc.test.ts new file mode 100644 index 0000000..48f374b --- /dev/null +++ b/tests/utils/misc.test.ts @@ -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 => { + // Reset spies so prior test runs do not affect call counts. + vi.clearAllMocks(); + + const successFn = async (): Promise => { + 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 => { + vi.clearAllMocks(); + + const errorFn = async (): Promise => { + 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 => { + vi.clearAllMocks(); + + const errorFn = async (): Promise => { + /* 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 => { + 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(); diff --git a/tsconfig.json b/tsconfig.json index 1a0ae01..e1d4886 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,5 +33,8 @@ "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": true, - } + }, + "exclude": [ + "tests" + ] }