commit 4c423c78fa8a9925d9683b8139663687fa39cac0 Author: Harvmaster Date: Tue Feb 3 12:49:18 2026 +0000 Async Iterables and Thenable LLM Calls diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7fb7a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/dist +.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1f7e42d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,591 @@ +{ + "name": "iterable-llm", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "iterable-llm", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@types/node": "^25.2.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", + "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "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/get-tsconfig": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", + "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a5bd5a2 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "iterable-llm", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "tsx src/index.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@types/node": "^25.2.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/src/async-push-iterator.ts b/src/async-push-iterator.ts new file mode 100644 index 0000000..485505f --- /dev/null +++ b/src/async-push-iterator.ts @@ -0,0 +1,65 @@ +/** + * An async iterator that allows pushing values from outside + * and consuming them with `for await...of` + */ +export class AsyncPushIterator implements AsyncIterable { + private queue: T[] = []; + private resolvers: ((result: IteratorResult) => void)[] = []; + private closed = false; + + /** + * Push a value into the iterator. + * If there's a pending consumer, it will receive the value immediately. + * Otherwise, it will be buffered until consumed. + */ + 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); + } + } + + /** + * Close the iterator. No more values can be pushed. + * Pending consumers will receive { done: true }. + */ + close(): void { + this.closed = true; + for (const resolve of this.resolvers) { + resolve({ value: undefined as T, done: true }); + } + this.resolvers = []; + } + + [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); + }); + }, + }; + } + + [Symbol.asyncDispose](): Promise { + this.close(); + return Promise.resolve(); + } +} diff --git a/src/exponential-backoff.ts b/src/exponential-backoff.ts new file mode 100644 index 0000000..03188ce --- /dev/null +++ b/src/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/gpt-response.ts b/src/gpt-response.ts new file mode 100644 index 0000000..fedc6b2 --- /dev/null +++ b/src/gpt-response.ts @@ -0,0 +1,157 @@ +import type { SSEvent } from './sse-session.js'; + +export type MessageChunk = { + type: 'reasoning' | 'content'; + reasoning_details?: string; + content: string; +} + +export type FinalResult = { + reasoning: string; + content: string; +} + +export type GPTResponse = { + id: string; + provider: string; + model: string; + object: string; + created: number; + choices: { + index: number; + delta: { + role: 'user' | 'assistant' | 'system'; + content: string; + reasoning: string; + reasoning_details: { + type: string; + summary: string; + } + }; + }[]; + finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | null; + native_finish_reason: string | null; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + cost: number; + is_byok: boolean; + prompt_tokens_details: { + cached_tokens: number; + }; + cost_details: { + upstream_inference_cost: number; + upstream_prompt_cost: number; + upstream_inference_completions_cost: number; + }, + completion_tokens_details: { + reasoning_tokens: number; + } + }; +} + +export class MessageResponse implements PromiseLike { + private chunks: MessageChunk[] = []; + private iteratorConsumed = false; + private resolveResult!: (value: FinalResult) => void; + private resultPromise: Promise; + private iterator: AsyncIterable; + + constructor(iterator: AsyncIterable) { + this.iterator = iterator; + this.resultPromise = new Promise(resolve => { + this.resolveResult = resolve; + }); + } + + async *[Symbol.asyncIterator]() { + if (this.iteratorConsumed) { + throw new Error('GPTResponse can only be iterated once'); + } + + this.iteratorConsumed = true; + + for await (const rawChunk of this.iterator) { + const chunk = this.parseChunk(rawChunk); + this.chunks.push(chunk); + yield chunk; + } + + this.resolveResult(this.buildResult()); + } + + then( + onfulfilled?: ((value: FinalResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise { + // If not yet iterated, consume the iterator to get the result + if (!this.iteratorConsumed) { + this.iteratorConsumed = true; + + (async () => { + for await (const rawChunk of this.iterator) { + const chunk = this.parseChunk(rawChunk); + this.chunks.push(chunk); + } + + this.resolveResult(this.buildResult()); + })(); + } + + return this.resultPromise.then(onfulfilled, onrejected); + } + + catch(onrejected?: ((reason: unknown) => never) | null): Promise { + return this.resultPromise.catch(onrejected); + } + + finally(onfinally?: (() => void) | undefined): Promise { + return this.resultPromise.finally(onfinally); + } + + private buildResult(): FinalResult { + return { + reasoning: this.chunks + .filter(c => c.type === 'reasoning') + .map(c => c.content) + .join(''), + content: this.chunks + .filter(c => c.type === 'content') + .map(c => c.content) + .join(''), + }; + } + + private parseChunk(rawChunk: SSEvent) { + // console.log('Raw Chunk:', rawChunk); + if (rawChunk.data === '[DONE]') { + return { + type: 'content', + content: '', + } as const; + } + + const data = JSON.parse(rawChunk.data) as GPTResponse; + const choice = data.choices[0]; + + if (!choice) { + throw new Error('No choice found in chunk'); + } + + const delta = choice.delta; + + if (delta.reasoning) { + return { + type: 'reasoning', + content: delta.reasoning, + reasoning_details: delta.reasoning_details.summary, + } as const; + } else { + return { + type: 'content', + content: delta.content, + } as const; + } + } +} \ No newline at end of file diff --git a/src/gpt.ts b/src/gpt.ts new file mode 100644 index 0000000..c61504e --- /dev/null +++ b/src/gpt.ts @@ -0,0 +1,87 @@ +import { SSESession } from './sse-session.js'; +import { EventEmitter } from './utils/event-emitter.js'; +import { MessageResponse } from './gpt-response.js'; + + +export type GPTEventMap = { + /** + * Emitted when a message is sent + */ + messageSent: { mesasge: string }; + + /** + * Emitted when a message chunk is received + */ + messageChunkReceived: { chunk: string }; + + /** + * Emitted when a response is received + */ + responseReceived: { response: string }; + + /** + * Emitted when a tool is called + */ + toolCalled: { toolName: string; arguments: Record; result: unknown }; +} + +export type GPTConfig = { + /** + * The API key to use for the GPT API + */ + apiKey: string; + + /** + * The API URL to use for the GPT API + */ + apiUrl: string; + + /** + * The model to use for the GPT API + */ + model: string; +} + +export type GPTRequest = { + /** + * The messages to send to the GPT API + */ + messages: { role: 'user' | 'assistant' | 'system'; content: string }[]; +} + +export class GPT extends EventEmitter { + constructor(public config: GPTConfig) { + super(); + } + + /** + * Sends a message to the GPT API + * @param message - The message to send + * @returns The response from the GPT API + */ + send(request: GPTRequest): MessageResponse { + const config = this.config; + + const lazyIterator = (async function* () { + const session = await SSESession.from(config.apiUrl, { + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + method: 'POST', + body: JSON.stringify({ + model: config.model, + messages: request.messages, + stream: true, + }), + }); + + if (!session.messages) { + throw new Error('Failed to create SSE session'); + } + + yield* session.messages; + })(); + + return new MessageResponse(lazyIterator); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..dd22cbd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,45 @@ +import { GPT } from './gpt.js'; + +const gptConfig = { + apiKey: process.env.OPENROUTER_API_KEY || '', + apiUrl: 'https://openrouter.ai/api/v1/chat/completions', + model: 'x-ai/grok-4.1-fast', +} + +const gpt = new GPT(gptConfig); + +const request = gpt.send({ messages: [{ role: 'user', content: 'Hello, how are you?' }] }); + +let lastChunk = { type: 'reasoning' } as { type: 'reasoning' | 'content'; reasoning?: string; reasoning_details?: string; content: string }; + +for await (const chunk of request) { + if (lastChunk.type === 'reasoning' && chunk.type === 'content') { + process.stdout.write('\n') + } + + if (chunk.type === 'reasoning') { + // trim the starting \n from the reasoning + const reasoning = chunk.content.replace(/^\n/, ''); + process.stdout.write(reasoning); + } else { + process.stdout.write(chunk.content); + } + + lastChunk = chunk; +} + +console.log('\n'); +console.log('--------------------------------'); +console.log('Streaming Results Completed'); +console.log('--------------------------------\n\n'); + +/** + * Generate the full response and get the final result + */ +const response = await gpt.send({ messages: [{ role: 'user', content: 'Hello, how are you?' }] }); +console.log(response); + +console.log('\n'); +console.log('--------------------------------'); +console.log('Final Result Generated'); +console.log('--------------------------------\n\n'); \ No newline at end of file diff --git a/src/sse-session.ts b/src/sse-session.ts new file mode 100644 index 0000000..b9b819f --- /dev/null +++ b/src/sse-session.ts @@ -0,0 +1,440 @@ +import { ExponentialBackoff } from './exponential-backoff.js'; +import { AsyncPushIterator } from './async-push-iterator.js'; + +/** + * A Server-Sent Events client implementation using fetch API. + * Supports custom headers, POST requests, and is non-blocking. + */ +export class SSESession { + /** + * Creates and connects a new SSESession instance. + * @param url The URL to connect to + * @param options Configuration options + * @returns A new connected SSESession instance + */ + public static async from( + url: string, + options: Partial = {}, + ): Promise { + const client = new SSESession(url, options); + await client.connect(); + return client; + } + + // State. + private url: string; + private controller: AbortController; + private connected: boolean = false; + protected options: SSESessionOptions; + protected messageBuffer: Uint8Array = new Uint8Array(); + public messages: AsyncPushIterator = new AsyncPushIterator(); + + // Listener for when the tab is hidden or shown. + private visibilityChangeHandler: ((event: Event) => void) | null = null; + + // Text decoders and encoders for parsing the message buffer. + private textDecoder: TextDecoder = new TextDecoder(); + private textEncoder: TextEncoder = new TextEncoder(); + + /** + * Creates a new SSESession instance. + * @param url The URL to connect to + * @param options Configuration options + */ + constructor(url: string, options: Partial = {}) { + this.url = url; + this.options = { + // Use default fetch function. + fetch: (...args) => fetch(...args), + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + onConnected: () => {}, + onMessage: () => {}, + onError: (error) => console.error('SSESession error:', error), + onDisconnected: () => {}, + onReconnect: (options) => Promise.resolve(options), + + // Reconnection options + attemptReconnect: true, + retryDelay: 1000, + persistent: false, + ...options, + }; + this.controller = new AbortController(); + + // Set up visibility change handling if in mobile browser environment + if (typeof document !== 'undefined') { + this.visibilityChangeHandler = this.handleVisibilityChange.bind(this); + document.addEventListener( + 'visibilitychange', + this.visibilityChangeHandler as (this: Document, ev: Event) => any, + ); + } + } + + /** + * Handles visibility change events in the browser. + */ + private async handleVisibilityChange(): Promise { + // When going to background, close the current connection cleanly + // This allows us to reconnect mobile devices when they come back after leaving the tab or browser app. + if (document.visibilityState === 'hidden') { + this.controller.abort(); + } + + // When coming back to foreground, attempt to reconnect if not connected + if (document.visibilityState === 'visible' && !this.connected) { + await this.connect(); + } + } + + /** + * Connects to the SSE endpoint. + */ + public async connect(): Promise { + if (this.connected) return; + + this.connected = true; + this.controller = new AbortController(); + + const { method, headers, body } = this.options; + + const fetchOptions: RequestInit = { + method, + headers, + body, + signal: this.controller.signal, + cache: 'no-store', + }; + + const exponentialBackoff = ExponentialBackoff.from({ + baseDelay: this.options.retryDelay, + maxDelay: 10000, + maxAttempts: 0, + growthRate: 1.3, + jitter: 0.3, + }); + + // Establish the connection and get the reader using the exponential backoff + const reader = await exponentialBackoff.run(async () => { + const reconnectOptions = await this.handleCallback( + this.options.onReconnect, + fetchOptions, + ); + + // Extract URL override if provided, use remaining options for fetch + const { url: urlOverride, ...restOptions } = reconnectOptions ?? {}; + + const updatedFetchOptions = { + ...fetchOptions, + ...restOptions, + }; + + // Use URL override if provided, otherwise use the original URL + const targetUrl = urlOverride ?? this.url; + + const res = await this.options.fetch(targetUrl, updatedFetchOptions); + if (!res.ok) { + throw new Error(`HTTP error! Status: ${res.status}`); + } + + if (!res.body) { + throw new Error('Response body is null'); + } + + return res.body.getReader(); + }); + + // Call the onConnected callback + this.handleCallback(this.options.onConnected); + + const readStream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + this.connected = false; + + // Call the onDisconnected callback. + this.handleCallback(this.options.onDisconnected, undefined); + + // If the connection was closed by the server, we want to attempt a reconnect if the connection should be persistent. + if (this.options.persistent) { + await this.connect(); + } + + break; + } + + const events = this.parseEvents(value); + + for (const event of events) { + this.messages?.push(event); + if (this.options.onMessage) { + this.handleCallback(this.options.onMessage, event); + } + } + } + this.messages?.close(); + } catch (error) { + this.connected = false; + + // Call the onDisconnected callback. + this.handleCallback(this.options.onDisconnected, error); + + // If the connection was aborted using the controller, we don't need to call onError. + if (this.controller.signal.aborted) { + return; + } + + // Call the onError callback. + // NOTE: we dont use the handleCallback here because it would result in 2 error callbacks. + try { + this.options.onError(error); + } catch (error) { + console.log(`SSE Session: onError callback error:`, error); + } + + // Attempt to reconnect if enabled + if (this.options.attemptReconnect) { + await this.connect(); + } + } + }; + + readStream(); + + return; + } + + protected parseEvents(chunk: Uint8Array): SSEvent[] { + // Append new chunk to existing buffer + this.messageBuffer = new Uint8Array([...this.messageBuffer, ...chunk]); + + const events: SSEvent[] = []; + const lines = this.textDecoder + .decode(this.messageBuffer) + .split(/\r\n|\r|\n/); + + let currentEvent: Partial = {}; + let completeEventCount = 0; + + // Iterate over the lines to find complete events + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Empty line signals the end of an event + if (line === '') { + if (currentEvent.data) { + // Remove trailing newline if present + currentEvent.data = currentEvent.data.replace(/\n$/, ''); + events.push(currentEvent as SSEvent); + currentEvent = {}; + completeEventCount = i + 1; + } + continue; + } + + if (!line) continue; + + // Parse field: value format + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) continue; + + const field = line.slice(0, colonIndex); + // Skip initial space after colon if present + const valueStartIndex = + colonIndex + 1 + (line[colonIndex + 1] === ' ' ? 1 : 0); + const value = line.slice(valueStartIndex); + + if (field === 'data') { + currentEvent.data = currentEvent.data + ? currentEvent.data + '\n' + value + : value; + } else if (field === 'event') { + currentEvent.event = value; + } else if (field === 'id') { + currentEvent.id = value; + } else if (field === 'retry') { + const retryMs = parseInt(value, 10); + if (!isNaN(retryMs)) { + currentEvent.retry = retryMs; + } + } + } + + // Store the remainder of the buffer for the next chunk + const remainder = lines.slice(completeEventCount).join('\n'); + this.messageBuffer = this.textEncoder.encode(remainder); + + return events; + } + + /** + * Override the onMessage callback. + * + * @param onMessage The callback to set. + */ + public setOnMessage(onMessage: (event: SSEvent) => void): void { + this.options.onMessage = onMessage; + } + + /** + * Closes the SSE connection and cleans up event listeners. + */ + public close(): void { + // Clean up everything including the visibility handler + this.controller.abort(); + + // Remove the visibility handler (This is only required on browsers) + if (this.visibilityChangeHandler && typeof document !== 'undefined') { + document.removeEventListener( + 'visibilitychange', + this.visibilityChangeHandler, + ); + this.visibilityChangeHandler = null; + } + } + + /** + * Checks if the client is currently connected. + * @returns Whether the client is connected + */ + public isConnected(): boolean { + return this.connected; + } + + /** + * Will handle thrown errors from the callback and call the onError callback. + * This is to avoid the sse-session from disconnecting from errors that are not a result of the sse-session itself. + * + * @param callback The callback to handle. + * @param args The arguments to pass to the callback. + */ + private handleCallback) => ReturnType>( + callback: T, + ...args: Parameters + ): ReturnType | undefined { + try { + return callback(...args); + } catch (error) { + try { + this.options.onError(error); + } catch (error) { + console.log(`SSE Session: onError callback error:`, error); + } + } + } +} + +/** + * Options returned from the onReconnect callback. + * Extends RequestInit with an optional URL override for reconnection. + */ +export interface ReconnectOptions extends RequestInit { + /** + * Optional URL override for the reconnection. + * If provided, the SSE session will connect to this URL instead of the original. + */ + url?: string; +} + +/** + * Configuration options for the SSESession. + */ +export interface SSESessionOptions { + /** + * 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". + */ + fetch: (url: string, options: RequestInit) => Promise; + + /** + * HTTP method to use (GET or POST). + */ + method: 'GET' | 'POST'; + + /** + * HTTP headers to send with the request. + */ + headers?: Record; + + /** + * Body to send with POST requests. + */ + body?: string | FormData | URLSearchParams; + + /** + * Called when the connection is established. + */ + onConnected: () => void; + + /** + * Called when a message is received. + */ + onMessage: (event: SSEvent) => void; + + /** + * Called when an error occurs. + */ + onError: (error: unknown) => void; + + /** + * Called when the connection is closed. + */ + onDisconnected: (error: unknown) => void; + + /** + * Called when the connection is going to try to reconnect. + * Can return modified request options including an optional URL override. + */ + onReconnect: (options: RequestInit) => Promise; + + /** + * Whether to attempt to reconnect. + */ + attemptReconnect: boolean; + + /** + * The delay in milliseconds between reconnection attempts. + */ + retryDelay: number; + + /** + * Whether to reconnect when the session is terminated by the server. + */ + persistent: boolean; +} + +/** + * 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/event-emitter.ts b/src/utils/event-emitter.ts new file mode 100644 index 0000000..e7d3cfe --- /dev/null +++ b/src/utils/event-emitter.ts @@ -0,0 +1,152 @@ +// TODO: You'll probably want to use WeakRef's here. + +export type EventMap = Record; + +type Listener = (detail: T) => void; + +interface ListenerEntry { + listener: Listener; + wrappedListener: Listener; + debounceTime?: number; + once?: boolean; +} + +export type OffCallback = () => void; + +export class EventEmitter { + private listeners: Map>> = new Map(); + + on( + type: K, + listener: Listener, + debounceMilliseconds?: number, + ): OffCallback { + const wrappedListener = + debounceMilliseconds && debounceMilliseconds > 0 + ? this.debounce(listener, debounceMilliseconds) + : listener; + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + + const listenerEntry: ListenerEntry = { + listener, + wrappedListener, + debounceTime: debounceMilliseconds || 0, + }; + + 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); + } + + once( + type: K, + listener: Listener, + debounceMilliseconds?: number, + ): OffCallback { + const wrappedListener: Listener = (detail: T[K]) => { + this.off(type, listener); + listener(detail); + }; + + const debouncedListener = + debounceMilliseconds && debounceMilliseconds > 0 + ? this.debounce(wrappedListener, debounceMilliseconds) + : wrappedListener; + + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + + const listenerEntry: ListenerEntry = { + listener, + wrappedListener: debouncedListener, + debounceTime: debounceMilliseconds || 0, + once: true, + }; + + 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); + } + + off(type: K, listener: Listener): void { + const listeners = this.listeners.get(type); + if (!listeners) return; + + const listenerEntry = Array.from(listeners).find( + (entry) => + entry.listener === listener || entry.wrappedListener === listener, + ); + + if (listenerEntry) { + listeners.delete(listenerEntry); + } + } + + emit(type: K, payload: T[K]): boolean { + const listeners = this.listeners.get(type); + if (!listeners) return false; + + listeners.forEach((entry) => { + entry.wrappedListener(payload); + }); + + return listeners.size > 0; + } + + removeAllListeners(): void { + this.listeners.clear(); + } + + async waitFor( + type: K, + predicate: (payload: T[K]) => boolean, + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + + 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); + } + + this.on(type, listener); + }); + } + + private debounce( + func: Listener, + wait: number, + ): Listener { + let timeout: ReturnType; + + return (detail: T[K]) => { + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + func(detail); + }, wait); + }; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a4e1431 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,44 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + // "rootDir": "./src", + // "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "types": ["node"], + // For nodejs: + // "lib": ["esnext"], + // "types": ["node"], + // and npm install -D @types/node + + // 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, + } +}