From c99568a59a35c1eb0c6ce0ea9031fc8afc0a8290 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sun, 24 May 2026 14:26:34 +0200 Subject: [PATCH] Initial Commit --- .gitignore | 2 + package-lock.json | 1277 ++++++++++++++++++++++++++++++ package.json | 29 + src/index.ts | 0 src/sse-event-parser.ts | 200 +++++ src/sse-session.ts | 542 +++++++++++++ src/types.ts | 148 ++++ src/utils/async-push-iterator.ts | 107 +++ src/utils/event-emitter.ts | 231 ++++++ src/utils/exponential-backoff.ts | 155 ++++ src/utils/misc.ts | 16 + tsconfig.json | 37 + 12 files changed, 2744 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/sse-event-parser.ts create mode 100644 src/sse-session.ts create mode 100644 src/types.ts create mode 100644 src/utils/async-push-iterator.ts create mode 100644 src/utils/event-emitter.ts create mode 100644 src/utils/exponential-backoff.ts create mode 100644 src/utils/misc.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c925c21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c1e0134 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1277 @@ +{ + "name": "ssesession", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ssesession", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "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==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "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" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.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==", + "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" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2ec4461 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "ssesession", + "version": "0.0.1", + "description": "", + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest" + }, + "files": [ + "dist" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "devDependencies": { + "typescript": "^6.0.3", + "vitest": "^4.1.7" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/sse-event-parser.ts b/src/sse-event-parser.ts new file mode 100644 index 0000000..eb4695b --- /dev/null +++ b/src/sse-event-parser.ts @@ -0,0 +1,200 @@ +import type { SSEvent } from './types.js'; + +/** + * Optional encoders used when decoding incoming SSE bytes and re-encoding + * any buffered remainder between chunks. + */ +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; +} + +/** + * Incrementally parses Server-Sent Events (SSE) from streamed byte chunks. + * + * SSE payloads are line-oriented: each event is a sequence of `field: value` + * lines terminated by a blank line. This parser accepts arbitrary chunk + * boundaries from a live HTTP response body and emits only complete events. + * + * Typical usage is one parser instance per connection, calling {@link parseEvents} + * for each chunk received from the stream: + * + * ```ts + * const parser = new SSEEventParser(); + * + * for await (const chunk of response.body) { + * for (const event of parser.parseEvents(chunk)) { + * // handle event.data, event.event, event.id, event.retry + * } + * } + * ``` + * + * Supported fields follow the SSE spec: `data`, `event`, `id`, and `retry`. + * Multiple `data:` lines in one event are joined with `\n`. An event is only + * emitted once a blank line is seen and at least one `data` field was collected. + */ +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(); + + /** + * Creates a parser for one SSE stream. + * + * Inject custom encoders in tests or when a non-default character encoding + * is required; production callers can rely on the defaults. + * + * @param options - Optional text encoders for decode/encode of stream bytes. + */ + constructor(options: Partial = {}) { + this.textDecoder = options.textDecoder ?? new TextDecoder(); + this.textEncoder = options.textEncoder ?? new TextEncoder(); + } + + /** + * Clears any buffered bytes from a partial line or incomplete event. + * + * Call when abandoning a transport so the next connection does not prepend + * stale bytes to incoming chunks. + */ + public reset(): void { + this.messageBuffer = new Uint8Array(); + } + + /** + * Parses all complete SSE events contained in a newly received chunk. + * + * The chunk is appended to any bytes buffered from earlier calls. Complete + * events (blank-line delimited blocks with at least one `data` field) are + * returned immediately; any trailing partial line or in-progress event stays + * in the internal buffer until a later chunk completes it. + * + * @param chunk - Newly received SSE stream bytes. + * @returns Zero or more complete parsed SSE events from this chunk. + */ + public parseEvents(chunk: Uint8Array): SSEvent[] { + const lines = this.getBufferedLines(chunk); + + const events: SSEvent[] = []; + let event: Partial = {}; + let processedLineCount = 0; + + for (const [index, line] of lines.entries()) { + if (line === "") { + if (event.data) { + events.push(this.completeEvent(event)); + event = {}; + processedLineCount = index + 1; + } + + continue; + } + + this.parseLine(line, event); + } + + this.storeRemainingLines(lines, processedLineCount); + + return events; + } + + /** + * Appends a new chunk to the buffered bytes and splits the combined payload + * into lines. + * + * Accepts `\r\n`, `\r`, and `\n` line endings so events parse correctly + * regardless of server or platform conventions. + */ + private getBufferedLines(chunk: Uint8Array): string[] { + this.messageBuffer = new Uint8Array([ + ...this.messageBuffer, + ...chunk, + ]); + + return this.textDecoder + .decode(this.messageBuffer) + .split(/\r\n|\r|\n/); + } + + /** + * Parses one SSE field line into an in-progress event. + * + * Lines without a colon are ignored. 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; + + const field = line.slice(0, colonIndex); + const value = line.slice(colonIndex + 1).replace(/^ /, ""); + + switch (field) { + case "data": + event.data = event.data + ? `${event.data}\n${value}` + : value; + return; + + case "event": + event.event = value; + return; + + case "id": + event.id = value; + return; + + case "retry": + this.parseRetry(value, event); + return; + } + } + + /** + * Applies a numeric `retry:` field to an in-progress event. + * + * Non-numeric values are ignored rather than failing the parse. + */ + private parseRetry(value: string, event: Partial): void { + const retry = parseInt(value, 10); + + if (!isNaN(retry)) { + event.retry = retry; + } + } + + /** + * 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. + * + * Only lines that were fully processed (through a completed event boundary) + * are discarded; the remainder is re-encoded into {@link messageBuffer}. + */ + private storeRemainingLines( + lines: string[], + processedLineCount: number, + ): void { + const remainder = lines + .slice(processedLineCount) + .join("\n"); + + this.messageBuffer = this.textEncoder.encode(remainder); + } +} \ No newline at end of file diff --git a/src/sse-session.ts b/src/sse-session.ts new file mode 100644 index 0000000..e1ef3f3 --- /dev/null +++ b/src/sse-session.ts @@ -0,0 +1,542 @@ +import type { + SSESessionOptions, + SSESessionEventMap, + SSEvent, +} from "./types.js"; + +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 { SSEEventParser } from "./sse-event-parser.js"; + +/** + * A fetch-based Server-Sent Events (SSE) client with reconnect and optional + * browser tab visibility handling. + * + * Each session maintains one HTTP streaming connection at a time. Incoming + * bytes are parsed into {@link SSEvent} objects and delivered through two + * surfaces: + * + * - **Events** — `"connected"`, `"message"`, `"disconnected"`, `"error"`, + * and `"closed"` on the session itself (extends {@link EventEmitter}). + * - **Messages** — {@link messages}, an async iterable for `for await...of` + * consumers. + * + * Typical usage: + * + * ```ts + * const session = await SSESession.create("/events"); + * + * session.on("message", (event) => console.log(event.data)); + * + * for await (const event of session.messages) { + * handle(event); + * } + * ``` + * + * ## Lifecycle + * + * - {@link connect} opens (or reopens) the transport. It resolves once the + * HTTP stream is established; reading continues in the background. + * - {@link abort} stops the in-flight fetch without ending the session. + * Used internally for tab visibility. The {@link messages} iterator stays + * open so an existing consumer resumes when the tab becomes visible again. + * - {@link disconnect} aborts the transport, closes {@link messages}, emits + * `"closed"`, and disables attached visibility handlers until the next + * manual {@link connect}. + * + * Automatic reconnect is controlled by {@link SSESessionOptions.persistent} + * (server closed the stream) and + * {@link SSESessionOptions.attemptReconnect} (transport error). + * + * ## Connection supersession + * + * Each {@link connect} or {@link abort} bumps an internal `connectionId`. + * Background read loops capture their id at start and exit quietly when a + * newer connection supersedes them, avoiding duplicate events or errors from + * stale transports. + */ +export class SSESession extends EventEmitter { + /** + * Creates a session and waits until the first connection is established. + * + * @param url - The SSE endpoint URL. + * @param options - Configuration merged with instance defaults. + * @returns A connected session. + * @throws When the initial connection cannot be established. + */ + static async create( + url: string, + options: Partial = {}, + ): Promise { + const client = new SSESession(url, options); + await client.connect(); + + 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. + * + * Listens for incoming `"message"` events and remembers the most recent + * {@link SSEvent.id}. On every subsequent connect or reconnect, the session's + * {@link onRequest} hook is wrapped so that header is attached when an id is + * known, allowing the server to replay only events the client has not yet + * received. + * + * The existing {@link onRequest} callback is preserved and runs after the + * header is applied, so auth or other header mutations continue to work. + * + * Attach as early in the session lifetime as possible. When added after + * {@link create}, the initial connection omits the header (no id yet); + * all later reconnects include it. To instrument before the first connect, + * call this on the session returned from {@link withBrowserVisibility} + * before awaiting a separate {@link connect} when the tab starts hidden. + * + * ```ts + * const session = await SSESession.create(url); + * await SSESession.addLastEventIdReconnect(session); + * // Reconnects send Last-Event-ID once an event with an id is received. + * ``` + * + * @param client - The session to instrument. + * @returns The same session, for chaining. + */ + static async addLastEventIdReconnect(client: SSESession): Promise { + let lastEventId: string | undefined; + + client.on("message", (event) => { + lastEventId = event.id; + }); + + const originalOnRequest = client.onRequest; + + client.onRequest = async (request) => { + if (lastEventId) { + request.headers = { ...request.headers, "Last-Event-ID": lastEventId }; + } + + return originalOnRequest(request); + }; + + return client; + } + + /** + * Pauses and resumes a session based on browser tab visibility. + * + * Uses the Page Visibility API (`document.visibilitychange`): + * + * - **hidden** — {@link abort} stops the active fetch. {@link messages} + * stays open; `"disconnected"` fires but `"closed"` does not. + * - **visible** — {@link connect} re-establishes the stream if needed. + * + * The listener is removed when {@link disconnect} emits `"closed"`, and + * re-attached automatically on the next `"connected"` event. + * + * No-op in non-browser environments where `document` is undefined. + * + * @param client - The session to manage. + */ + static addBrowserVisibilityHandler(client: SSESession): SSESession { + if (typeof document === "undefined") return client; + + const handleVisibilityChange = (): void => { + if (document.visibilityState === "hidden") { + void client.abort(); + return; + } + + client.connect().catch(() => { + // connect() reports failures via onError and the "error" event. + }); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + // Stop managing visibility after an explicit disconnect; re-register + // when the same instance is manually connected again. + client.once("closed", () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + + client.once("connected", () => { + SSESession.addBrowserVisibilityHandler(client); + }); + }); + + return client; + } + + /** SSE endpoint URL for this session. */ + private readonly url: string; + + /** + * Per-instance configuration. + * + * Defaults live on the instance field (not a shared static) so each session + * gets its own {@link SSEEventParser} and {@link ExponentialBackoff}. + */ + private options: SSESessionOptions = { + fetch: (...args) => fetch(...args), + method: "GET", + headers: { + Accept: "text/event-stream", + "Cache-Control": "no-cache", + }, + body: new FormData(), + + onRequest: (request) => Promise.resolve(request), + onConnected: () => {}, + onDisconnected: () => {}, + onError: (error) => console.error("SSEClient error:", error), + + // Retry the initial fetch until it succeeds (maxAttempts: 0 = unlimited). + retry: new ExponentialBackoff({ + baseDelay: 1000, + maxDelay: 10000, + maxAttempts: 0, + growthRate: 1.3, + jitter: 0.3, + }), + + attemptReconnect: true, + persistent: false, + + eventParser: new SSEEventParser(), + }; + + /** 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; + + /** + * Asynchronous stream of parsed SSE events for the active connection. + * + * Stays open across {@link abort} and automatic reconnects so an existing + * `for await` consumer keeps receiving events after visibility resumes. + * + * Closes when: + * - the server ends the stream and {@link SSESessionOptions.persistent} + * is false, + * - {@link disconnect} is called, or + * - a transport error occurs with + * {@link SSESessionOptions.attemptReconnect} disabled. + * + * A later {@link connect} replaces this with a new iterator when the + * previous one was closed. Consumers should read from `session.messages` + * rather than caching a reference across terminal disconnects. + */ + public messages: AsyncPushIterator = new AsyncPushIterator(); + + private constructor(url: string, options: Partial) { + super(); + + this.url = url; + this.options = { + ...this.options, + ...options, + // Shallow merge would drop default headers when options.headers is set. + headers: { ...this.options.headers, ...options.headers }, + }; + } + + 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. + * + * Resolves once the HTTP stream is established and `"connected"` has been + * emitted. Body reading continues asynchronously in the background via + * {@link readStream}. + * + * @throws When the fetch retry policy exhausts attempts or the connection + * is superseded before the reader is handed off (in the latter case the + * promise resolves without throwing). + */ + public async connect(): Promise { + if (this.connected) 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(); + + const connectionId = ++this.connectionId; + const controller = new AbortController(); + + this.connected = true; + this.controller = controller; + + const { method, headers, body } = this.options; + const fetchOptions: RequestInit = { + method, + headers: headers || {}, + body: body || null, + signal: controller.signal, + cache: "no-store", + }; + + let reader: ReadableStreamDefaultReader; + + try { + reader = await this.options.retry.run(() => + this.createReader(fetchOptions), + ); + } catch (error) { + // A newer abort/connect superseded this attempt — leave state to the winner. + if (!this.isCurrentConnection(connectionId, controller)) return; + + this.connected = false; + + await this.notifyDisconnected(); + await this.notifyError(error); + this.closeMessageStream(); + + throw error; + } + + // Connection succeeded but was already replaced (for example abort during fetch). + if (!this.isCurrentConnection(connectionId, controller)) { + await reader.cancel(); + return; + } + + await tryAsync( + () => this.options.onConnected(), + (error) => this.options.onError(error), + ); + this.emit("connected", undefined); + + // Fire-and-forget: connect() resolves while the stream is consumed. + this.readStream(reader, connectionId, controller); + } + + /** + * Aborts only the currently active transport. + * + * The session remains reusable: {@link messages} stays open, visibility + * handling stays attached, and {@link connect} can reopen the stream. + * Partial parser state from the abandoned transport is discarded. + * + * Emits `"disconnected"` but not `"closed"`. + */ + public async abort(): Promise { + if (!this.connected) return; + + this.connected = false; + // Invalidate any in-flight read loop and fetch for this transport. + this.connectionId++; + this.controller.abort(); + this.resetEventParser(); + + await this.notifyDisconnected(); + } + + /** + * Terminates the session and disables attached visibility handling until + * the same instance is manually {@link connect connected} again. + * + * Closes {@link messages} and emits `"closed"`. + */ + public async disconnect(): Promise { + this.closeMessageStream(); + this.emit("closed", undefined); + + if (this.connected) { + await this.abort(); + } else { + this.resetEventParser(); + } + } + + /** + * Performs the HTTP request and returns a reader for the response body. + * + * {@link SSESessionOptions.onRequest} may mutate headers (for example auth + * tokens or `Last-Event-ID`) before the fetch runs. + */ + private async createReader( + fetchOptions: RequestInit, + ): Promise> { + const requestOptions = await this.options.onRequest(fetchOptions); + const response = await this.options.fetch(this.url, requestOptions); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + return response.body.getReader(); + } + + /** + * Reads bytes from an established stream until it ends, errors, or is + * superseded by a newer connection. + */ + private async readStream( + reader: ReadableStreamDefaultReader, + connectionId: number, + controller: AbortController, + ): Promise { + try { + while (this.isCurrentConnection(connectionId, 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 (done) { + this.connected = false; + + await this.notifyDisconnected(); + + if (this.options.persistent) { + // Server closed gracefully — reopen unless the consumer opted out. + await this.connect(); + } else { + this.closeMessageStream(); + } + + return; + } + + // Some environments yield `{ done: false, value: undefined }`. + if (!value) continue; + + for (const event of this.options.eventParser.parseEvents(value)) { + this.emit("message", event); + this.messages.push(event); + } + } + } catch (error) { + if (!this.isCurrentConnection(connectionId, controller)) return; + + this.connected = false; + + await this.notifyDisconnected(); + + // Expected path for abort() — do not treat as an error or reconnect. + if (controller.signal.aborted) return; + + await this.notifyError(error); + + if (this.options.attemptReconnect) { + await this.connect(); + } else { + this.closeMessageStream(); + } + } + } + + /** Clears partial SSE frames left over from an abandoned transport. */ + private resetEventParser(): void { + this.options.eventParser.reset(); + } + + /** + * Creates a new {@link messages} iterator when the previous one was closed + * by a terminal disconnect or server stream end. + */ + private ensureMessageStreamOpen(): void { + if (!this.messages.closed) return; + + this.messages = new AsyncPushIterator(); + } + + /** 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( + () => this.options.onDisconnected(), + (error) => this.options.onError(error), + ); + this.emit("disconnected", undefined); + } + + /** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */ + private async notifyError(error: unknown): Promise { + const errorInstance = error instanceof Error ? error : new Error(String(error)); + + await tryAsync( + () => this.options.onError(errorInstance), + (callbackError) => + console.error("SSESession error:", callbackError), + ); + this.emit("error", errorInstance); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a6ac4dd --- /dev/null +++ b/src/types.ts @@ -0,0 +1,148 @@ +export type SSERequestInit = { + /** + * The HTTP method to use. + */ + method: "GET" | "POST"; + + /** + * Request headers sent on every connect and reconnect. + */ + headers?: Record; + + /** + * Request body for POST-based SSE endpoints. + */ + body?: string | FormData; +}; + +/** + * The fetch function to use. + * + * NOTE: This is compatible with Browser/Node's native "fetch" function. + * We use this in place of "typeof fetch" so that we can accept non-standard URLs ("url" is a "string" here). + * For example, a LibP2P adapter might not use a standardized URL format (and might only include "path"). + * This would cause a type error as native fetch expects type "URL". + */ +export type SSERequestFunction = { + fetch: (url: string, options: RequestInit) => Promise; +}; + +/** + * Lifecycle hooks invoked by {@link SSESession} during connect, read, and teardown. + */ +export type SSESessionCallbacks = { + /** + * Called before each fetch so callers can attach auth headers, cookies, or + * a `Last-Event-ID` for resume semantics. + */ + onRequest: (request: RequestInit) => Promise; + + /** + * Called after the HTTP stream is established and before body reading begins. + */ + onConnected: () => void; + + /** + * Called when the active transport ends — including {@link SSESession.abort}, + * server stream completion, and errors. Not paired with {@link SSESessionCallbacks.onConnected} + * when the initial connect never succeeds. + */ + onDisconnected: () => void; + + /** + * Called on fetch or read failures. Not invoked for intentional + * {@link SSESession.abort} aborts. + */ + onError: (error: Error) => void; +} + +export type SSESessionRetryInterface = { + /** + * Retry policy used while establishing the HTTP connection in + * {@link SSESession.connect}. Defaults to {@link ExponentialBackoff} with + * unlimited attempts. + */ + retry: { + run(fn: () => Promise, onError?: (error: Error) => void): Promise; + }; +}; + +export interface SSEParser { + /** + * Incremental SSE frame parser for the response body. + * + * {@link SSEEventParser.reset} is called by the session when abandoning a + * transport so partial frames do not carry over to the next connection. + */ + eventParser: { + parseEvents(buffer: Uint8Array): SSEvent[]; + reset(): void; + }; +} + +export type SSELifecycleOptions = { + /** + * When true, {@link SSESession} calls {@link SSESession.connect} again after + * a transport **error** (not an intentional abort). + */ + attemptReconnect: boolean, + + /** + * When true, {@link SSESession} calls {@link SSESession.connect} again after + * the **server** closes the stream normally (`done`). + */ + persistent: boolean, +} + +/** + * Events emitted by {@link SSESession}. + * + * - `"connected"` — HTTP stream established. + * - `"message"` — A complete SSE event was parsed. + * - `"disconnected"` — The active transport ended (including {@link SSESession.abort}). + * - `"error"` — An unexpected fetch or read failure. + * - `"closed"` — {@link SSESession.disconnect} was called; visibility handling is detached. + */ +export type SSESessionEventMap = { + connected: void; + disconnected: void; + error: Error; + message: SSEvent; + closed: void; +}; + +/** + * Configuration for {@link SSESession}. + */ +export type SSESessionOptions = + SSESessionCallbacks & + SSERequestInit & + SSERequestFunction & + SSESessionRetryInterface & + SSELifecycleOptions & + SSEParser; + +/** + * Represents a Server-Sent Event. + */ +export interface SSEvent { + /** + * Event data. + */ + data: string; + + /** + * Event type. + */ + event?: string; + + /** + * Event ID. + */ + id?: string; + + /** + * Reconnection time in milliseconds. + */ + retry?: number; +} diff --git a/src/utils/async-push-iterator.ts b/src/utils/async-push-iterator.ts new file mode 100644 index 0000000..216aa57 --- /dev/null +++ b/src/utils/async-push-iterator.ts @@ -0,0 +1,107 @@ +/** + * 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: + * + * ```ts + * const messages = new AsyncPushIterator(); + * + * // Producer (elsewhere) + * messages.push(event); + * + * // Consumer + * for await (const event of messages) { + * handle(event); + * } + * ``` + * + * 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. + */ +export class AsyncPushIterator implements AsyncIterable { + /** Values pushed before a consumer was waiting to read them. */ + private queue: T[] = []; + + /** Pending `next()` calls waiting for a pushed value or close. */ + private resolvers: ((result: IteratorResult) => void)[] = []; + + /** When true, no more values are accepted and iteration eventually completes. */ + public closed = false; + + /** + * 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. + * + * @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); + } + } + + /** + * 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. + */ + close(): void { + this.closed = true; + for (const resolve of this.resolvers) { + resolve({ value: undefined as T, done: true }); + } + this.resolvers = []; + } + + /** + * Returns an async iterator that reads from this instance's shared queue. + * + * Buffered values are returned first, then the iterator waits for pushes or + * for {@link close}. Intended for a single consumer per instance. + */ + [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(); + } +} diff --git a/src/utils/event-emitter.ts b/src/utils/event-emitter.ts new file mode 100644 index 0000000..7036020 --- /dev/null +++ b/src/utils/event-emitter.ts @@ -0,0 +1,231 @@ +// 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; + +/** + * A listener entry. + * @template T - The event type. + */ +interface ListenerEntry { + listener: Listener; + wrappedListener: Listener; + debounceTime?: number; + once?: boolean; +} + +export type OffCallback = () => void; + +/** + * A simple event emitter implementation. + * @template T - The event map type. + */ +export class EventEmitter { + /** + * The listeners map. + * @private + */ + private listeners: Map>> = new Map(); + + /** + * Add a listener for an event. + * @param type - The event type. + * @param listener - The listener function. + * @param debounceMilliseconds - The debounce time in milliseconds. + * @returns An off callback that can be called to stop listening for events. + */ + on( + type: K, + listener: Listener, + debounceMilliseconds?: number, + ): OffCallback { + // Create a wrapped listener so that the debounce can be applied. + const wrappedListener = + debounceMilliseconds && debounceMilliseconds > 0 + ? this.debounce(listener, debounceMilliseconds) + : listener; + + // If the listeners map does not have the event type, create a new set. + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + + // Create a listener entry. + const listenerEntry: ListenerEntry = { + listener, + wrappedListener, + ...(debounceMilliseconds !== undefined + ? { debounceTime: debounceMilliseconds } + : {}), + }; + + // Add the listener entry to the listeners map. + this.listeners.get(type)?.add(listenerEntry as ListenerEntry); + + // Return an "off" callback that can be called to stop listening for events. + return () => this.off(type, listener); + } + + /** + * Add a one-time listener for an event. + * @param type - The event type. + * @param listener - The listener function. + * @param debounceMilliseconds - The debounce time in milliseconds. + * @returns An off callback that can be called to stop listening for events. + */ + once( + type: K, + listener: Listener, + debounceMilliseconds?: number, + ): OffCallback { + const wrappedListener: Listener = (detail: T[K]) => { + this.off(type, listener); + listener(detail); + }; + + // Create a debounced listener. + const debouncedListener = + debounceMilliseconds && debounceMilliseconds > 0 + ? this.debounce(wrappedListener, debounceMilliseconds) + : wrappedListener; + + // If the listeners map does not have the event type, create a new set. + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + + // Create a listener entry. + const listenerEntry: ListenerEntry = { + listener, + wrappedListener: debouncedListener, + once: true, + ...(debounceMilliseconds !== undefined + ? { debounceTime: debounceMilliseconds } + : {}), + }; + + // Add the listener entry to the listeners map. + this.listeners.get(type)?.add(listenerEntry as ListenerEntry); + + // Return an "off" callback that can be called to stop listening for events. + return () => this.off(type, listener); + } + + /** + * Remove a listener for an event. + * @param type - The event type. + * @param listener - The listener function. + */ + off(type: K, listener: Listener): void { + // Get the listeners for the event type. + const listeners = this.listeners.get(type); + if (!listeners) return; + + // Find the listener entry. + const listenerEntry = Array.from(listeners).find( + (entry) => + entry.listener === listener || entry.wrappedListener === listener, + ); + + // If the listener entry is found, remove it from the listeners map. + if (listenerEntry) { + listeners.delete(listenerEntry); + } + } + + /** + * Emit an event. + * @param type - The event type. + * @param payload - The event payload. + * @returns True if there are listeners for the event, false otherwise. + */ + emit(type: K, payload: T[K]): boolean { + // Get the listeners for the event type. + const listeners = this.listeners.get(type); + if (!listeners) return false; + + // Emit the event to all listeners. + listeners.forEach((entry) => { + entry.wrappedListener(payload); + }); + + // Return true if there are listeners for the event, false otherwise. + return listeners.size > 0; + } + + /** + * Remove all listeners. + */ + removeAllListeners(): void { + this.listeners.clear(); + } + + /** + * Wait for an event to be emitted. + * @param type - The event type. + * @param predicate - The predicate function. + * @param timeoutMs - The timeout in milliseconds. + * @returns The event payload. + */ + async waitFor( + type: K, + predicate: (payload: T[K]) => boolean, + timeoutMs?: number, + ): Promise { + // Create a promise to wait for the event to be emitted. + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + + // Create a listener function. + const listener = (payload: T[K]) => { + if (predicate(payload)) { + // Clean up + this.off(type, listener); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + resolve(payload); + } + }; + + // Set up timeout if specified + if (timeoutMs !== undefined) { + timeoutId = setTimeout(() => { + this.off(type, listener); + reject(new Error(`Timeout waiting for event "${String(type)}"`)); + }, timeoutMs); + } + + // Add the listener to the listeners map. + this.on(type, listener); + }); + } + + /** + * Debounce a function. + * @param func - The function to debounce. + * @param wait - The wait time in milliseconds. + * @returns The debounced function. + */ + private debounce( + func: Listener, + wait: number, + ): Listener { + // Create a timeout variable. + let timeout: ReturnType; + + return (detail: T[K]) => { + // If the timeout is not null, clear it. + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + func(detail); + }, wait); + }; + } +} diff --git a/src/utils/exponential-backoff.ts b/src/utils/exponential-backoff.ts new file mode 100644 index 0000000..8315a13 --- /dev/null +++ b/src/utils/exponential-backoff.ts @@ -0,0 +1,155 @@ +/** + * Exponential backoff is a technique used to retry a function after a delay. + * + * The delay increases exponentially with each attempt, up to a maximum delay. + * + * The jitter is a random amount of time added to the delay to prevent thundering herd problems. + * + * The growth rate is the factor by which the delay increases with each attempt. + */ +export class ExponentialBackoff { + /** + * Create a new ExponentialBackoff instance + * + * @param config - The configuration for the exponential backoff + * @returns The ExponentialBackoff instance + */ + static from(config?: Partial): ExponentialBackoff { + const backoff = new ExponentialBackoff(config); + return backoff; + } + + /** + * Run the function with exponential backoff + * + * @param fn - The function to run + * @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 + * + * @returns The result of the function + */ + static run( + fn: () => Promise, + onError = (_error: Error) => {}, + options?: Partial, + ): Promise { + const backoff = ExponentialBackoff.from(options); + return backoff.run(fn, onError); + } + + private readonly options: ExponentialBackoffOptions; + + constructor(options?: Partial) { + this.options = { + maxDelay: 10000, + maxAttempts: 10, + baseDelay: 1000, + growthRate: 2, + jitter: 0.1, + ...options, + }; + } + + /** + * Run the function with exponential backoff + * + * 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 + * + * @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 + * + * @returns The result of the function + */ + async run( + fn: () => Promise, + onError = (_error: Error) => {}, + ): Promise { + let lastError: Error = new Error("Exponential backoff: Max retries hit"); + + let attempt = 0; + + while ( + attempt < this.options.maxAttempts || + this.options.maxAttempts == 0 + ) { + try { + return await fn(); + } 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)); + } + + attempt++; + } + + // We completed the loop without ever succeeding. Throw the last error we got + throw lastError; + } + + /** + * Calculate the delay before we should attempt to retry + * + * NOTE: The maximum delay is (maxDelay * (1 + jitter)) + * + * @param attempt + * @returns The time in milliseconds before another attempt should be made + */ + private calculateDelay(attempt: number): number { + // Get the power of the growth rate + const power = Math.pow(this.options.growthRate, attempt); + + // Get the delay before jitter or limit + const rawDelay = this.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); + + // Get the jitter direction. This will be between -1 and 1 + const jitterDirection = 2 * Math.random() - 1; + + // Calculate the jitter + const jitter = jitterDirection * this.options.jitter * cappedDelay; + + // Add the jitter to the delay + return cappedDelay + jitter; + } +} + +export type ExponentialBackoffOptions = { + /** + * The maximum delay between attempts in milliseconds + */ + maxDelay: number; + + /** + * The maximum number of attempts. Passing 0 will result in infinite attempts. + */ + maxAttempts: number; + + /** + * The base delay between attempts in milliseconds + */ + baseDelay: number; + + /** + * The growth rate of the delay + */ + growthRate: number; + + /** + * The jitter of the delay as a percentage of growthRate + */ + jitter: number; +}; diff --git a/src/utils/misc.ts b/src/utils/misc.ts new file mode 100644 index 0000000..417eff1 --- /dev/null +++ b/src/utils/misc.ts @@ -0,0 +1,16 @@ +/** + * Tries to execute an async function and handles any errors that occur. + * @param fn - The function to execute. + * @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 => { + try { + return await fn(); + } catch (error) { + const errorInstance = error instanceof Error ? error : new Error(`${error}`); + + onError?.(errorInstance); + + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1a0ae01 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + + // Environment Settings + "module": "nodenext", + "target": "esnext", + "types": [], + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // "noPropertyAccessFromIndexSignature": true, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + } +}