├── .nvmrc ├── examples ├── workers-chat-demo │ ├── .gitignore │ ├── wrangler.toml │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ │ └── utils.ts │ ├── README.md │ └── tsconfig.json └── http-streaming │ ├── README.md │ ├── package.json │ ├── src │ ├── server.ts │ └── index.html │ └── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── 2-feature-request.yml │ ├── 3-docs-issue.yml │ └── 1-bug-report.yml └── workflows │ ├── shared-build.yml │ ├── no-response.yml │ ├── format.yml │ ├── deduplicate-lock-file.yml │ ├── test.yml │ ├── deploy-docs.yml │ └── release.yml ├── pnpm-workspace.yaml ├── packages ├── eventkit │ ├── lib │ │ ├── schedulers │ │ │ ├── index.ts │ │ │ ├── queue.ts │ │ │ └── subject-queue.ts │ │ ├── utils │ │ │ ├── array.ts │ │ │ ├── types.ts │ │ │ ├── operators.ts │ │ │ └── errors.ts │ │ ├── operators │ │ │ ├── skip.ts │ │ │ ├── map.ts │ │ │ ├── index.ts │ │ │ ├── isEmpty.ts │ │ │ ├── max.ts │ │ │ ├── min.ts │ │ │ ├── pairwise.ts │ │ │ ├── takeUntil.ts │ │ │ ├── count.ts │ │ │ ├── filter.ts │ │ │ ├── findIndex.ts │ │ │ ├── every.ts │ │ │ ├── find.ts │ │ │ ├── elementAt.ts │ │ │ ├── partition.ts │ │ │ ├── first.ts │ │ │ ├── concat.ts │ │ │ ├── last.ts │ │ │ ├── withScheduler.ts │ │ │ ├── reduce.ts │ │ │ ├── buffer.ts │ │ │ ├── dlq.ts │ │ │ ├── pipe.ts │ │ │ └── retry.ts │ │ ├── index.ts │ │ └── singleton.ts │ ├── eslint.config.mjs │ ├── tsup.config.ts │ ├── NOTICE.md │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ ├── CHANGELOG.md │ └── __tests__ │ │ ├── operators │ │ ├── isEmpty.spec.ts │ │ ├── count.spec.ts │ │ └── buffer.spec.ts │ │ └── singleton.test.ts ├── eventkit-http │ ├── lib │ │ ├── index.ts │ │ └── utils.ts │ ├── vite.config.ts │ ├── tsconfig.json │ ├── eslint.config.mjs │ ├── __tests__ │ │ └── setup.ts │ ├── tsup.config.ts │ ├── README.md │ ├── package.json │ └── CHANGELOG.md └── async-observable │ ├── tsup.config.ts │ ├── NOTICE.md │ ├── eslint.config.mjs │ ├── tsconfig.json │ ├── lib │ ├── index.ts │ ├── utils │ │ ├── signal.ts │ │ └── promise.ts │ └── types.ts │ ├── README.md │ ├── CHANGELOG.md │ └── package.json ├── .vscode └── settings.json ├── .prettierignore ├── docs ├── assets │ └── images │ │ ├── observable-tree-dark.png │ │ ├── observable-tree-light.png │ │ ├── scheduled-action-marble-dark.png │ │ ├── error-handling-dlq-marble-dark.png │ │ ├── error-handling-dlq-marble-light.png │ │ ├── observable-tree-exhausted-dark.png │ │ ├── observable-tree-exhausted-light.png │ │ ├── scheduled-action-marble-light.png │ │ ├── error-handling-baseline-marble-dark.png │ │ ├── error-handling-baseline-marble-light.png │ │ ├── error-handling-callback-marble-dark.png │ │ ├── error-handling-callback-marble-light.png │ │ ├── observable-tree-no-passthrough-dark.png │ │ ├── observable-tree-no-passthrough-light.png │ │ ├── observable-tree-with-scheduler-dark.png │ │ ├── observable-tree-with-scheduler-light.png │ │ ├── observable-tree-with-passthrough-dark.png │ │ ├── observable-tree-with-passthrough-light.png │ │ ├── observable-tree-with-own-scheduler-dark.png │ │ ├── observable-tree-with-own-scheduler-light.png │ │ ├── error-handling-unhandled-callback-marble-dark.png │ │ └── error-handling-unhandled-callback-marble-light.png ├── .vitepress │ ├── theme │ │ ├── index.ts │ │ └── custom.css │ └── config.mts ├── index.md └── guide │ ├── getting-started.md │ └── what-is-eventkit.md ├── .editorconfig ├── vitest.config.ts ├── prettier.config.js ├── scripts ├── build.d.ts ├── build.js └── typedoc.js ├── .changeset ├── config.json └── README.md ├── .gitignore ├── SECURITY.md ├── typedoc.json ├── LICENSE.md ├── eslint.config.mjs ├── package.json ├── README.md └── CONTRIBUTING.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /examples/workers-chat-demo/.gitignore: -------------------------------------------------------------------------------- 1 | .wrangler -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - "examples/*" 4 | -------------------------------------------------------------------------------- /packages/eventkit/lib/schedulers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./queue"; 2 | export * from "./subject-queue"; 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.options": { 3 | "flags": ["unstable_config_lookup_from_file"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | pnpm-lock.yaml 3 | docs/.vitepress/** 4 | docs/reference/** 5 | examples/**/dist/ 6 | packages/**/dist/ -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-dark.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-light.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_size = 2 6 | indent_style = space 7 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /docs/assets/images/scheduled-action-marble-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/scheduled-action-marble-dark.png -------------------------------------------------------------------------------- /packages/eventkit-http/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./event-source-response"; 2 | export * from "./event-source"; 3 | export * from "./websocket"; 4 | -------------------------------------------------------------------------------- /docs/assets/images/error-handling-dlq-marble-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/error-handling-dlq-marble-dark.png -------------------------------------------------------------------------------- /docs/assets/images/error-handling-dlq-marble-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/error-handling-dlq-marble-light.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-exhausted-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-exhausted-dark.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-exhausted-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-exhausted-light.png -------------------------------------------------------------------------------- /docs/assets/images/scheduled-action-marble-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/scheduled-action-marble-light.png -------------------------------------------------------------------------------- /docs/assets/images/error-handling-baseline-marble-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/error-handling-baseline-marble-dark.png -------------------------------------------------------------------------------- /docs/assets/images/error-handling-baseline-marble-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/error-handling-baseline-marble-light.png -------------------------------------------------------------------------------- /docs/assets/images/error-handling-callback-marble-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/error-handling-callback-marble-dark.png -------------------------------------------------------------------------------- /docs/assets/images/error-handling-callback-marble-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/error-handling-callback-marble-light.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-no-passthrough-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-no-passthrough-dark.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-no-passthrough-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-no-passthrough-light.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-with-scheduler-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-with-scheduler-dark.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-with-scheduler-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-with-scheduler-light.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-with-passthrough-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-with-passthrough-dark.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-with-passthrough-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-with-passthrough-light.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-with-own-scheduler-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-with-own-scheduler-dark.png -------------------------------------------------------------------------------- /docs/assets/images/observable-tree-with-own-scheduler-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/observable-tree-with-own-scheduler-light.png -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | workspace: ["packages/[^_]*"], 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /docs/assets/images/error-handling-unhandled-callback-marble-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/error-handling-unhandled-callback-marble-dark.png -------------------------------------------------------------------------------- /docs/assets/images/error-handling-unhandled-callback-marble-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hntrl/eventkit/HEAD/docs/assets/images/error-handling-unhandled-callback-marble-light.png -------------------------------------------------------------------------------- /packages/eventkit-http/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | setupFiles: "__tests__/setup.ts", 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import type { EnhanceAppContext } from "vitepress"; 2 | import DefaultTheme from "vitepress/theme"; 3 | 4 | import "./custom.css"; 5 | 6 | export default { 7 | extends: DefaultTheme, 8 | }; 9 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | module.exports = { 5 | trailingComma: "es5", 6 | singleQuote: false, 7 | printWidth: 100, 8 | overrides: [ 9 | { 10 | files: ["__tests__/**/*.ts"], 11 | options: { 12 | requirePragma: true, 13 | }, 14 | }, 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /scripts/build.d.ts: -------------------------------------------------------------------------------- 1 | import { type Options } from "tsup"; 2 | 3 | declare function createBanner(packageName: string, version: string): string; 4 | 5 | type BuildConfigParams = { 6 | packageName: string; 7 | packageVersion: string; 8 | target: "browser" | "neutral"; 9 | options: Options; 10 | }; 11 | 12 | declare function getBuildConfig(params: BuildConfigParams): Options[]; 13 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "hntrl/eventkit" }], 4 | "commit": false, 5 | "fixed": [["@eventkit/*"]], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "bumpVersionsWithWorkspaceProtocolOnly": true, 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /examples/workers-chat-demo/wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "eventkit-edge-chat-demo" 3 | compatibility_date = "2024-01-01" 4 | main = "src/worker.ts" 5 | 6 | [durable_objects] 7 | bindings = [{ name = "rooms", class_name = "ChatRoom" }] 8 | 9 | [[rules]] 10 | type = "Data" 11 | globs = ["**/*.html"] 12 | fallthrough = false 13 | 14 | [[migrations]] 15 | tag = "v1" 16 | new_classes = ["ChatRoom"] 17 | -------------------------------------------------------------------------------- /packages/eventkit/lib/utils/array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes an item from an array, mutating it. 3 | * @param arr The array to remove the item from 4 | * @param item The item to remove 5 | */ 6 | export function arrRemove(arr: T[] | undefined | null, item: T) { 7 | if (arr) { 8 | const index = arr.indexOf(item); 9 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 10 | 0 <= index && arr.splice(index, 1); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/http-streaming/README.md: -------------------------------------------------------------------------------- 1 | # HTTP Streaming Example 2 | 3 | This example showcases how to use eventkit's primitives to implement HTTP streaming. 4 | 5 | See the [HTTP Streaming Guide](https://github.com/hntrl/eventkit/blob/main/docs/guide/examples/http-streaming.md). 6 | 7 | ## Running the example 8 | 9 | 1. Install dependencies: 10 | 11 | ```bash 12 | npm install 13 | ``` 14 | 15 | 2. Start the server: 16 | 17 | ```bash 18 | npm run dev 19 | ``` 20 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/skip.ts: -------------------------------------------------------------------------------- 1 | import { type OperatorFunction } from "@eventkit/async-observable"; 2 | 3 | import { filter } from "./filter"; 4 | 5 | /** 6 | * Returns an observable that skips the first `count` values emitted by the source observable. 7 | * 8 | * @param count The number of values to skip. 9 | * 10 | * @group Operators 11 | */ 12 | export function skip(count: number): OperatorFunction { 13 | return filter((_, index) => count <= index); 14 | } 15 | -------------------------------------------------------------------------------- /packages/eventkit-http/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "exclude": ["dist", "__tests__", "node_modules"], 4 | "compilerOptions": { 5 | "lib": ["ES2022"], 6 | "target": "ES2022", 7 | "types": ["node"], 8 | "moduleResolution": "Bundler", 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "declaration": true, 12 | "emitDeclarationOnly": true, 13 | "rootDir": ".", 14 | "outDir": "./dist", 15 | "skipLibCheck": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/http-streaming/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eventkit/http-streaming-example", 3 | "private": true, 4 | "scripts": { 5 | "test": "echo \"Error: no test specified\" && exit 1", 6 | "dev": "tsx src/server.ts" 7 | }, 8 | "dependencies": { 9 | "@hono/node-server": "^1.14.0", 10 | "@eventkit/base": "workspace:*", 11 | "hono": "^4.7.5" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^22.13.14", 15 | "tsx": "^4.19.3", 16 | "typescript": "^5.8.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/eventkit-http/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import tseslint from "typescript-eslint"; 3 | 4 | import shared from "../../eslint.config.mjs"; 5 | 6 | // @ts-check 7 | 8 | export default tseslint.config(...shared, { 9 | ignores: ["**/__tests__/**"], 10 | languageOptions: { 11 | globals: globals.commonjs, 12 | }, 13 | rules: { 14 | "@typescript-eslint/no-explicit-any": "off", 15 | "import/no-nodejs-modules": "error", 16 | "import/named": "off", 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /examples/workers-chat-demo/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import tseslint from "typescript-eslint"; 3 | 4 | import shared from "../../eslint.config.mjs"; 5 | 6 | // @ts-check 7 | 8 | export default tseslint.config(...shared, { 9 | ignores: ["**/__tests__/**"], 10 | languageOptions: { 11 | globals: globals.commonjs, 12 | }, 13 | rules: { 14 | "@typescript-eslint/no-explicit-any": "off", 15 | "import/no-nodejs-modules": "error", 16 | "import/named": "off", 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Suggest an idea, feature, or enhancement 3 | labels: [enhancement] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to file a feature request! Please let us know what you're trying to do 10 | 11 | - type: textarea 12 | attributes: 13 | label: Describe the solution 14 | description: A clear description of what you want to happen. 15 | validations: 16 | required: true 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor-specific 2 | .idea/ 3 | *.iml 4 | *.sublime-project 5 | *.sublime-workspace 6 | .settings 7 | 8 | # Installed libs 9 | node_modules/ 10 | typings/ 11 | 12 | # Generated 13 | dist/ 14 | .wireit 15 | docs/reference 16 | tsup.config.bundled_*.mjs 17 | 18 | # Copied Package Files 19 | packages/**/LICENSE.txt 20 | packages/**/CODE_OF_CONDUCT.md 21 | 22 | # Misc 23 | npm-debug.log 24 | .DS_STORE 25 | *.tgz 26 | .eslintcache 27 | package-lock.json 28 | .tmp 29 | /.env 30 | /NOTES.md 31 | 32 | # Docs 33 | docs/.vitepress/dist 34 | docs/.vitepress/cache -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/map.ts: -------------------------------------------------------------------------------- 1 | import { type OperatorFunction } from "@eventkit/async-observable"; 2 | 3 | /** 4 | * Applies a given predicate function to each value emitted by the source observable. 5 | * @group Operators 6 | */ 7 | export function map(predicate: (value: T, index: number) => R): OperatorFunction { 8 | return (source) => 9 | new source.AsyncObservable(async function* () { 10 | let index = 0; 11 | for await (const value of source) { 12 | yield predicate(value, index++); 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you have discovered a security vulnerability in this project, please report it privately. **Do not disclose it as a public issue.** This gives us time to work with you to fix the issue before public exposure, reducing the chance that the exploit will be used before a patch is released. Besides, make sure to align with us before any public disclosure to ensure no dangerous information goes public too soon. 6 | 7 | Please disclose it at [security advisory](https://github.com/hntrl/eventkit/security/advisories/new). 8 | -------------------------------------------------------------------------------- /packages/eventkit-http/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import { EventSource as ExternalEventSource } from "eventsource"; 2 | 3 | // To use the EventSource global in node, it needs a --experimental-eventsource flag (which isn't feasible) 4 | // so we need to use the external implementation for testing. As far as I can tell, the external implementation 5 | // is compatible with the browser implementation, so this should be fine. 6 | // We don't need to do this for WebSocket because it's exported in node 22 but not node 20 (we test both versions) 7 | if (!globalThis.EventSource) { 8 | globalThis.EventSource = ExternalEventSource as any; 9 | } 10 | -------------------------------------------------------------------------------- /packages/eventkit-http/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | import { getBuildConfig } from "../../scripts/build"; 4 | import pkg from "./package.json"; 5 | 6 | export default defineConfig([ 7 | ...getBuildConfig({ 8 | packageName: pkg.name, 9 | packageVersion: pkg.version, 10 | target: "neutral", 11 | options: { 12 | entry: ["lib/index.ts"], 13 | }, 14 | }), 15 | ...getBuildConfig({ 16 | packageName: pkg.name, 17 | packageVersion: pkg.version, 18 | target: "browser", 19 | options: { 20 | entry: ["lib/index.ts"], 21 | globalName: "eventkit.http", 22 | }, 23 | }), 24 | ]); 25 | -------------------------------------------------------------------------------- /packages/async-observable/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | import { getBuildConfig } from "../../scripts/build"; 4 | import pkg from "./package.json"; 5 | 6 | export default defineConfig([ 7 | ...getBuildConfig({ 8 | packageName: pkg.name, 9 | packageVersion: pkg.version, 10 | target: "neutral", 11 | options: { 12 | entry: ["lib/index.ts"], 13 | }, 14 | }), 15 | ...getBuildConfig({ 16 | packageName: pkg.name, 17 | packageVersion: pkg.version, 18 | target: "browser", 19 | options: { 20 | entry: ["lib/index.ts"], 21 | globalName: "eventkit.asyncObservable", 22 | }, 23 | }), 24 | ]); 25 | -------------------------------------------------------------------------------- /packages/eventkit/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import tseslint from "typescript-eslint"; 3 | 4 | import shared from "../../eslint.config.mjs"; 5 | 6 | // @ts-check 7 | 8 | const restrictedNodeGlobals = Object.keys(globals.node).filter( 9 | (key) => !["setTimeout", "setInterval"].includes(key) 10 | ); 11 | 12 | export default tseslint.config(...shared, { 13 | languageOptions: { 14 | globals: globals.commonjs, 15 | }, 16 | rules: { 17 | "no-restricted-globals": ["error", ...restrictedNodeGlobals], 18 | "@typescript-eslint/no-explicit-any": "off", 19 | "import/no-nodejs-modules": "error", 20 | "import/named": "off", 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /examples/workers-chat-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eventkit-edge-chat-demo", 3 | "private": true, 4 | "scripts": { 5 | "test": "echo \"Error: no test specified\" && exit 1", 6 | "dev": "pnpm run wrangler:dev", 7 | "wrangler:dev": "wrangler dev", 8 | "wrangler:deploy": "wrangler deploy", 9 | "wrangler:types": "wrangler types", 10 | "wrangler:tail": "wrangler tail --format pretty" 11 | }, 12 | "dependencies": { 13 | "@eventkit/base": "workspace:*", 14 | "@eventkit/http": "workspace:*", 15 | "@hono/zod-validator": "^0.4.3", 16 | "hono": "^4.7.5", 17 | "zod": "^3.24.2" 18 | }, 19 | "devDependencies": { 20 | "typescript": "^5.8.3", 21 | "wrangler": "^4.9.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-docs-issue.yml: -------------------------------------------------------------------------------- 1 | name: 📚 Documentation Issue 2 | description: Something is wrong with the eventkit docs 3 | labels: [docs] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for contributing! 10 | 11 | For documentation updates - we would happily accept PRs, so feel free to update and 12 | open a PR to the `main` branch. Otherwise let us know in this issue what you felt 13 | was missing or incorrect. 14 | 15 | - type: textarea 16 | attributes: 17 | label: Describe what's incorrect/missing in the documentation 18 | description: A concise description of what you expected to see in the docs 19 | validations: 20 | required: true 21 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./buffer"; 2 | export * from "./concat"; 3 | export * from "./count"; 4 | export * from "./dlq"; 5 | export * from "./elementAt"; 6 | export * from "./every"; 7 | export * from "./filter"; 8 | export * from "./find"; 9 | export * from "./findIndex"; 10 | export * from "./first"; 11 | export * from "./isEmpty"; 12 | export * from "./last"; 13 | export * from "./map"; 14 | export * from "./max"; 15 | export * from "./merge"; 16 | export * from "./min"; 17 | export * from "./pairwise"; 18 | export * from "./partition"; 19 | export * from "./pipe"; 20 | export * from "./reduce"; 21 | export * from "./retry"; 22 | export * from "./skip"; 23 | export * from "./takeUntil"; 24 | export * from "./withScheduler"; 25 | -------------------------------------------------------------------------------- /packages/eventkit/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | import { getBuildConfig } from "../../scripts/build"; 4 | import pkg from "./package.json"; 5 | 6 | export default defineConfig([ 7 | ...getBuildConfig({ 8 | packageName: pkg.name, 9 | packageVersion: pkg.version, 10 | target: "neutral", 11 | options: { 12 | entry: ["lib/index.ts"], 13 | noExternal: ["@eventkit/async-observable"], 14 | }, 15 | }), 16 | ...getBuildConfig({ 17 | packageName: pkg.name, 18 | packageVersion: pkg.version, 19 | target: "browser", 20 | options: { 21 | entry: ["lib/index.ts"], 22 | globalName: "eventkit", 23 | noExternal: ["@eventkit/async-observable"], 24 | }, 25 | }), 26 | ]); 27 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/isEmpty.ts: -------------------------------------------------------------------------------- 1 | import { singletonFrom } from "../singleton"; 2 | import { type SingletonOperatorFunction } from "../utils/types"; 3 | 4 | /** 5 | * Emits `false` if the source observable emits any values, or emits `true` if the source observable 6 | * completes without emitting any values. 7 | * 8 | * @group Operators 9 | */ 10 | export function isEmpty(): SingletonOperatorFunction { 11 | return (source) => 12 | singletonFrom( 13 | new source.AsyncObservable(async function* () { 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | for await (const _ of source) { 16 | yield false; 17 | return; 18 | } 19 | yield true; 20 | }) 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /packages/eventkit/NOTICE.md: -------------------------------------------------------------------------------- 1 | This project includes code and concepts derived from RxJS (https://github.com/ReactiveX/rxjs) 2 | Copyright (c) 2015-2025 Ben Lesh, Google, Inc., Netflix, Inc., Microsoft Corp. and contributors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /packages/async-observable/NOTICE.md: -------------------------------------------------------------------------------- 1 | This project includes code and concepts derived from RxJS (https://github.com/ReactiveX/rxjs) 2 | Copyright (c) 2015-2025 Ben Lesh, Google, Inc., Netflix, Inc., Microsoft Corp. and contributors 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /packages/async-observable/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import tseslint from "typescript-eslint"; 3 | 4 | import shared from "../../eslint.config.mjs"; 5 | 6 | // @ts-check 7 | 8 | const restrictedNodeGlobals = Object.keys(globals.node).filter( 9 | (key) => !["ReadableStream"].includes(key) 10 | ); 11 | 12 | export default tseslint.config(...shared, { 13 | languageOptions: { 14 | globals: globals.commonjs, 15 | }, 16 | rules: { 17 | "no-restricted-globals": ["error", ...restrictedNodeGlobals], 18 | "@typescript-eslint/no-unused-vars": "off", 19 | "@typescript-eslint/no-explicit-any": "off", 20 | "@typescript-eslint/no-unsafe-declaration-merging": "off", 21 | "import/no-nodejs-modules": "error", 22 | "import/export": "off", 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/max.ts: -------------------------------------------------------------------------------- 1 | import { type SingletonOperatorFunction } from "../utils/types"; 2 | import { reduce } from "./reduce"; 3 | 4 | /** 5 | * Emits the maximum value emitted by the source observable. The source observable must emit a 6 | * comparable type (numbers, strings, dates, etc.), or any type when a comparer function is 7 | * provided. 8 | * 9 | * @param comparer A function that compares two values and returns a number; a positive number if the 10 | * first value is greater than the second, a negative number if the first value is less than the 11 | * second, or 0 if they are equal. 12 | * @group Operators 13 | */ 14 | export function max(comparer?: (x: T, y: T) => number): SingletonOperatorFunction { 15 | comparer ??= (x: T, y: T) => (x > y ? 1 : -1); 16 | return reduce((x, y) => (comparer(x, y) > 0 ? x : y)); 17 | } 18 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/min.ts: -------------------------------------------------------------------------------- 1 | import { type SingletonOperatorFunction } from "../utils/types"; 2 | import { reduce } from "./reduce"; 3 | 4 | /** 5 | * Emits the minimum value emitted by the source observable. The source observable must emit a 6 | * comparable type (numbers, strings, dates, etc.), or any type when a comparer function is 7 | * provided. 8 | * 9 | * @param comparer A function that compares two values and returns a number; a positive number if the 10 | * first value is greater than the second, a negative number if the first value is less than the 11 | * second, or 0 if they are equal. 12 | * @group Operators 13 | */ 14 | export function min(comparer?: (x: T, y: T) => number): SingletonOperatorFunction { 15 | comparer ??= (x: T, y: T) => (x > y ? 1 : -1); 16 | return reduce((x, y) => (comparer(x, y) < 0 ? x : y)); 17 | } 18 | -------------------------------------------------------------------------------- /packages/eventkit/lib/utils/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type UnaryFunction, 3 | type AsyncObservable, 4 | type AsyncObservableInput, 5 | } from "@eventkit/async-observable"; 6 | 7 | import { type SingletonAsyncObservable } from "../singleton"; 8 | 9 | /** 10 | * A simple type to represent a gamut of "falsy" values... with a notable exception: 11 | * `NaN` is "falsy" however, it is not and cannot be typed via TypeScript. See 12 | * comments here: https://github.com/microsoft/TypeScript/issues/28682#issuecomment-707142417 13 | */ 14 | export type Falsy = null | undefined | false | 0 | -0 | 0n | ""; 15 | 16 | export type TruthyTypesOf = T extends Falsy ? never : T; 17 | 18 | export type AsyncObservableInputTuple = { 19 | [K in keyof T]: AsyncObservableInput; 20 | }; 21 | 22 | export type SingletonOperatorFunction = UnaryFunction< 23 | AsyncObservable, 24 | SingletonAsyncObservable 25 | >; 26 | -------------------------------------------------------------------------------- /packages/eventkit/lib/utils/operators.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a function that returns the logical negation of the provided predicate function. 3 | * This is useful for inverting the logic of a predicate function for use with operators 4 | * like `filter`. 5 | * 6 | * @param pred - The predicate function to negate 7 | * @returns A new function that returns the opposite boolean value of the original predicate 8 | * 9 | * @example 10 | * // Filter out even numbers 11 | * observable.pipe(filter(not(isEven))) 12 | */ 13 | export function not( 14 | pred: (value: T, index: number) => boolean 15 | ): (value: T, index: number) => boolean { 16 | return (value: T, index: number) => !pred(value, index); 17 | } 18 | 19 | /** 20 | * Immediately invokes a function and returns its result. 21 | * 22 | * @param fn - The function to invoke 23 | * @returns The result of the function 24 | */ 25 | export function iife(fn: () => T): T { 26 | return fn(); 27 | } 28 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme", "typedoc-plugin-frontmatter"], 3 | "entryPoints": ["packages/eventkit", "packages/eventkit-http"], 4 | "entryPointStrategy": "packages", 5 | "packageOptions": { 6 | "excludeInternal": true, 7 | "categorizeByGroup": true, 8 | "excludeCategories": true, 9 | "sort": ["kind", "instance-first", "alphabetical-ignoring-documents"], 10 | "entryPoints": ["lib/index.ts"] 11 | }, 12 | "router": "structure", 13 | "navigation": { 14 | "includeGroups": true, 15 | "includeCategories": false 16 | }, 17 | "frontmatterGlobals": { 18 | "outline": [2, 3] 19 | }, 20 | "disableSources": true, 21 | "readme": "none", 22 | "out": "./docs/reference", 23 | "docsRoot": "./docs", 24 | "indexFormat": "table", 25 | "parametersFormat": "table", 26 | "maxTypeConversionDepth": 5, 27 | "useCodeBlocks": true, 28 | "sanitizeComments": true 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/shared-build.yml: -------------------------------------------------------------------------------- 1 | name: 🛠️ Build 2 | 3 | on: 4 | workflow_call: 5 | 6 | env: 7 | CI: true 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: ⬇️ Checkout repo 14 | uses: actions/checkout@v4 15 | 16 | - name: 📦 Setup pnpm 17 | uses: pnpm/action-setup@v4 18 | 19 | - name: ⎔ Setup node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version-file: ".nvmrc" 23 | cache: "pnpm" 24 | 25 | - uses: google/wireit@setup-github-actions-caching/v2 26 | 27 | - name: Disable GitHub Actions Annotations 28 | run: | 29 | echo "::remove-matcher owner=tsc::" 30 | echo "::remove-matcher owner=eslint-compact::" 31 | echo "::remove-matcher owner=eslint-stylish::" 32 | 33 | - name: 📥 Install deps 34 | run: pnpm install --frozen-lockfile 35 | 36 | - name: 🏗 Build 37 | run: pnpm build 38 | -------------------------------------------------------------------------------- /packages/eventkit-http/README.md: -------------------------------------------------------------------------------- 1 | `@eventkit/http` is a package that provides HTTP utilities for eventkit. 2 | 3 | ## Installation 4 | 5 | ```sh 6 | npm i @eventkit/http 7 | ``` 8 | 9 | ### Using a CDN 10 | 11 | This package also bundles a browser-friendly version that can be accessed using a CDN like [unpkg](https://unpkg.com/). 12 | 13 | ```html 14 | 15 | 16 | 17 | 18 | ``` 19 | 20 | When imported this way, all exports are available on the `eventkit.http` global variable. 21 | 22 | ```js 23 | const { EventSource } = eventkit.http; 24 | ``` 25 | 26 | ## Related Resources 27 | 28 | - [HTTP Streaming](https://hntrl.github.io/eventkit/guide/examples/http-streaming) 29 | - [API Reference](https://hntrl.github.io/eventkit/reference/_eventkit/http) 30 | - [Changelog](./CHANGELOG.md) 31 | -------------------------------------------------------------------------------- /packages/eventkit/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "exclude": ["dist", "__tests__", "node_modules"], 4 | "compilerOptions": { 5 | "lib": [ 6 | "DOM", // ??? 7 | "ESNext", 8 | "ESNext.AsyncIterable" 9 | ], 10 | "target": "ESNext", 11 | "module": "Node16", 12 | "moduleResolution": "Node16", 13 | "strict": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "outDir": "./dist", 18 | "rootDir": "./", 19 | "resolveJsonModule": true, 20 | "noImplicitAny": true, 21 | "noImplicitThis": true, 22 | "strictNullChecks": true, 23 | "strictFunctionTypes": true, 24 | "strictBindCallApply": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "noUnusedLocals": true, 27 | "noUnusedParameters": true, 28 | "noImplicitReturns": true, 29 | "noEmitOnError": true, 30 | "paths": { 31 | "eventkit": ["./index.ts"] 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/async-observable/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "exclude": ["dist", "__tests__", "node_modules"], 4 | "compilerOptions": { 5 | /* Projects */ 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "inlineSources": true, 10 | "module": "nodenext", 11 | "moduleResolution": "nodenext", 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "outDir": "./dist", 17 | "target": "esnext", 18 | /* Type Checking */ 19 | "forceConsistentCasingInFileNames": true, 20 | "noImplicitAny": true, 21 | "noImplicitThis": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedIndexedAccess": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "noImplicitReturns": true, 27 | "noEmitOnError": true, 28 | "strictNullChecks": true, 29 | "strictFunctionTypes": true, 30 | "strictBindCallApply": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/async-observable/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type AsyncObservableInputType, 3 | getAsyncObservableInputType, 4 | isAsyncObservable, 5 | from, 6 | } from "./from"; 7 | 8 | export { AsyncObservable } from "./observable"; 9 | 10 | export { 11 | kCancelSignal, 12 | SubscriberReturnSignal, 13 | Subscriber, 14 | ConsumerPromise, 15 | CallbackSubscriber, 16 | } from "./subscriber"; 17 | 18 | export { 19 | ScheduledAction, 20 | CallbackAction, 21 | CleanupAction, 22 | Scheduler, 23 | PassthroughScheduler, 24 | } from "./scheduler"; 25 | 26 | export { 27 | type UnaryFunction, 28 | type OperatorFunction, 29 | type MonoTypeOperatorFunction, 30 | type SubscriberCallback, 31 | type SubscriptionLike, 32 | type SchedulerSubject, 33 | type SchedulerLike, 34 | type AsyncObservableInput, 35 | type InteropAsyncObservable, 36 | type ObservedValueOf, 37 | type ReadableStreamLike, 38 | } from "./types"; 39 | 40 | export { PromiseSet } from "./utils/promise"; 41 | export { Signal } from "./utils/signal"; 42 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/pairwise.ts: -------------------------------------------------------------------------------- 1 | import { type OperatorFunction } from "@eventkit/async-observable"; 2 | 3 | /** 4 | * Groups pairs of consecutive emissions together and emits them as a tuple of two values. In other 5 | * words, it will take the current value and the previous value and emit them as a pair. 6 | * 7 | * Note: Because pairwise only emits complete pairs, the first emission from the source observable 8 | * will be "skipped" since there is no previous value. This means that the output observable will 9 | * always have one less emission than the source observable. 10 | * 11 | * @group Operators 12 | */ 13 | export function pairwise(): OperatorFunction { 14 | return (source) => 15 | new source.AsyncObservable(async function* () { 16 | let prev: T | undefined; 17 | for await (const value of source) { 18 | if (prev === undefined) { 19 | prev = value; 20 | continue; 21 | } 22 | yield [prev, value]; 23 | prev = value; 24 | } 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2025 Hunter Lovell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/takeUntil.ts: -------------------------------------------------------------------------------- 1 | import { type OperatorFunction, type AsyncObservable } from "@eventkit/async-observable"; 2 | 3 | import { map } from "./map"; 4 | import { merge } from "./merge"; 5 | 6 | const kStopNotification = Symbol("stopNotification"); 7 | type StopNotification = typeof kStopNotification; 8 | 9 | /** 10 | * Emits values from the source observable until the `stopNotifier` observable emits a value. 11 | * Once the `stopNotifier` emits, the resulting observable completes and no more values will be 12 | * emitted. 13 | * 14 | * @param stopNotifier An observable that signals when to stop taking values from the source. 15 | * @group Operators 16 | */ 17 | export function takeUntil(stopNotifier: AsyncObservable): OperatorFunction { 18 | return (source) => 19 | new source.AsyncObservable(async function* () { 20 | const notifier$ = stopNotifier.pipe(map(() => kStopNotification)); 21 | const merged$ = source.pipe(merge(notifier$)); 22 | for await (const value of merged$) { 23 | if (value === kStopNotification) break; 24 | yield value; 25 | } 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/count.ts: -------------------------------------------------------------------------------- 1 | import { singletonFrom } from "../singleton"; 2 | import { type SingletonOperatorFunction } from "../utils/types"; 3 | 4 | /** 5 | * Counts the number of items emitted by the source observable, and emits that 6 | * number when the source observable completes. 7 | * 8 | * @param predicate A function that is used to analyze the value and the index and 9 | * determine whether or not to increment the count. Return `true` to increment the count, 10 | * and return `false` to keep the count the same. If the predicate is not provided, every value 11 | * will be counted. 12 | * @group Operators 13 | */ 14 | export function count( 15 | predicate?: (value: T, index: number) => boolean 16 | ): SingletonOperatorFunction { 17 | predicate = predicate ?? (() => true); 18 | return (source) => 19 | singletonFrom( 20 | new source.AsyncObservable(async function* () { 21 | let index = 0; 22 | let count = 0; 23 | for await (const value of source) { 24 | if (predicate(value, index++)) { 25 | count++; 26 | } 27 | } 28 | yield count; 29 | }) 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /packages/eventkit/lib/utils/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An error thrown when an element was queried at a certain index of an 3 | * observable, but no such index or position exists in that sequence. 4 | * 5 | * @see {@link elementAt} 6 | * @group Errors 7 | */ 8 | export class ArgumentOutOfRangeError extends Error { 9 | /** @internal */ 10 | constructor() { 11 | super("argument out of range"); 12 | this.name = "ArgumentOutOfRangeError"; 13 | } 14 | } 15 | 16 | /** 17 | * An error thrown when an invalid concurrency limit is provided to an operator. 18 | * 19 | * @see {@link QueueScheduler} 20 | * @see {@link SubjectQueueScheduler} 21 | * @group Errors 22 | */ 23 | export class InvalidConcurrencyLimitError extends Error { 24 | /** @internal */ 25 | constructor() { 26 | super("invalid concurrency limit"); 27 | this.name = "InvalidConcurrencyLimitError"; 28 | } 29 | } 30 | 31 | /** 32 | * An error thrown when an observable completes without emitting any valid values. 33 | * 34 | * @group Errors 35 | */ 36 | export class NoValuesError extends Error { 37 | /** @internal */ 38 | constructor() { 39 | super("no values"); 40 | this.name = "NoValuesError"; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/async-observable/README.md: -------------------------------------------------------------------------------- 1 | `@eventkit/async-observable` is the package that provides the `AsyncObservable` class. This is exported separately from the main package to separate the implementation details of the observable pattern from the rest of the API. 2 | 3 | ## Installation 4 | 5 | ```sh 6 | npm i @eventkit/async-observable 7 | ``` 8 | 9 | ### Using a CDN 10 | 11 | This package also bundles a browser-friendly version that can be accessed using a CDN like [unpkg](https://unpkg.com/). 12 | 13 | ```html 14 | 15 | 16 | 17 | 18 | ``` 19 | 20 | When imported this way, all exports are available on the `eventkit.asyncObservable` global variable. 21 | 22 | ```js 23 | const { AsyncObservable } = eventkit.asyncObservable; 24 | ``` 25 | 26 | ## Related Resources 27 | 28 | - [Observable Pattern](https://hntrl.github.io/eventkit/guide/concepts/observable-pattern) 29 | - [API Reference](https://hntrl.github.io/eventkit/reference/eventkit/AsyncObservable) 30 | - [Changelog](./CHANGELOG.md) 31 | -------------------------------------------------------------------------------- /packages/eventkit/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./operators"; 2 | export * from "./schedulers"; 3 | export * from "./stream"; 4 | 5 | export * from "./utils/errors"; 6 | 7 | export { SingletonAsyncObservable } from "./singleton"; 8 | 9 | export { 10 | // @eventkit/async-observable/from 11 | type AsyncObservableInputType, 12 | getAsyncObservableInputType, 13 | isAsyncObservable, 14 | from, 15 | 16 | // @eventkit/async-observable/observable 17 | AsyncObservable, 18 | 19 | // @eventkit/async-observable/subscriber 20 | Subscriber, 21 | CallbackSubscriber, 22 | kCancelSignal, 23 | ConsumerPromise, 24 | 25 | // @eventkit/async-observable/scheduler 26 | PromiseSet, 27 | ScheduledAction, 28 | CallbackAction, 29 | CleanupAction, 30 | Scheduler, 31 | PassthroughScheduler, 32 | 33 | // @eventkit/async-observable/types 34 | type UnaryFunction, 35 | type OperatorFunction, 36 | type MonoTypeOperatorFunction, 37 | type SubscriberCallback, 38 | type SubscriptionLike, 39 | type SchedulerSubject, 40 | type SchedulerLike, 41 | type AsyncObservableInput, 42 | type InteropAsyncObservable, 43 | type ObservedValueOf, 44 | type ReadableStreamLike, 45 | } from "@eventkit/async-observable"; 46 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "eventkit" 7 | text: "Declarative streams for Typescript" 8 | tagline: Build powerful data streams without the headache 9 | actions: 10 | - theme: brand 11 | text: What is eventkit? 12 | link: /guide/what-is-eventkit 13 | - theme: alt 14 | text: Quickstart 15 | link: /guide/getting-started 16 | - theme: alt 17 | text: GitHub 18 | link: https://github.com/hntrl/eventkit 19 | 20 | features: 21 | - icon: 📦 22 | title: Lightweight & Modular 23 | details: Zero external dependencies and a modular design that lets you only import what you need 24 | - icon: 🌍 25 | title: Declarative 26 | details: Build complex data streams using a simple, declarative API inspired by RxJS and Node.js streams 27 | - icon: ⚡️ 28 | title: Async-First Architecture 29 | details: Built on modern async iterators and generators, making it perfect for handling real-time data and I/O operations 30 | - icon: 🔋 31 | title: Batteries Included 32 | details: Super clean APIs, first-class TypeScript support with full type inference, and all the extras included 33 | --- 34 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/filter.ts: -------------------------------------------------------------------------------- 1 | import { type OperatorFunction } from "@eventkit/async-observable"; 2 | 3 | import { type TruthyTypesOf } from "../utils/types"; 4 | 5 | /** 6 | * Filters items emitted by the source observable by only emitting those that 7 | * satisfy a specified predicate. 8 | * 9 | * @param predicate A function that evaluates each value emitted by the source Observable. 10 | * Returns true to keep the value, false to drop it. 11 | * @group Operators 12 | */ 13 | export function filter( 14 | predicate?: (value: T, index: number) => value is S 15 | ): OperatorFunction; 16 | export function filter(predicate?: (value: T, index: number) => boolean): OperatorFunction; 17 | export function filter(predicate?: BooleanConstructor): OperatorFunction>; 18 | export function filter( 19 | predicate?: ((value: T, index: number) => boolean) | BooleanConstructor 20 | ): OperatorFunction { 21 | return (source) => 22 | new source.AsyncObservable(async function* () { 23 | let index = 0; 24 | for await (const value of source) { 25 | if (typeof predicate === "function" ? predicate(value, index++) : Boolean(value)) { 26 | yield value; 27 | } 28 | } 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/no-response.yml: -------------------------------------------------------------------------------- 1 | name: 🥺 No Response 2 | 3 | on: 4 | schedule: 5 | # Schedule for five minutes after the hour, every week 6 | - cron: "5 0 * * 0" 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | if: github.repository == 'hntrl/eventkit' 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 🥺 Handle Ghosting 18 | uses: actions/stale@v9 19 | with: 20 | days-before-close: 10 21 | close-issue-message: > 22 | This issue has been automatically closed because we haven't received a 23 | response from the original author 🙈. This automation helps keep the issue 24 | tracker clean from issues that aren't actionable. Please reach out if you 25 | have more information for us! 🙂 26 | close-pr-message: > 27 | This PR has been automatically closed because we haven't received a 28 | response from the original author 🙈. This automation helps keep the issue 29 | tracker clean from PRs that aren't actionable. Please reach out if you 30 | have more information for us! 🙂 31 | # don't automatically mark issues/PRs as stale 32 | days-before-stale: -1 33 | stale-issue-label: needs-response 34 | stale-pr-label: needs-response 35 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: 👔 Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | format: 15 | if: github.repository == 'hntrl/eventkit' 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: ⬇️ Checkout repo 20 | uses: actions/checkout@v4 21 | 22 | - name: 📦 Setup pnpm 23 | uses: pnpm/action-setup@v4 24 | 25 | - name: ⎔ Setup node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version-file: ".nvmrc" 29 | cache: pnpm 30 | 31 | - name: 📥 Install deps 32 | run: pnpm install --frozen-lockfile 33 | 34 | - name: 👔 Format 35 | run: pnpm format 36 | 37 | - name: 💪 Commit 38 | run: | 39 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 40 | git config --local user.name "github-actions[bot]" 41 | git add . 42 | if [ -z "$(git status --porcelain)" ]; then 43 | echo "💿 no formatting changed" 44 | exit 0 45 | fi 46 | git commit -m "chore: format" 47 | git push 48 | echo "💿 pushed formatting changes https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" 49 | -------------------------------------------------------------------------------- /.github/workflows/deduplicate-lock-file.yml: -------------------------------------------------------------------------------- 1 | name: ⚙️ Deduplicate lock file 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | paths: 8 | - ./pnpm-lock.yaml 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | format: 16 | if: github.repository == 'hntrl/eventkit' 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: ⬇️ Checkout repo 21 | uses: actions/checkout@v4 22 | 23 | - name: 📦 Setup pnpm 24 | uses: pnpm/action-setup@v4 25 | 26 | - name: ⎔ Setup node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version-file: ".nvmrc" 30 | cache: "pnpm" 31 | 32 | - name: ⚙️ Dedupe lock file 33 | run: pnpm dedupe && rm -rf ./node_modules && pnpm install 34 | 35 | - name: 💪 Commit 36 | run: | 37 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 38 | git config --local user.name "github-actions[bot]" 39 | git add . 40 | if [ -z "$(git status --porcelain)" ]; then 41 | echo "💿 no deduplication needed" 42 | exit 0 43 | fi 44 | git commit -m "chore: deduplicate `pnpm-lock.yaml`" 45 | git push 46 | echo "💿 https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" 47 | -------------------------------------------------------------------------------- /packages/async-observable/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @eventkit/async-observable 2 | 3 | ## 0.3.1 4 | 5 | ### Patch Changes 6 | 7 | - [#10](https://github.com/hntrl/eventkit/pull/10) [`d1d5dca`](https://github.com/hntrl/eventkit/commit/d1d5dcace45730de1feabfbc81216a7fd034b29f) Thanks [@hntrl](https://github.com/hntrl)! - Gave some TLC to the bundling process for each package. Each package bundle now contains sourcemaps for both cjs & esm builds, as well as a new `index.global.js` and `index.global.min.js` that is intended to be used with browser targets. 8 | 9 | ## 0.3.0 10 | 11 | Version bumped to match the version of `@eventkit/base` 12 | 13 | ## 0.2.0 14 | 15 | ### Patch Changes 16 | 17 | - [`fa3aa52`](https://github.com/hntrl/eventkit/commit/fa3aa52410d95dbe79f093f6bd992b800d4768f2) - Fixes an issue where subscribers wouldn't be tracked by the observable when using constructor syntax 18 | 19 | ## 0.1.1 20 | 21 | ### Patch Changes 22 | 23 | - [`35f0ed7`](https://github.com/hntrl/eventkit/commit/35f0ed7feca076852c835defbede22a17210466e) - Fixed an issue where an error would be thrown if multiple eventkit packages were used in the same file 24 | 25 | ## 0.1.0 26 | 27 | ### Minor Changes 28 | 29 | - [`78687a5`](https://github.com/hntrl/eventkit/commit/78687a55a2d53bad9e7011c8ba3ec32625774a89) - v0.1.0 is the first official release of eventkit 🎉! Refer to the [docs](https://hntrl.github.io/eventkit) to get started. 30 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/findIndex.ts: -------------------------------------------------------------------------------- 1 | import { singletonFrom } from "../singleton"; 2 | import { type Falsy, type SingletonOperatorFunction } from "../utils/types"; 3 | 4 | /** 5 | * Emits the index of the first value emitted by the source observable that satisfies a specified 6 | * condition. If no such value is found, emits `-1` when the source observable completes. 7 | * 8 | * @param predicate A function that evaluates each value emitted by the source observable. 9 | * Returns `true` if the value satisfies the condition, `false` otherwise. 10 | * 11 | * @group Operators 12 | */ 13 | export function findIndex( 14 | predicate: BooleanConstructor 15 | ): SingletonOperatorFunction; 16 | export function findIndex( 17 | predicate: (value: T, index: number) => boolean 18 | ): SingletonOperatorFunction; 19 | export function findIndex( 20 | predicate: ((value: T, index: number) => boolean) | BooleanConstructor 21 | ): SingletonOperatorFunction { 22 | return (source) => 23 | singletonFrom( 24 | new source.AsyncObservable(async function* () { 25 | let index = 0; 26 | for await (const value of source) { 27 | if (typeof predicate === "function" ? predicate(value, index) : Boolean(value)) { 28 | yield index; 29 | return; 30 | } 31 | index++; 32 | } 33 | yield -1; 34 | }) 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/every.ts: -------------------------------------------------------------------------------- 1 | import { singletonFrom } from "../singleton"; 2 | import { type SingletonOperatorFunction, type Falsy } from "../utils/types"; 3 | 4 | /** 5 | * Determines whether all items emitted by the source observable satisfy a specified condition. 6 | * Emits `true` if all values pass the condition, or `false` immediately if any value fails. 7 | * 8 | * Note: If any value fails the predicate, the observable will be cancelled. 9 | * 10 | * @param predicate A function that evaluates each value emitted by the source observable. 11 | * Returns `true` if the value satisfies the condition, `false` otherwise. 12 | * 13 | * @group Operators 14 | */ 15 | export function every( 16 | predicate: (value: T, index: number) => boolean 17 | ): SingletonOperatorFunction; 18 | export function every( 19 | predicate: BooleanConstructor 20 | ): SingletonOperatorFunction extends never ? false : boolean>; 21 | export function every( 22 | predicate: (value: T, index: number) => boolean 23 | ): SingletonOperatorFunction { 24 | return (source) => 25 | singletonFrom( 26 | new source.AsyncObservable(async function* () { 27 | let index = 0; 28 | for await (const value of source) { 29 | if (!predicate(value, index++)) { 30 | yield false; 31 | return; 32 | } 33 | } 34 | yield true; 35 | }) 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | function createBanner(packageName, version) { 2 | return `/** 3 | * ${packageName} v${version} 4 | * 5 | * Copyright (c) Hunter Lovell 6 | * 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE.md file in the root directory of this source tree. 9 | * 10 | * @license MIT 11 | */`; 12 | } 13 | 14 | export function getBuildConfig({ packageName, packageVersion, target, options }) { 15 | const banner = createBanner(packageName, packageVersion); 16 | if (target === "neutral") { 17 | return [ 18 | { 19 | format: ["cjs", "esm"], 20 | outDir: "dist", 21 | dts: { resolve: true, banner }, 22 | sourcemap: true, 23 | platform: "neutral", 24 | banner: { js: banner }, 25 | ...options, 26 | }, 27 | ]; 28 | } 29 | if (target === "browser") { 30 | const commonOptions = { 31 | format: ["iife"], 32 | outDir: "dist", 33 | sourcemap: true, 34 | dts: false, 35 | platform: "browser", 36 | banner: { js: banner }, 37 | }; 38 | return [ 39 | { 40 | ...commonOptions, 41 | minify: false, 42 | ...options, 43 | }, 44 | { 45 | ...commonOptions, 46 | minify: true, 47 | outExtension() { 48 | return { 49 | js: `.global.min.js`, 50 | }; 51 | }, 52 | ...options, 53 | }, 54 | ]; 55 | } 56 | return []; 57 | } 58 | -------------------------------------------------------------------------------- /examples/workers-chat-demo/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject as DurableObjectPrimitive } from "cloudflare:workers"; 2 | 3 | export const iife = (fn: () => T): T => fn(); 4 | 5 | export function DurableObject(accessor: keyof TEnv) { 6 | return class DurableObject extends DurableObjectPrimitive { 7 | /** 8 | * Retrieves a durable object stub for a given id. 9 | * 10 | * @template T - The type of the DurableObject. 11 | * @param {TEnv} env - The env object containing the durable object's namespace. 12 | * @param {DurableObjectId | string} [id] - The id of the DurableObject. If not provided, `newUniqueId()` will be used. 13 | * @returns {DurableObjectStub} The DurableObjectStub for the given id. 14 | */ 15 | static getStub( 16 | this: { new (...args: any[]): T }, 17 | env: TEnv, 18 | id?: DurableObjectId | string 19 | ): DurableObjectStub { 20 | const namespace = env[accessor] as DurableObjectNamespace; 21 | const stubId = iife(() => { 22 | if (!id) return namespace.newUniqueId(); 23 | if (typeof id === "string") { 24 | // Check if the provided id is a string representation of the 25 | // 256-bit Durable Object ID 26 | if (id.match(/^[0-9a-f]{64}$/)) return namespace.idFromString(id); 27 | else return namespace.idFromName(id); 28 | } 29 | return id; 30 | }); 31 | const stub = namespace.get(stubId); 32 | return stub; 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/find.ts: -------------------------------------------------------------------------------- 1 | import { singletonFrom } from "../singleton"; 2 | import { type TruthyTypesOf, type SingletonOperatorFunction } from "../utils/types"; 3 | 4 | /** 5 | * Emits the first value emitted by the source observable that satisfies a specified condition. 6 | * If no such value is found, emits `undefined` when the source observable completes. 7 | * 8 | * @param predicate A function that evaluates each value emitted by the source observable. 9 | * Returns `true` if the value satisfies the condition, `false` otherwise. 10 | * 11 | * @group Operators 12 | */ 13 | export function find( 14 | predicate: (value: T, index: number) => value is S 15 | ): SingletonOperatorFunction; 16 | export function find( 17 | predicate: (value: T, index: number) => boolean 18 | ): SingletonOperatorFunction; 19 | export function find( 20 | predicate: BooleanConstructor 21 | ): SingletonOperatorFunction>; 22 | export function find( 23 | predicate: ((value: T, index: number) => boolean) | BooleanConstructor 24 | ): SingletonOperatorFunction { 25 | return (source) => 26 | singletonFrom( 27 | new source.AsyncObservable(async function* () { 28 | let index = 0; 29 | for await (const value of source) { 30 | if (typeof predicate === "function" ? predicate(value, index++) : Boolean(value)) { 31 | yield value; 32 | return; 33 | } 34 | } 35 | yield undefined; 36 | }) 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Report an issue that should be fixed 3 | labels: [triage] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to file a bug report! Please fill out this form as completely as possible. 10 | 11 | - type: input 12 | attributes: 13 | label: What version of eventkit are you using? 14 | placeholder: 0.0.0 15 | validations: 16 | required: true 17 | - type: input 18 | attributes: 19 | label: What runtime are you using with eventkit? 20 | placeholder: Node.JS v22, Bun 1.2.8, etc. 21 | validations: 22 | required: true 23 | - type: input 24 | attributes: 25 | label: Please provide a link to a minimal reproduction 26 | placeholder: https://github.com/foobar-user/minimal-repro 27 | validations: 28 | required: false 29 | - type: textarea 30 | attributes: 31 | label: Describe the Bug 32 | description: Please describe the bug in as much detail as possible, including steps to reproduce, the behaviour you're seeing, and the behaviour you expect. 33 | validations: 34 | required: true 35 | - type: textarea 36 | attributes: 37 | label: Please provide any relevant error logs 38 | description: Although not required, we often request logs to help troubleshoot the issue, so providing this up-front streamlines the process towards resolution. Please be careful to hide any sensitive information. 39 | validations: 40 | required: false 41 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/elementAt.ts: -------------------------------------------------------------------------------- 1 | import { singletonFrom } from "../singleton"; 2 | import { ArgumentOutOfRangeError } from "../utils/errors"; 3 | import { type SingletonOperatorFunction } from "../utils/types"; 4 | 5 | /** 6 | * Emits the single value at the specified `index` in the source observable, or a default value 7 | * provided in the `defaultValue` argument and if the index is out of range. If the index is out of 8 | * range and no default is given, an ArgumentOutOfRangeError is thrown. 9 | * 10 | * @throws {ArgumentOutOfRangeError} When using `elementAt(i)`, it throws an `ArgumentOutOfRangeError` 11 | * if `i < 0` or the observable has completed before yielding the i-th value. 12 | * 13 | * @param index Is the number `i` for the i-th source emission that has happened 14 | * since the subscription, starting from the number `0`. 15 | * @param defaultValue The default value returned for missing indices. 16 | * @group Operators 17 | */ 18 | export function elementAt( 19 | index: number, 20 | defaultValue?: D 21 | ): SingletonOperatorFunction { 22 | if (index < 0) { 23 | throw new ArgumentOutOfRangeError(); 24 | } 25 | return (source) => 26 | singletonFrom( 27 | new source.AsyncObservable(async function* () { 28 | let i = 0; 29 | for await (const value of source) { 30 | if (i++ === index) { 31 | yield value; 32 | return; 33 | } 34 | } 35 | if (defaultValue) yield defaultValue; 36 | else throw new ArgumentOutOfRangeError(); 37 | }) 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /examples/http-streaming/src/server.ts: -------------------------------------------------------------------------------- 1 | import { AsyncObservable, Stream } from "@eventkit/base"; 2 | import { serve } from "@hono/node-server"; 3 | import { serveStatic } from "@hono/node-server/serve-static"; 4 | import { Hono } from "hono"; 5 | 6 | const app = new Hono(); 7 | 8 | app.get("/", serveStatic({ path: "./src/index.html" })); 9 | 10 | app.get("/eventkit.js", serveStatic({ path: "./node_modules/eventkit/dist/index.mjs" })); 11 | 12 | app.get("/observable-stream", () => { 13 | const obs = new AsyncObservable(async function* () { 14 | const messages = [ 15 | "Starting the stream...", 16 | "This is a slow stream", 17 | "Each message takes time", 18 | "To propagate through", 19 | "The network", 20 | "Like a river", 21 | "Flowing downstream", 22 | "One message at a time", 23 | "Almost there...", 24 | "Done!", 25 | ]; 26 | 27 | for (const msg of messages) { 28 | yield msg + "\n"; 29 | await new Promise((resolve) => setTimeout(resolve, 1000)); // 1 second delay between messages 30 | } 31 | }); 32 | 33 | return new Response(ReadableStream.from(obs)); 34 | }); 35 | 36 | const stream = new Stream(); 37 | 38 | app.get("/shared-stream", () => { 39 | return new Response(ReadableStream.from(stream)); 40 | }); 41 | 42 | app.post("/shared-stream", async (c) => { 43 | const body = await c.req.text(); 44 | stream.push(body); 45 | return c.body(null, 204); 46 | }); 47 | 48 | serve( 49 | { 50 | fetch: app.fetch, 51 | port: 3000, 52 | }, 53 | (info) => { 54 | console.log(`Server is running on http://localhost:${info.port}`); 55 | } 56 | ); 57 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | tags-ignore: 9 | - v* 10 | paths-ignore: 11 | - "docs/**" 12 | - "**/README.md" 13 | pull_request: 14 | paths-ignore: 15 | - "docs/**" 16 | - "**/*.md" 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.ref }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | test: 24 | name: "🧪 Test: (Node: ${{ matrix.node }})" 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | node: 29 | - 20 30 | - 22 31 | 32 | runs-on: ubuntu-latest 33 | 34 | steps: 35 | - name: ⬇️ Checkout repo 36 | uses: actions/checkout@v4 37 | 38 | - name: 📦 Setup pnpm 39 | uses: pnpm/action-setup@v4 40 | 41 | - name: ⎔ Setup node 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: ${{ matrix.node }} 45 | cache: pnpm 46 | check-latest: true 47 | 48 | # TODO: Track and renable once this has been fixed: https://github.com/google/wireit/issues/1297 49 | # - uses: google/wireit@setup-github-actions-caching/v2 50 | 51 | - name: Disable GitHub Actions Annotations 52 | run: | 53 | echo "::remove-matcher owner=tsc::" 54 | echo "::remove-matcher owner=eslint-compact::" 55 | echo "::remove-matcher owner=eslint-stylish::" 56 | 57 | - name: 📥 Install deps 58 | run: pnpm install --frozen-lockfile 59 | 60 | - name: 🏗 Build 61 | run: pnpm build 62 | 63 | - name: 🔍 Typecheck 64 | run: pnpm typecheck 65 | 66 | - name: 🔬 Lint 67 | run: pnpm lint 68 | 69 | - name: 🧪 Run tests 70 | run: pnpm test 71 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/partition.ts: -------------------------------------------------------------------------------- 1 | import { type AsyncObservable, type UnaryFunction } from "@eventkit/async-observable"; 2 | 3 | import { not } from "../utils/operators"; 4 | import { type TruthyTypesOf } from "../utils/types"; 5 | import { filter } from "./filter"; 6 | 7 | type PartitionOperatorFunction> = UnaryFunction< 8 | AsyncObservable, 9 | [AsyncObservable, AsyncObservable] 10 | >; 11 | 12 | /** 13 | * Returns an array with two observables that act as a split of the source observable; one with 14 | * values that satisfy the predicate, and another with values that don't satisfy the predicate. 15 | * 16 | * @param predicate A function that evaluates each value emitted by the source observable. If it 17 | * returns `true`, the value is emitted on the first observable in the returned array, if `false` 18 | * the value is emitted on the second observable in the array. The `index` parameter is the number 19 | * `i` for the i-th source emission that has happened since the subscription, starting from 0. 20 | * @group Operators 21 | */ 22 | export function partition( 23 | predicate: (value: T, index: number) => value is S 24 | ): PartitionOperatorFunction; 25 | export function partition( 26 | predicate: (value: T, index: number) => boolean 27 | ): PartitionOperatorFunction; 28 | export function partition( 29 | predicate: BooleanConstructor 30 | ): PartitionOperatorFunction>; 31 | export function partition( 32 | predicate: ((value: T, index: number) => boolean) | BooleanConstructor 33 | ): PartitionOperatorFunction { 34 | return (source: AsyncObservable) => [ 35 | source.pipe(filter(predicate)) as AsyncObservable, 36 | source.pipe(filter(not(predicate))) as AsyncObservable, 37 | ]; 38 | } 39 | -------------------------------------------------------------------------------- /packages/eventkit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eventkit/base", 3 | "version": "0.3.1", 4 | "license": "MIT", 5 | "author": "Hunter Lovell ", 6 | "description": "Declarative event stream processing library", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/hntrl/eventkit", 10 | "directory": "packages/eventkit" 11 | }, 12 | "keywords": [ 13 | "event", 14 | "stream", 15 | "reactive", 16 | "streaming", 17 | "readable", 18 | "pipeline" 19 | ], 20 | "scripts": { 21 | "build": "wireit" 22 | }, 23 | "wireit": { 24 | "build": { 25 | "command": "tsup", 26 | "files": [ 27 | "lib/**", 28 | "tsup.config.ts", 29 | "tsconfig.json", 30 | "package.json" 31 | ], 32 | "output": [ 33 | "dist/**" 34 | ] 35 | } 36 | }, 37 | "main": "./dist/index.js", 38 | "types": "./dist/index.d.ts", 39 | "module": "./dist/index.mjs", 40 | "exports": { 41 | ".": { 42 | "node": { 43 | "types": "./dist/index.d.ts", 44 | "module-sync": "./dist/index.mjs", 45 | "default": "./dist/index.js" 46 | }, 47 | "import": { 48 | "types": "./dist/index.d.mts", 49 | "default": "./dist/index.mjs" 50 | }, 51 | "default": { 52 | "types": "./dist/index.d.ts", 53 | "default": "./dist/index.js" 54 | } 55 | }, 56 | "./package.json": "./package.json" 57 | }, 58 | "files": [ 59 | "dist/", 60 | "CHANGELOG.md", 61 | "LICENSE.md", 62 | "NOTICE.md", 63 | "README.md" 64 | ], 65 | "devDependencies": { 66 | "@eventkit/async-observable": "workspace:*", 67 | "tsup": "^8.3.6", 68 | "typescript": "^5.7.3", 69 | "wireit": "^0.14.11" 70 | }, 71 | "engines": { 72 | "node": ">=20.0.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: 📨 Deploy Docs 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "docs/**" 8 | - "packages/**" 9 | - "**/README.md" 10 | 11 | # Allows this workflow to be manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 15 | permissions: 16 | contents: read 17 | pages: write 18 | id-token: write 19 | 20 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 21 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 22 | concurrency: 23 | group: pages 24 | cancel-in-progress: false 25 | 26 | jobs: 27 | # Build job 28 | build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 35 | - uses: pnpm/action-setup@v3 36 | - name: Setup Node 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: 20 40 | cache: pnpm 41 | - name: Setup Pages 42 | uses: actions/configure-pages@v4 43 | - name: Install dependencies 44 | run: pnpm install 45 | - name: Build with VitePress 46 | run: pnpm docs:build 47 | - name: Upload artifact 48 | uses: actions/upload-pages-artifact@v3 49 | with: 50 | path: docs/.vitepress/dist 51 | 52 | # Deployment job 53 | deploy: 54 | environment: 55 | name: github-pages 56 | url: ${{ steps.deployment.outputs.page_url }} 57 | needs: build 58 | runs-on: ubuntu-latest 59 | name: Deploy 60 | steps: 61 | - name: Deploy to GitHub Pages 62 | id: deployment 63 | uses: actions/deploy-pages@v4 64 | -------------------------------------------------------------------------------- /packages/async-observable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eventkit/async-observable", 3 | "version": "0.3.1", 4 | "license": "MIT", 5 | "author": "Hunter Lovell ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/hntrl/eventkit", 9 | "directory": "packages/async-observable" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/hntrl/eventkit/issues" 13 | }, 14 | "keywords": [ 15 | "event", 16 | "streams", 17 | "streaming", 18 | "reactive", 19 | "readable", 20 | "pipeline", 21 | "Observable", 22 | "AsyncObservable" 23 | ], 24 | "scripts": { 25 | "build": "wireit" 26 | }, 27 | "wireit": { 28 | "build": { 29 | "command": "tsup", 30 | "files": [ 31 | "lib/**", 32 | "tsup.config.ts", 33 | "tsconfig.json", 34 | "package.json" 35 | ], 36 | "output": [ 37 | "dist/**" 38 | ] 39 | } 40 | }, 41 | "main": "./dist/index.js", 42 | "types": "./dist/index.d.ts", 43 | "module": "./dist/index.mjs", 44 | "exports": { 45 | ".": { 46 | "node": { 47 | "types": "./dist/index.d.ts", 48 | "module-sync": "./dist/index.mjs", 49 | "default": "./dist/index.js" 50 | }, 51 | "import": { 52 | "types": "./dist/index.d.mts", 53 | "default": "./dist/index.mjs" 54 | }, 55 | "default": { 56 | "types": "./dist/index.d.ts", 57 | "default": "./dist/index.js" 58 | } 59 | }, 60 | "./package.json": "./package.json" 61 | }, 62 | "files": [ 63 | "dist/", 64 | "CHANGELOG.md", 65 | "LICENSE.md", 66 | "NOTICE.md", 67 | "README.md" 68 | ], 69 | "devDependencies": { 70 | "tsup": "^8.3.6", 71 | "typescript": "^5.7.3", 72 | "wireit": "^0.14.11" 73 | }, 74 | "engines": { 75 | "node": ">=20.0.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/eventkit-http/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@eventkit/http", 3 | "version": "0.3.1", 4 | "license": "MIT", 5 | "author": "Hunter Lovell ", 6 | "description": "HTTP helpers for eventkit", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/hntrl/eventkit", 10 | "directory": "packages/eventkit-http" 11 | }, 12 | "keywords": [ 13 | "eventkit", 14 | "http", 15 | "fetch", 16 | "sse", 17 | "eventsource", 18 | "observable", 19 | "streaming" 20 | ], 21 | "files": [ 22 | "dist/", 23 | "CHANGELOG.md", 24 | "LICENSE.md", 25 | "README.md" 26 | ], 27 | "scripts": { 28 | "build": "wireit", 29 | "typecheck": "tsc" 30 | }, 31 | "wireit": { 32 | "build": { 33 | "command": "tsup", 34 | "files": [ 35 | "lib/**", 36 | "tsup.config.ts", 37 | "tsconfig.json", 38 | "package.json" 39 | ], 40 | "output": [ 41 | "dist/**" 42 | ] 43 | } 44 | }, 45 | "main": "dist/index.js", 46 | "typings": "dist/index.d.ts", 47 | "exports": { 48 | ".": { 49 | "import": { 50 | "types": "./dist/index.d.mts", 51 | "default": "./dist/index.mjs" 52 | }, 53 | "default": { 54 | "types": "./dist/index.d.ts", 55 | "default": "./dist/index.js" 56 | } 57 | }, 58 | "./package.json": "./package.json" 59 | }, 60 | "dependencies": { 61 | "undici-types": "^7.5.0" 62 | }, 63 | "devDependencies": { 64 | "@types/node": "^22.13.14", 65 | "@types/ws": "^8.18.1", 66 | "eventsource": "^3.0.6", 67 | "tsup": "^8.3.6", 68 | "typescript": "^5.7.3", 69 | "wireit": "^0.14.11", 70 | "ws": "^8.18.1" 71 | }, 72 | "peerDependencies": { 73 | "@eventkit/base": "workspace:*" 74 | }, 75 | "peerDependenciesMeta": { 76 | "@eventkit/base": { 77 | "optional": true 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import importPlugin from "eslint-plugin-import"; 3 | import importHelpersPlugin from "eslint-plugin-import-helpers"; 4 | import commentLengthPlugin from "eslint-plugin-comment-length"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { 9 | ignores: [ 10 | "docs/**", 11 | "**/node_modules/", 12 | "**/pnpm-lock.yaml", 13 | "__tests__/**", 14 | "dist/**", 15 | ".wireit/**", 16 | "scripts/**", 17 | "worker-configuration.d.ts", 18 | "eslint.config.mjs", 19 | "vitest.config.ts", 20 | "prettier.config.js", 21 | ], 22 | }, 23 | eslint.configs.recommended, 24 | ...tseslint.configs.recommended, 25 | importPlugin.flatConfigs.recommended, 26 | { 27 | languageOptions: { 28 | parserOptions: { 29 | projectService: true, 30 | tsconfigRootDir: import.meta.dirname, 31 | }, 32 | }, 33 | plugins: { 34 | "import-helpers": importHelpersPlugin, 35 | "comment-length": commentLengthPlugin, 36 | }, 37 | rules: { 38 | "@typescript-eslint/consistent-type-imports": ["error", { fixStyle: "inline-type-imports" }], 39 | "@typescript-eslint/consistent-type-exports": [ 40 | "error", 41 | { fixMixedExportsWithInlineTypeSpecifier: true }, 42 | ], 43 | "import/no-unresolved": "off", 44 | "import-helpers/order-imports": [ 45 | "warn", 46 | { 47 | newlinesBetween: "always", 48 | groups: ["module", ["parent", "sibling", "index"]], 49 | 50 | alphabetize: { 51 | order: "asc", 52 | ignoreCase: true, 53 | }, 54 | }, 55 | ], 56 | "comment-length/limit-single-line-comments": ["warn", { maxLength: 100 }], 57 | "comment-length/limit-multi-line-comments": ["warn", { maxLength: 100 }], 58 | }, 59 | } 60 | ); 61 | -------------------------------------------------------------------------------- /packages/eventkit/README.md: -------------------------------------------------------------------------------- 1 | `@eventkit/base` is the primary package in the eventkit project. 2 | 3 | ## Installation 4 | 5 | ```sh 6 | npm i @eventkit/base 7 | ``` 8 | 9 | ### Using a CDN 10 | 11 | This package also bundles a browser-friendly version that can be accessed using a CDN like [unpkg](https://unpkg.com/). 12 | 13 | ```html 14 | 15 | 16 | 17 | 18 | ``` 19 | 20 | When imported this way, all exports are available on the `eventkit` global variable. 21 | 22 | ```js 23 | const { Stream, filter } = eventkit; 24 | ``` 25 | 26 | ## Basic Example 27 | 28 | This is a basic example of how to use an eventkit stream. To get started, you should check out the [Getting Started](https://hntrl.github.io/eventkit/guide/getting-started) guide. 29 | 30 | ```typescript 31 | import { Stream, filter } from "@eventkit/base"; 32 | 33 | // Create a stream of events 34 | const stream = new Stream<{ type: string; payload: any }>(); 35 | 36 | // Filter for specific event types 37 | const userEvents = stream.pipe(filter((event) => event.type.startsWith("user."))); 38 | 39 | // Subscribe to the filtered stream 40 | userEvents.subscribe((event) => { 41 | console.log(`Received user event: ${event.type}`); 42 | }); 43 | 44 | // Push events to the stream 45 | stream.push({ type: "user.login", payload: { userId: "123" } }); 46 | stream.push({ type: "system.update", payload: { version: "1.0.1" } }); // This won't be logged 47 | 48 | // Wait for all events to be processed 49 | await stream.drain(); 50 | ``` 51 | 52 | ## Related Resources 53 | 54 | - [eventkit](https://github.com/hntrl/eventkit) 55 | - [Getting Started](https://hntrl.github.io/eventkit/guide/getting-started) 56 | - [API Reference](https://hntrl.github.io/eventkit/reference/_eventkit/base) 57 | - [Changelog](./CHANGELOG.md) 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eventkit", 3 | "private": true, 4 | "scripts": { 5 | "build": "pnpm run --filter=\"./packages/[^_]**\" build", 6 | "watch": "pnpm build && pnpm run --filter=\"./packages/[^_]**\" --parallel build --watch", 7 | "clean": "git clean -fdX .", 8 | "clean:build": "git clean -fdx -e node_modules .", 9 | "format": "prettier --write .", 10 | "format:check": "prettier --check .", 11 | "lint": "eslint --flag unstable_config_lookup_from_file --cache .", 12 | "prerelease": "pnpm build", 13 | "release": "changeset publish", 14 | "test": "vitest", 15 | "test:inspect": "node --inspect-brk node_modules/.bin/vitest", 16 | "typecheck": "pnpm run --recursive --parallel typecheck", 17 | "changeset": "changeset", 18 | "changeset:version": "changeset version", 19 | "docs:types": "node scripts/typedoc.js", 20 | "docs:dev": "trap 'kill $(jobs -p)' EXIT; pnpm docs:types --watch & vitepress dev docs", 21 | "docs:build": "pnpm docs:types && vitepress build docs", 22 | "docs:preview": "pnpm docs:types && vitepress preview docs" 23 | }, 24 | "packageManager": "pnpm@10.4.0", 25 | "dependencies": { 26 | "@changesets/changelog-github": "^0.5.1", 27 | "@changesets/cli": "^2.28.1", 28 | "@eslint/js": "^9.22.0", 29 | "@types/semver": "^7.5.8", 30 | "eslint": "^9.20.1", 31 | "eslint-plugin-comment-length": "^2.1.1", 32 | "eslint-plugin-import": "^2.31.0", 33 | "eslint-plugin-import-helpers": "^2.0.1", 34 | "globals": "^16.0.0", 35 | "msw": "^2.7.3", 36 | "prettier": "^3.5.1", 37 | "tsup": "^8.3.6", 38 | "typedoc": "^0.28.1", 39 | "typedoc-plugin-frontmatter": "^1.3.0", 40 | "typedoc-plugin-markdown": "^4.6.0", 41 | "typedoc-vitepress-theme": "^1.1.2", 42 | "typescript": "^5.7.3", 43 | "typescript-eslint": "^8.26.0", 44 | "vitepress": "^1.6.3", 45 | "vitest": "^3.0.6" 46 | }, 47 | "engines": { 48 | "node": ">=20.0.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 🦋 Changesets Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | release: 13 | name: 🦋 Changesets Release 14 | if: github.repository == 'hntrl/eventkit' 15 | runs-on: ubuntu-latest 16 | outputs: 17 | published_packages: ${{ steps.changesets.outputs.publishedPackages }} 18 | published: ${{ steps.changesets.outputs.published }} 19 | steps: 20 | - name: ⬇️ Checkout repo 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: 📦 Setup pnpm 26 | uses: pnpm/action-setup@v4 27 | 28 | - name: ⎔ Setup node 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version-file: ".nvmrc" 32 | cache: "pnpm" 33 | 34 | - name: 📥 Install deps 35 | run: pnpm install --frozen-lockfile 36 | 37 | - name: 🔐 Setup npm auth 38 | run: | 39 | echo "registry=https://registry.npmjs.org" >> ~/.npmrc 40 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc 41 | 42 | # This action has two responsibilities. The first time the workflow runs 43 | # it will create a new branch and then open a PR with the related changes 44 | # for the new version. After the PR is merged, the workflow will run again 45 | # and this action will build + publish to npm. 46 | - name: 🚀 PR / Publish 47 | id: changesets 48 | uses: changesets/action@v1 49 | with: 50 | version: pnpm run changeset:version 51 | commit: "chore: Update version for release" 52 | title: "chore: Update version for release" 53 | publish: pnpm run release 54 | createGithubReleases: false 55 | env: 56 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /scripts/typedoc.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require("node:child_process"); 2 | const { watch } = require("node:fs"); 3 | 4 | async function typedoc() { 5 | console.log("typedoc: rebuilding..."); 6 | try { 7 | execSync("pnpm build", { stdio: "inherit" }); 8 | } catch (error) { 9 | return false; 10 | } 11 | try { 12 | execSync("pnpm typedoc", { stdio: "inherit" }); 13 | } catch (error) { 14 | return false; 15 | } 16 | return true; 17 | } 18 | 19 | async function watchAndRun() { 20 | const success = await typedoc(); 21 | if (!success) { 22 | console.error("typedoc: failed to generate documentation"); 23 | process.exit(1); 24 | } 25 | 26 | let watcher = null; 27 | let isShuttingDown = false; 28 | let typedocPromise = null; 29 | let shouldRerun = false; 30 | 31 | const cleanup = () => { 32 | if (isShuttingDown) return; 33 | isShuttingDown = true; 34 | 35 | if (watcher) { 36 | console.log("typedoc: closing file watcher..."); 37 | watcher.close(); 38 | } 39 | 40 | console.log("typedoc: process terminated"); 41 | process.exit(0); 42 | }; 43 | 44 | // Handle various termination signals 45 | process.on("SIGINT", cleanup); 46 | process.on("SIGTERM", cleanup); 47 | process.on("exit", cleanup); 48 | 49 | watcher = watch("./", { recursive: true }, async (eventType, filename) => { 50 | const isSourceChange = 51 | filename.includes("/lib/") && !filename.includes("dist") && !filename.includes(".wireit"); 52 | if (filename && filename[0] !== "_" && (isSourceChange || filename.includes("typedoc.json"))) { 53 | if (typedocPromise) shouldRerun = true; 54 | else { 55 | typedocPromise = typedoc().then(async () => { 56 | if (shouldRerun) { 57 | await typedoc(); 58 | shouldRerun = false; 59 | } 60 | typedocPromise = null; 61 | }); 62 | } 63 | } 64 | }); 65 | console.log("typedoc: listening for changes in packages/*"); 66 | } 67 | 68 | // If this script is run directly, run typedoc or watch based on flags 69 | if (require.main === module) { 70 | const watchFlag = process.argv.includes("--watch"); 71 | (watchFlag ? watchAndRun() : typedoc()).catch((error) => { 72 | console.error("Error:", error); 73 | process.exit(1); 74 | }); 75 | } 76 | 77 | module.exports = { 78 | typedoc, 79 | watchAndRun, 80 | }; 81 | -------------------------------------------------------------------------------- /packages/async-observable/lib/utils/signal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A class that wraps a Promise with explicit resolve and reject methods. 3 | * 4 | * Signal implements the PromiseLike interface, allowing it to be used in async/await 5 | * contexts and with Promise chaining. Unlike a regular Promise, Signal exposes the 6 | * resolve and reject methods as instance methods, making it easier to control when 7 | * and how the underlying promise resolves or rejects. 8 | * 9 | * This class is useful for creating deferred promises where the resolution is controlled 10 | * externally from the promise creation. 11 | * 12 | * @template T The type of value that the wrapped Promise resolves to, defaults to void 13 | * @internal 14 | */ 15 | export class Signal implements PromiseLike { 16 | /** @internal */ 17 | _status: "pending" | "resolved" | "rejected" = "pending"; 18 | /** @internal */ 19 | _promise: Promise; 20 | 21 | /** Creates a new Signal instance with resolve and reject methods bound to the instance. */ 22 | constructor() { 23 | this._promise = new Promise((resolve, reject) => { 24 | this.resolve = (value: T) => { 25 | this._status = "resolved"; 26 | resolve(value); 27 | }; 28 | this.reject = (reason: any) => { 29 | this._status = "rejected"; 30 | reject(reason); 31 | }; 32 | }); 33 | } 34 | 35 | /** Resolves the underlying promise with the given value. */ 36 | resolve(value: T | PromiseLike) { 37 | this._status = "resolved"; 38 | this._promise = Promise.resolve(value); 39 | } 40 | 41 | /** Rejects the underlying promise with the given reason. */ 42 | reject(reason?: any) { 43 | this._status = "rejected"; 44 | this._promise = Promise.reject(reason); 45 | } 46 | 47 | /** 48 | * Attaches callbacks for the resolution and/or rejection of the Promise. 49 | * 50 | * @param onfulfilled The callback to execute when the Promise is resolved 51 | * @param onrejected The callback to execute when the Promise is rejected 52 | * @returns A Promise for the completion of which ever callback is executed 53 | */ 54 | then( 55 | onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, 56 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null 57 | ): Promise { 58 | return this._promise.then(onfulfilled, onrejected); 59 | } 60 | 61 | /** Returns the underlying promise. */ 62 | asPromise(): Promise { 63 | return this._promise; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /examples/workers-chat-demo/README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare + Eventkit Edge Chat Demo 2 | 3 | This is a demo app written on [Cloudflare Workers](https://workers.cloudflare.com/) utilizing [Durable Objects](https://blog.cloudflare.com/introducing-workers-durable-objects) and [Eventkit](https://github.com/eventkit/eventkit) to implement real-time chat with stored history. This app runs 100% on Cloudflare's edge. 4 | 5 | This demo is based off of the [Edge Chat Demo](https://github.com/cloudflare/workers-chat-demo/tree/master) provided by Cloudflare, but uses eventkit in place of WebSockets to demonstrate its capabilities. 6 | 7 | ## How does it work? 8 | 9 | This chat app uses a Durable Object to control each chat room. Users can listen to new messages pushed to the room by listening to its event stream using EventSource. Whereas the chat demo this is based off of uses a WebSocket connection to facilitate the chat, this app uses [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) and a good ol' POST endpoint to handle the interactions. 10 | 11 | The chat history is also stored in durable storage, but this is only for history. Real-time messages are relayed directly from one user to others without going through the storage layer. 12 | 13 | For more details, take a look at the code! It is well-commented. 14 | 15 | ## Learn More 16 | 17 | - [HTTP Streaming in Eventkit](https://hntrl.github.io/eventkit/guide/examples/http-streaming) 18 | - [Durable Objects](https://developers.cloudflare.com/durable-objects/) 19 | 20 | ## Running locally 21 | 22 | You can run the demo locally using the following command: 23 | 24 | ```bash 25 | pnpm wrangler:dev 26 | ``` 27 | 28 | This will start the worker locally and you can interact with it by opening the dev server link in your browser. 29 | 30 | ## Deploy it yourself 31 | 32 | If you haven't already, enable Durable Objects by visiting the [Cloudflare dashboard](https://dash.cloudflare.com/) and navigating to "Workers" and then "Durable Objects". 33 | 34 | Then, make sure you have [Wrangler](https://developers.cloudflare.com/workers/cli-wrangler/install-update), the official Workers CLI, installed. 35 | 36 | After installing it, run `wrangler login` to [connect it to your Cloudflare account](https://developers.cloudflare.com/workers/cli-wrangler/authentication). 37 | 38 | Once you've enabled Durable Objects on your account and have Wrangler installed and authenticated, you can deploy the app for the first time by running: 39 | 40 | pnpm wrangler:deploy 41 | 42 | If you get an error saying "Cannot create binding for class [...] because it is not currently configured to implement durable objects", you need to update your version of Wrangler. 43 | 44 | This command will deploy the app to your account under the name `eventkit-edge-chat-demo`. -------------------------------------------------------------------------------- /packages/eventkit-http/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @eventkit/http 2 | 3 | ## 0.3.1 4 | 5 | ### Patch Changes 6 | 7 | - [#9](https://github.com/hntrl/eventkit/pull/9) [`107dfd4`](https://github.com/hntrl/eventkit/commit/107dfd470a195c854d96436ba5c5d81236cb8898) Thanks [@hntrl](https://github.com/hntrl)! - Makes the `@eventkit/base` peer dep optional which silences errors in package managers if it's not being used. 8 | 9 | - [#10](https://github.com/hntrl/eventkit/pull/10) [`d1d5dca`](https://github.com/hntrl/eventkit/commit/d1d5dcace45730de1feabfbc81216a7fd034b29f) Thanks [@hntrl](https://github.com/hntrl)! - Gave some TLC to the bundling process for each package. Each package bundle now contains sourcemaps for both cjs & esm builds, as well as a new `index.global.js` and `index.global.min.js` that is intended to be used with browser targets. 10 | 11 | - Updated dependencies [[`3ea1105`](https://github.com/hntrl/eventkit/commit/3ea1105c73b96a5e26aa80f0795b6dbf55941fef), [`d1d5dca`](https://github.com/hntrl/eventkit/commit/d1d5dcace45730de1feabfbc81216a7fd034b29f)]: 12 | - @eventkit/base@0.3.1 13 | 14 | ## 0.3.0 15 | 16 | ### Patch Changes 17 | 18 | - Updated dependencies [[`03fb5d1`](https://github.com/hntrl/eventkit/commit/03fb5d13a3370d5164cf81527710c25c4e67e3e5), [`7b6dbb1`](https://github.com/hntrl/eventkit/commit/7b6dbb1a1d96478fcc25c8325648c31d08e78467)]: 19 | - @eventkit/base@0.3.0 20 | 21 | ## 0.2.0 22 | 23 | ### Patch Changes 24 | 25 | - Updated dependencies [[`1371b77`](https://github.com/hntrl/eventkit/commit/1371b774b5409b5aa45e56fb215b27ab7233bd9b)]: 26 | - @eventkit/base@0.2.0 27 | 28 | ## 0.1.1 29 | 30 | ### Patch Changes 31 | 32 | - [`0140fce`](https://github.com/hntrl/eventkit/commit/0140fce4ffeb8d880865a5363296f3e966b5d4a6) - Make the `init` arg in EventSourceResponse optional 33 | 34 | - [`b3854b5`](https://github.com/hntrl/eventkit/commit/b3854b5b5603d080fbd1503e5e279a9436a8791d) - Fixed an issue where the bundled version of @eventkit/http used its own imports of eventkit primitives 35 | 36 | - Updated dependencies [[`a84a6cd`](https://github.com/hntrl/eventkit/commit/a84a6cdbf8f9ed93bfcc97d239e0c0b5376038d1), [`35f0ed7`](https://github.com/hntrl/eventkit/commit/35f0ed7feca076852c835defbede22a17210466e), [`2c27d80`](https://github.com/hntrl/eventkit/commit/2c27d8064695e5d33039843826b147b09d6b9750)]: 37 | - @eventkit/base@0.1.1 38 | 39 | ## 0.1.0 40 | 41 | ### Minor Changes 42 | 43 | - [`78687a5`](https://github.com/hntrl/eventkit/commit/78687a55a2d53bad9e7011c8ba3ec32625774a89) - v0.1.0 is the first official release of eventkit 🎉! Refer to the [docs](https://hntrl.github.io/eventkit) to get started. 44 | 45 | ### Patch Changes 46 | 47 | - Updated dependencies [[`78687a5`](https://github.com/hntrl/eventkit/commit/78687a55a2d53bad9e7011c8ba3ec32625774a89)]: 48 | - eventkit@0.1.0 49 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/first.ts: -------------------------------------------------------------------------------- 1 | import { singletonFrom } from "../singleton"; 2 | import { NoValuesError } from "../utils/errors"; 3 | import { iife } from "../utils/operators"; 4 | import { type TruthyTypesOf, type SingletonOperatorFunction } from "../utils/types"; 5 | 6 | /** 7 | * Emits the first value emitted by the source observable that satisfies a specified condition. If 8 | * no such value is found when the source observable completes, the `defaultValue` is emitted if 9 | * it's provided. If it isn't, a NoValuesError is thrown. 10 | * 11 | * @throws {NoValuesError} Will throw a `NoValuesError` if no value is found and no default value is provided. 12 | * 13 | * @param predicate A function that evaluates each value emitted by the source observable. 14 | * Returns `true` if the value satisfies the condition, `false` otherwise. 15 | * @param defaultValue The default value returned when no value matches the predicate. 16 | * 17 | * @group Operators 18 | */ 19 | export function first( 20 | predicate?: null, 21 | defaultValue?: D 22 | ): SingletonOperatorFunction; 23 | export function first( 24 | predicate: BooleanConstructor 25 | ): SingletonOperatorFunction>; 26 | export function first( 27 | predicate: BooleanConstructor, 28 | defaultValue: D 29 | ): SingletonOperatorFunction | D>; 30 | export function first( 31 | predicate: (value: T, index: number) => value is S, 32 | defaultValue?: S 33 | ): SingletonOperatorFunction; 34 | export function first( 35 | predicate: (value: T, index: number) => value is S, 36 | defaultValue: D 37 | ): SingletonOperatorFunction; 38 | export function first( 39 | predicate: (value: T, index: number) => boolean, 40 | defaultValue?: D 41 | ): SingletonOperatorFunction; 42 | export function first( 43 | predicate?: ((value: T, index: number) => boolean) | BooleanConstructor | null, 44 | defaultValue?: T 45 | ): SingletonOperatorFunction { 46 | const hasDefaultValue = arguments.length >= 2; 47 | return (source) => 48 | singletonFrom( 49 | new source.AsyncObservable(async function* () { 50 | let index = 0; 51 | for await (const value of source) { 52 | const passed = iife(() => { 53 | if (predicate === null || predicate === undefined) return true; 54 | else if (typeof predicate === "function") return predicate(value, index++); 55 | else return Boolean(value); 56 | }); 57 | if (passed) { 58 | yield value; 59 | return; 60 | } 61 | } 62 | if (hasDefaultValue) yield defaultValue; 63 | else throw new NoValuesError(); 64 | }) 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/concat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | from, 3 | type AsyncObservableInput, 4 | type ObservedValueOf, 5 | type OperatorFunction, 6 | } from "@eventkit/async-observable"; 7 | 8 | import { type AsyncObservableInputTuple } from "../utils/types"; 9 | import { mergeAll, mergeMap } from "./merge"; 10 | 11 | /** 12 | * Merges the values from all provided observables into a single observable. When subscribed to, it 13 | * will subscribe to the provided observables in a serial fashion, emitting the observables values, 14 | * and waiting for each one to complete before subscribing to the next. The output observable will 15 | * complete when all provided observables have completed, and error when any provided observable 16 | * errors. 17 | * 18 | * @group Operators 19 | */ 20 | export function concat( 21 | ...otherSources: [...AsyncObservableInputTuple] 22 | ): OperatorFunction { 23 | return (source) => from([source, ...otherSources]).pipe(concatAll()); 24 | } 25 | 26 | /** 27 | * Converts an observable that yields observables (called a higher-order observable) into a 28 | * first-order observable which delivers all the values from the inner observables in order. It 29 | * only subscribes to an inner observable only after the previous inner observable has completed. 30 | * All values emitted by the inner observables are emitted in order. 31 | * 32 | * Note: If the source observable emits observables quickly and endlessly, and the inner observables 33 | * it emits generally complete slower than the source emits, you can run into memory issues as 34 | * the incoming observables collect in an unbounded buffer. 35 | * 36 | * Note: `concatAll` is equivalent to `mergeAll` with the concurrency parameter set to `1`. 37 | * 38 | * @group Operators 39 | */ 40 | export function concatAll>(): OperatorFunction< 41 | O, 42 | ObservedValueOf 43 | > { 44 | return mergeAll(1); 45 | } 46 | 47 | /** 48 | * Applies a predicate function to each value yielded by the source observable, which returns a 49 | * different observable that will be merged in a serialized fashion, waiting for each one to 50 | * complete before subscribing to the next. 51 | * 52 | * Note: If the source observable emits observables quickly and endlessly, and the inner observables 53 | * it emits generally complete slower than the source emits, you can run into memory issues as 54 | * the incoming observables collect in an unbounded buffer. 55 | * 56 | * Note: `concatMap` is equivalent to `mergeMap` with the concurrency parameter set to `1`. 57 | * 58 | * @group Operators 59 | */ 60 | export function concatMap>( 61 | predicate: (value: T, index: number) => O 62 | ): OperatorFunction> { 63 | return mergeMap(predicate, 1); 64 | } 65 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/last.ts: -------------------------------------------------------------------------------- 1 | import { singletonFrom } from "../singleton"; 2 | import { NoValuesError } from "../utils/errors"; 3 | import { iife } from "../utils/operators"; 4 | import { type SingletonOperatorFunction, type TruthyTypesOf } from "../utils/types"; 5 | 6 | /** 7 | * Emits the last value emitted by the source observable that satisfies a specified condition. If 8 | * no such value is found when the source observable completes, the `defaultValue` is emitted if 9 | * it's provided. If it isn't, a NoValuesError is thrown. 10 | * 11 | * @throws {NoValuesError} Will throw a `NoValuesError` if no value is found and no default value is provided. 12 | * 13 | * @param predicate A function that evaluates each value emitted by the source observable. 14 | * Returns `true` if the value satisfies the condition, `false` otherwise. 15 | * @param defaultValue The default value returned when no value matches the predicate. 16 | * 17 | * @group Operators 18 | */ 19 | export function last( 20 | predicate?: null, 21 | defaultValue?: D 22 | ): SingletonOperatorFunction; 23 | export function last( 24 | predicate: BooleanConstructor 25 | ): SingletonOperatorFunction>; 26 | export function last( 27 | predicate: BooleanConstructor, 28 | defaultValue: D 29 | ): SingletonOperatorFunction | D>; 30 | export function last( 31 | predicate: (value: T, index: number) => value is S, 32 | defaultValue?: S 33 | ): SingletonOperatorFunction; 34 | export function last( 35 | predicate: (value: T, index: number) => value is S, 36 | defaultValue: D 37 | ): SingletonOperatorFunction; 38 | export function last( 39 | predicate: (value: T, index: number) => boolean, 40 | defaultValue?: D 41 | ): SingletonOperatorFunction; 42 | export function last( 43 | predicate?: ((value: T, index: number) => boolean) | BooleanConstructor | null, 44 | defaultValue?: T 45 | ): SingletonOperatorFunction { 46 | const hasDefaultValue = arguments.length >= 2; 47 | return (source) => 48 | singletonFrom( 49 | new source.AsyncObservable(async function* () { 50 | let index = 0; 51 | let lastValue: T | undefined; 52 | for await (const value of source) { 53 | const passed = iife(() => { 54 | if (predicate === null || predicate === undefined) return true; 55 | else if (typeof predicate === "function") return predicate(value, index++); 56 | else return Boolean(value); 57 | }); 58 | if (passed) { 59 | lastValue = value; 60 | } 61 | } 62 | if (lastValue === undefined) { 63 | if (hasDefaultValue) yield defaultValue; 64 | else throw new NoValuesError(); 65 | } 66 | yield lastValue; 67 | }) 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [eventkit](https://hntrl.github.io/eventkit) — [![GitHub license][license-badge]][license-url] [![npm][npm-badge]][npm-url] [![build][build-badge]][build-url] [![PRs Welcome][prs-badge]][prs-url] 2 | 3 | [license-badge]: https://img.shields.io/badge/license-MIT-blue.svg 4 | [license-url]: https://github.com/hntrl/eventkit/blob/main/LICENSE.md 5 | [npm-badge]: https://img.shields.io/npm/v/@eventkit/base 6 | [npm-url]: https://www.npmjs.com/package/@eventkit/base 7 | [build-badge]: https://img.shields.io/github/actions/workflow/status/hntrl/eventkit/test.yml 8 | [build-url]: https://github.com/hntrl/eventkit/actions/workflows/test.yml 9 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg 10 | [prs-url]: https://github.com/hntrl/eventkit/blob/main/CONTRIBUTING.md#making-a-pull-request 11 | 12 | Eventkit is a library for defining, composing, and observing asynchronous streams of data. 13 | 14 | - **Declarative** 🌍: Build complex data streams using a simple, declarative API inspired by RxJS and Node.js streams 15 | - **Async-First Architecture** ⚡️: Built on modern async iterators and generators, making it perfect for handling real-time data and I/O operations 16 | - **Batteries Included** 🔋: Super clean APIs, first-class TypeScript support with full type inference, and all the extras included 17 | - **Lightweight & Modular** 📦: Zero external dependencies and a modular design that lets you only import what you need 18 | 19 | You can learn more about the use cases and features of Eventkit in the [official documentation](https://hntrl.github.io/eventkit/guide/what-is-eventkit). 20 | 21 | ## Basic Usage 22 | 23 | The best way to start using Eventkit is by checking out the [getting started](https://hntrl.github.io/eventkit/guide/getting-started) guide. 24 | 25 | Here's a basic example of how to use an eventkit stream— a common object used to represent data that will be emitted elsewhere in your application: 26 | 27 | ```typescript 28 | import { Stream, filter } from "@eventkit/base"; 29 | 30 | // Create a stream of events 31 | const stream = new Stream<{ type: string; payload: any }>(); 32 | 33 | // Filter for specific event types 34 | const userEvents = stream.pipe(filter((event) => event.type.startsWith("user."))); 35 | 36 | // Subscribe to the filtered stream 37 | userEvents.subscribe((event) => { 38 | console.log(`Received user event: ${event.type}`); 39 | }); 40 | 41 | // Push events to the stream 42 | stream.push({ type: "user.login", payload: { userId: "123" } }); 43 | stream.push({ type: "system.update", payload: { version: "1.0.1" } }); // This won't be logged 44 | 45 | // Wait for all events to be processed 46 | await stream.drain(); 47 | ``` 48 | 49 | ## Documentation 50 | 51 | View the official eventkit documentation [here](https://hntrl.github.io/eventkit). 52 | 53 | ## Contributing 54 | 55 | We welcome contributions! Please see our [CONTRIBUTING.md](CONTRIBUTING.md) guide for more information on how to get involved. 56 | 57 | ## License 58 | 59 | Distributed under the MIT License. See the [LICENSE](LICENSE.md) for more information. 60 | -------------------------------------------------------------------------------- /examples/http-streaming/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Projects */ 4 | "incremental": true /* Saves compilation info to .tsbuildinfo files for faster builds */, 5 | "disableSourceOfProjectReferenceRedirect": true /* Disable preferring source files instead of declaration files when referencing composite projects */, 6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo" /* Specifies the file name for the incremental build information. */, 7 | 8 | /* Language and Environment */ 9 | "target": "esnext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 10 | "lib": [ 11 | "esnext" 12 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 13 | "types": ["node"] /* Specifies the types to include in the compilation. */, 14 | 15 | /* Modules */ 16 | "module": "esnext" /* Specify what module code is generated. */, 17 | "moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */, 18 | "resolveJsonModule": true /* Enable importing .json files */, 19 | "pretty": true /* Enable pretty-printing of errors and warnings. */, 20 | "paths": { 21 | /* Specifies custom module name mappings. */ "@/*": ["./src/*"] 22 | }, 23 | 24 | /* Emit */ 25 | "declaration": false /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 26 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 27 | "removeComments": true /* Disable emitting comments. */, 28 | "noEmit": false /* Disable emitting files from a compilation. */, 29 | "importHelpers": true /* Allow importing helper functions from tslib once per project, instead of including them per-file. */, 30 | "noEmitHelpers": true /* Disable generating custom helper functions like `__extends` in compiled output. */, 31 | "stripInternal": true /* Disable emitting declarations that have `@internal` in their JSDoc comments. */, 32 | 33 | /* Interop Constraints */ 34 | "isolatedDeclarations": false, 35 | "isolatedModules": false /* Ensure that each file can be safely transpiled without relying on other imports. */, 36 | "preserveSymlinks": true /* Preserve symlinks when resolving modules. */, 37 | "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, 38 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 39 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 40 | 41 | /* Type Checking */ 42 | "strict": true /* Enable all strict type-checking options. */, 43 | "strictNullChecks": true /* When type checking, take into account `null` and `undefined`. */, 44 | "strictBindCallApply": true /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */, 45 | 46 | /* Completeness */ 47 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 48 | }, 49 | "include": ["src/**/*"], 50 | "exclude": ["**/*.test.ts"] 51 | } 52 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/withScheduler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type SchedulerLike, 3 | type OperatorFunction, 4 | type SchedulerSubject, 5 | type ScheduledAction, 6 | PassthroughScheduler, 7 | } from "@eventkit/async-observable"; 8 | 9 | /** 10 | * A scheduler that defers the execution of scheduled actions to a specified deferred scheduler, 11 | * while still passing through work to a parent scheduler for tracking purposes. 12 | * 13 | * The `DeferredPassthroughScheduler` is useful in scenarios where you want to control the 14 | * execution timing of actions separately from the parent scheduler, but still want to track 15 | * the work within the parent scheduler's context. This is the direct scheduler that's imposed 16 | * when using the {@link #withScheduler} operator. 17 | * 18 | * This scheduler extends the `PassthroughScheduler`, inheriting its ability to pass work 19 | * through to a parent scheduler, but overrides the scheduling behavior to use a different 20 | * scheduler for execution. 21 | * 22 | * @group Scheduling 23 | */ 24 | export class DeferredPassthroughScheduler extends PassthroughScheduler implements SchedulerLike { 25 | constructor( 26 | protected readonly parent: SchedulerLike, 27 | protected readonly deferred: SchedulerLike, 28 | protected readonly pinningSubject?: SchedulerSubject 29 | ) { 30 | super(parent, pinningSubject); 31 | } 32 | 33 | /** 34 | * Schedules an action that is "owned" by the subject that will be executed by the deferred 35 | * scheduler instead of the parent scheduler. 36 | * 37 | * @param subject - The subject that "owns" the action. 38 | * @param action - The action to be scheduled. 39 | * @group Operators 40 | */ 41 | schedule(subject: SchedulerSubject, action: ScheduledAction) { 42 | // we defer to the deferred scheduler to create the execution. 43 | this.deferred.schedule(subject, action); 44 | super.add(subject, action); 45 | } 46 | } 47 | 48 | /** 49 | * Applies a scheduler to an observable that passes side effects to the source observable, but 50 | * defers the execution to the scheduler provided in the parameters. Use this when you want to 51 | * control the execution of side effects independently of the source observable. 52 | * 53 | * @param scheduler - The scheduler to defer execution to. 54 | * @group Operators 55 | */ 56 | export function withScheduler(scheduler: SchedulerLike): OperatorFunction { 57 | return (source) => { 58 | const obs = new source.AsyncObservable(async function* () { 59 | yield* source; 60 | }); 61 | obs._scheduler = new DeferredPassthroughScheduler(source._scheduler, scheduler, source); 62 | return obs; 63 | }; 64 | } 65 | 66 | /** 67 | * Applies this to an independent Scheduler to an observable. Use this when you want to separate 68 | * side effects from the source observable entirely. 69 | * 70 | * @param scheduler - The scheduler to apply to the observable. 71 | * @group Operators 72 | */ 73 | export function withOwnScheduler(scheduler: SchedulerLike): OperatorFunction { 74 | return (source) => { 75 | const obs = new source.AsyncObservable(async function* () { 76 | yield* source; 77 | }); 78 | obs._scheduler = scheduler; 79 | return obs; 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /packages/eventkit-http/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type MessageEvent as UndiciMessageEvent } from "undici-types"; 2 | import { 3 | type AddEventListenerOptions, 4 | type EventListener, 5 | type EventListenerObject, 6 | } from "undici-types/patch"; 7 | 8 | /** 9 | * Parses a MessageEvent and returns a new MessageEvent with the data parsed as JSON. 10 | * If the data cannot be parsed as JSON, the original event is returned unchanged. 11 | * 12 | * @param event - The MessageEvent to parse 13 | * @returns A new MessageEvent with the data parsed as JSON, or the original event if parsing fails 14 | */ 15 | export function parseMessageEvent(event: MessageEvent): UndiciMessageEvent { 16 | try { 17 | const data = JSON.parse(event.data); 18 | return new MessageEvent(event.type, { 19 | data, 20 | lastEventId: event.lastEventId, 21 | origin: event.origin, 22 | ports: event.ports as (typeof MessagePort)[], 23 | source: event.source, 24 | }); 25 | } catch { 26 | return event; 27 | } 28 | } 29 | 30 | /** 31 | * Represents a global event listener that can track which event types it's listening to. 32 | */ 33 | type GlobalEventListener = { 34 | /** 35 | * Returns a record of all event types and their associated listeners that have been registered. 36 | * 37 | * @returns A record mapping event types to their event listeners 38 | */ 39 | getListeners: () => Record; 40 | }; 41 | 42 | /** 43 | * Adds a global event listener to the target that automatically registers for new event types. 44 | * This function modifies the target's dispatchEvent method to automatically register the listener 45 | * for any event type that is dispatched but not yet being listened to. 46 | * 47 | * @param target - The EventTarget to attach the global listener to 48 | * @param listener - The event listener or listener object to be called when events are dispatched 49 | * @param options - Optional addEventListener options 50 | * @returns A GlobalEventListener object that can be used to track and remove the listeners 51 | */ 52 | export function addGlobalEventListener( 53 | target: EventTarget, 54 | listener: EventListener | EventListenerObject, 55 | options?: AddEventListenerOptions 56 | ): GlobalEventListener { 57 | const originalDispatchEvent = target.dispatchEvent; 58 | const listeners: Record = {}; 59 | function dispatchEvent(event: Event) { 60 | if (!listeners[event.type]) { 61 | target.addEventListener(event.type, listener, options); 62 | listeners[event.type] = listener; 63 | } 64 | return originalDispatchEvent.call(target, event); 65 | } 66 | target.dispatchEvent = dispatchEvent; 67 | return { 68 | getListeners: () => listeners, 69 | }; 70 | } 71 | 72 | /** 73 | * Removes all event listeners that were added by the global event listener. 74 | * 75 | * @param target - The EventTarget to remove the listeners from 76 | * @param listener - The GlobalEventListener object returned by addGlobalEventListener 77 | */ 78 | export function removeGlobalEventListeners(target: EventTarget, listener: GlobalEventListener) { 79 | const listeners = listener.getListeners(); 80 | for (const eventType in listeners) { 81 | target.removeEventListener(eventType, listeners[eventType]); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /examples/workers-chat-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Projects */ 4 | "incremental": true, /* Saves compilation info to .tsbuildinfo files for faster builds */ 5 | "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", /* Specifies the file name for the incremental build information. */ 7 | 8 | /* Language and Environment */ 9 | "target": "esnext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 10 | "lib": ["esnext"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 11 | 12 | /* Modules */ 13 | "module": "esnext", /* Specify what module code is generated. */ 14 | "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ 15 | "resolveJsonModule": true, /* Enable importing .json files */ 16 | "pretty": true, /* Enable pretty-printing of errors and warnings. */ 17 | "paths": { /* Specifies custom module name mappings. */ 18 | "@/*": ["./src/*"] 19 | }, 20 | 21 | /* Emit */ 22 | "declaration": false, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 23 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 24 | "removeComments": true, /* Disable emitting comments. */ 25 | "noEmit": false, /* Disable emitting files from a compilation. */ 26 | "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 27 | "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 28 | "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 29 | 30 | /* Interop Constraints */ 31 | "isolatedDeclarations": false, 32 | "isolatedModules": false, /* Ensure that each file can be safely transpiled without relying on other imports. */ 33 | "preserveSymlinks": true, /* Preserve symlinks when resolving modules. */ 34 | "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 35 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 36 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 37 | 38 | /* Type Checking */ 39 | "strict": true , /* Enable all strict type-checking options. */ 40 | "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 41 | "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 42 | 43 | /* Completeness */ 44 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 45 | }, 46 | "include": [ 47 | "src/**/*", 48 | "worker-configuration.d.ts" 49 | ], 50 | "exclude": ["**/*.test.ts"] 51 | } -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { type DefaultTheme, defineConfig } from "vitepress"; 2 | 3 | import typedocSidebar from "../reference/typedoc-sidebar.json"; 4 | import eventkitPkg from "../../packages/eventkit/package.json"; 5 | 6 | // https://vitepress.dev/reference/site-config 7 | export default defineConfig({ 8 | lang: "en-US", 9 | title: "eventkit", 10 | description: "Declarative stream processing for Typescript", 11 | base: "/eventkit/", 12 | head: [ 13 | [ 14 | "script", 15 | { 16 | defer: "true", 17 | src: "https://assets.onedollarstats.com/stonks.js", 18 | }, 19 | ], 20 | ], 21 | lastUpdated: false, 22 | ignoreDeadLinks: true, 23 | cleanUrls: true, 24 | themeConfig: { 25 | editLink: { 26 | pattern: "https://github.com/hntrl/eventkit/edit/main/docs/:path", 27 | text: "Suggest changes to this page", 28 | }, 29 | search: { 30 | provider: "local", 31 | }, 32 | nav: nav(), 33 | sidebar: sidebar(), 34 | 35 | footer: { 36 | message: 37 | "Released under the MIT License.", 38 | copyright: "Copyright © 2025-present Hunter Lovell & eventkit contributors", 39 | }, 40 | 41 | socialLinks: [{ icon: "github", link: "https://github.com/hntrl/eventkit" }], 42 | }, 43 | }); 44 | 45 | function nav(): DefaultTheme.NavItem[] { 46 | return [ 47 | { 48 | text: "Guide", 49 | link: "/guide/what-is-eventkit", 50 | activeMatch: "/guide/", 51 | }, 52 | { 53 | text: "Reference", 54 | link: "/reference", 55 | activeMatch: "/reference/", 56 | }, 57 | { 58 | text: eventkitPkg.version, 59 | items: [ 60 | { 61 | text: "Changelog", 62 | link: "https://github.com/hntrl/eventkit/blob/main/packages/eventkit/CHANGELOG.md", 63 | }, 64 | { 65 | text: "Contributing", 66 | link: "https://github.com/hntrl/eventkit/blob/main/CONTRIBUTING.md", 67 | }, 68 | ], 69 | }, 70 | ]; 71 | } 72 | 73 | function sidebar(): DefaultTheme.SidebarItem[] { 74 | return [ 75 | { 76 | text: "Introduction", 77 | collapsed: false, 78 | base: "/guide/", 79 | items: [ 80 | { text: "What is eventkit?", link: "what-is-eventkit" }, 81 | { text: "Getting Started", link: "getting-started" }, 82 | { text: "Motivations", link: "motivations" }, 83 | ], 84 | }, 85 | { 86 | text: "Concepts", 87 | collapsed: false, 88 | base: "/guide/concepts/", 89 | items: [ 90 | { text: "Creating Streams", link: "creating-streams" }, 91 | { text: "Transforming Data", link: "transforming-data" }, 92 | { text: "Observable Pattern", link: "observable-pattern" }, 93 | { text: "Async Processing", link: "async-processing" }, 94 | { text: "Scheduling", link: "scheduling" }, 95 | ], 96 | }, 97 | { 98 | text: "Examples", 99 | collapsed: false, 100 | base: "/guide/examples/", 101 | items: [ 102 | { text: "Reactive Systems", link: "reactive-systems" }, 103 | { text: "HTTP Streaming", link: "http-streaming" }, 104 | ], 105 | }, 106 | { 107 | text: "Reference", 108 | items: typedocSidebar, 109 | }, 110 | ]; 111 | } 112 | -------------------------------------------------------------------------------- /docs/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | In this guide, we'll walk through the fundamentals and tell you how to go from 0 to 100 with eventkit. 4 | 5 | ## Installation 6 | 7 | ::: code-group 8 | 9 | ```sh [npm] 10 | npm install @eventkit/base 11 | ``` 12 | 13 | ```sh [yarn] 14 | yarn add @eventkit/base 15 | ``` 16 | 17 | ```sh [pnpm] 18 | pnpm add @eventkit/base 19 | ``` 20 | 21 | ```sh [bun] 22 | bun add @eventkit/base 23 | ``` 24 | 25 | ::: 26 | 27 | ### Using a CDN 28 | 29 | Eventkit also bundles a browser-friendly version of each package that can be accessed using a CDN like [unpkg](https://unpkg.com/). 30 | 31 | ```html 32 | 33 | 34 | 35 | 36 | ``` 37 | 38 | When imported this way, all exports are available on the `eventkit` global variable. 39 | 40 | ```js 41 | const { map, filter, Stream } = eventkit; 42 | const stream = new Stream(); 43 | ``` 44 | 45 | ## A Simple Example 46 | 47 | Let's create a simple example to demonstrate how eventkit works. We'll create a stream of events and filter them based on a condition. 48 | 49 | ```typescript 50 | import { Stream, filter } from "@eventkit/base"; 51 | 52 | // Create a stream of events 53 | const stream = new Stream<{ type: string; payload: any }>(); 54 | 55 | // Filter for specific event types 56 | const userEvents = stream.pipe(filter((event) => event.type.startsWith("user."))); 57 | 58 | // Subscribe to the filtered stream 59 | userEvents.subscribe((event) => { 60 | console.log(`Received user event: ${event.type}`); 61 | }); 62 | 63 | // Push events to the stream 64 | stream.push({ type: "user.login", payload: { userId: "123" } }); 65 | stream.push({ type: "system.update", payload: { version: "1.0.1" } }); // This won't be logged 66 | 67 | // Wait for all events to be processed 68 | await stream.drain(); 69 | ``` 70 | 71 | ## Basic Concepts 72 | 73 | Eventkit revolves around a few core concepts: 74 | 75 | - [**AsyncObservable**](./concepts/observable-pattern#using-asyncobservable): A powerful implementation of the observable pattern that handles asynchronous data streams, allowing you to process multiple values over time. 76 | - [**Stream**](./concepts/creating-streams): A specialized observable that can be pushed to indefinitely and provides fine-grained control over execution timing, perfect for real-time data and event-driven applications. 77 | - [**Operators**](./concepts/transforming-data): Composable functions that transform, filter, and combine data streams, enabling you to build complex data processing pipelines with clean, declarative code. 78 | - [**Schedulers**](./concepts/scheduling#the-scheduler-object): Components that coordinate work execution, giving you precise control over how and when side effects occur in your application. 79 | 80 | ## Next Steps 81 | 82 | Now that you have a basic understanding of how to use Eventkit, you can explore more advanced features and concepts: 83 | 84 | - **[What is Eventkit?](./what-is-eventkit.md)**: Learn more about the library and its use cases. 85 | - **[Creating Streams](./concepts/creating-streams)**: Learn how to create and manipulate streams. 86 | - **[Transforming Data](./concepts/transforming-data)**: Learn how to transform data in streams. 87 | - **[Observable Pattern](./concepts/observable-pattern)**: Understand the core principles of eventkit. 88 | -------------------------------------------------------------------------------- /packages/eventkit/lib/schedulers/queue.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ScheduledAction, 3 | type SchedulerSubject, 4 | type SchedulerLike, 5 | Scheduler, 6 | CleanupAction, 7 | } from "@eventkit/async-observable"; 8 | 9 | import { InvalidConcurrencyLimitError } from "../utils/errors"; 10 | 11 | /** 12 | * Configuration options for initializing queue schedulers. 13 | * @group Scheduling 14 | */ 15 | export interface QueueSchedulerInit { 16 | /** 17 | * The maximum number of actions that can be executing at the same time. 18 | * Defaults to 1, which means actions will be executed sequentially. 19 | */ 20 | concurrency?: number; 21 | } 22 | 23 | /** 24 | * A scheduler that limits the number of actions that can execute concurrently. 25 | * 26 | * The `QueueScheduler` maintains a global queue of actions waiting to be executed and 27 | * ensures that no more than a specified number of actions are executing at any 28 | * given time. When an action completes, the next action in the queue is executed 29 | * if there's room under the concurrency limit. 30 | * 31 | * This scheduler is useful when you want to control the processing order of side 32 | * effects, especially when they involve asynchronous operations that might otherwise 33 | * complete out of order. 34 | * 35 | * @group Scheduling 36 | */ 37 | export class QueueScheduler extends Scheduler implements SchedulerLike { 38 | /** @internal */ 39 | private _queue: Array<[SchedulerSubject, ScheduledAction]> = []; 40 | /** @internal */ 41 | private _running = 0; 42 | /** @internal */ 43 | private readonly concurrency: number; 44 | 45 | /** 46 | * Creates a new QueueScheduler. 47 | * 48 | * @param init - Configuration options for the scheduler. 49 | */ 50 | constructor(init: QueueSchedulerInit = {}) { 51 | super(); 52 | 53 | this.concurrency = init.concurrency ?? 1; 54 | if (this.concurrency < 1) { 55 | throw new InvalidConcurrencyLimitError(); 56 | } 57 | } 58 | 59 | /** 60 | * Schedules an action for execution. The action will be queued and executed when there's room 61 | * under the concurrency limit. 62 | * 63 | * @param subject - The subject that "owns" the action. 64 | * @param action - The action to be scheduled. 65 | */ 66 | schedule(subject: SchedulerSubject, action: ScheduledAction) { 67 | // Add the action to the subject's work set 68 | this.add(subject, action); 69 | // CleanupAction's are handled separately by the base class 70 | if (action instanceof CleanupAction) return; 71 | 72 | // Add to global queue 73 | this._queue.push([subject, action]); 74 | this._processQueue(); 75 | } 76 | 77 | /** @internal */ 78 | private async _processQueue() { 79 | // If we've reached the concurrency limit, we'll wait for an action to complete 80 | if (this._running >= this.concurrency) return; 81 | 82 | // Take the next action from the queue 83 | const next = this._queue.shift(); 84 | if (!next) return; // No more actions to process 85 | 86 | // Mark that we're executing an action 87 | this._running++; 88 | 89 | try { 90 | const [, action] = next; 91 | // Execute the action 92 | await action.execute(); 93 | } finally { 94 | // Mark that we're done executing this action 95 | this._running--; 96 | // Process the next action in the queue if there is one 97 | this._processQueue(); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/reduce.ts: -------------------------------------------------------------------------------- 1 | import { singletonFrom } from "../singleton"; 2 | import { NoValuesError } from "../utils/errors"; 3 | import { type SingletonOperatorFunction } from "../utils/types"; 4 | 5 | const kUndefinedReducerValue = Symbol("undefinedReducerValue"); 6 | type UndefinedReducerValue = typeof kUndefinedReducerValue; 7 | 8 | /** 9 | * Applies an accumulator function over the source generator, and returns the 10 | * accumulated result when the source completes, given an optional seed value. 11 | * 12 | * Like 13 | * [Array.prototype.reduce()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce), 14 | * `reduce` applies an `accumulator` function against an accumulation and each 15 | * value emitted by the source generator to reduce it to a single value, emitted 16 | * on the output generator. This operator also behaves similarly to the reduce method in terms of 17 | * how it handles the seed value (or initialValue): 18 | * 19 | * * If no seed value is provided and the observable emits more than one value, the accumulator 20 | * function will be called for each value starting with the second value, where the first value is 21 | * used as the initial accumulator value. This means that the accumulator function will be called 22 | * N-1 times where N is the number of values emitted by the source generator. 23 | * 24 | * * If the seed value is provided and the observable emits any values, then the accumulator 25 | * function will always be called starting with the first emission from the source generator. 26 | * 27 | * * If the observable only emits one value and no seed value is provided, or if the seed value is 28 | * provided but the observable doesn't emit any values, the solo value will be emitted without 29 | * any calls to the accumulator function. 30 | * 31 | * * If the observable emits no values and a seed value is provided, the seed value will be emitted 32 | * without any calls to the accumulator function. 33 | * 34 | * * If the observable emits no values and no seed value is provided, the operator will throw a 35 | * `NoValuesError` on completion. 36 | * 37 | * @throws {NoValuesError} If the observable emits no values and no seed value is provided. 38 | * 39 | * @param accumulator The accumulator function called on each source value. 40 | * @param seed The initial accumulation value. 41 | * 42 | * @group Operators 43 | */ 44 | export function reduce( 45 | accumulator: (acc: A | V, value: V, index: number) => A 46 | ): SingletonOperatorFunction; 47 | export function reduce( 48 | accumulator: (acc: A, value: V, index: number) => A, 49 | seed: A 50 | ): SingletonOperatorFunction; 51 | export function reduce( 52 | accumulator: (acc: A | S, value: V, index: number) => A, 53 | seed: S 54 | ): SingletonOperatorFunction; 55 | export function reduce( 56 | accumulator: (acc: A | V, value: V, index: number) => A, 57 | seed?: A 58 | ): SingletonOperatorFunction { 59 | return (source) => 60 | singletonFrom( 61 | new source.AsyncObservable(async function* () { 62 | let acc: A | V | UndefinedReducerValue = seed ?? kUndefinedReducerValue; 63 | let index = 0; 64 | for await (const value of source) { 65 | if (acc === kUndefinedReducerValue) acc = value; 66 | else acc = accumulator(acc, value, index); 67 | index++; 68 | } 69 | if (acc === kUndefinedReducerValue) throw new NoValuesError(); 70 | yield acc; 71 | }) 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /packages/eventkit/lib/singleton.ts: -------------------------------------------------------------------------------- 1 | import { AsyncObservable, Subscriber } from "@eventkit/async-observable"; 2 | 3 | import { NoValuesError } from "./utils/errors"; 4 | 5 | /** 6 | * An extension of AsyncObservable that implements PromiseLike, allowing it to be used with 7 | * await syntax. 8 | * 9 | * This class is designed for observables that are expected to emit exactly one value, such as 10 | * those created by operators like `reduce` or `count`. It provides a convenient way to await 11 | * the single emitted value without having to manually set up a subscription. 12 | * 13 | * @example 14 | * ```ts 15 | * // instead of 16 | * let value: T | undefined; 17 | * await observable.subscribe((v) => { 18 | * // this observable only emits one value, so this will only be called once 19 | * value = v; 20 | * }); 21 | * 22 | * // you can do 23 | * const value: T = await observable; 24 | * 25 | * // like with `reduce` for instance 26 | * const result = await from([1, 2, 3]).pipe(reduce((acc, val) => acc + val, 0)); 27 | * console.log(result); // 6 28 | * ``` 29 | * 30 | * @template T - The type of the value emitted by the observable 31 | */ 32 | export class SingletonAsyncObservable extends AsyncObservable implements PromiseLike { 33 | /** 34 | * Returns a promise that will subscribe to the observable and resolve when the subscriber emits 35 | * its first value. This is useful in cases where you know the observable will emit one and only 36 | * one value (like the result of a `reduce` or `count` operator), but you want to wait for the 37 | * value to be emitted using `await` syntax instead of a subscription callback. 38 | * 39 | * When the first value is emitted, the subscriber will immediately be cancelled and all cleanup 40 | * work will be performed before the promise resolves. 41 | * 42 | * @throws {NoValuesError} If the observable completes without emitting any values. 43 | * 44 | * @param onfulfilled Optional callback to execute when the promise resolves successfully 45 | * @param onrejected Optional callback to execute when the promise rejects with an error 46 | * @returns A promise that resolves with the result of the onfulfilled/onrejected handlers 47 | */ 48 | then( 49 | onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, 50 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null 51 | ): Promise { 52 | const sub = new Subscriber(this); 53 | return Promise.race([ 54 | // Race against the return signal to see if the observable completed without emitting any 55 | // values. 56 | sub._returnSignal.then(() => { 57 | throw new NoValuesError(); 58 | }), 59 | // Race against the first value to be emitted by the observable. 60 | sub.next(), 61 | ]) 62 | .then(async (result) => { 63 | await sub.cancel(); 64 | return result.value; 65 | }) 66 | .then(onfulfilled, onrejected); 67 | } 68 | } 69 | 70 | /** 71 | * Reconstructs a SingletonAsyncObservable from an existing AsyncObservable. We use this in 72 | * operators to intrinsically return singletons that retains the operator behavior of the source 73 | * without introducing the concept of a singleton to async-observable 74 | * 75 | * @param source The AsyncObservable to reconstruct 76 | * @returns A SingletonAsyncObservable that is a composition of the source and the provided operator 77 | * @internal 78 | */ 79 | export function singletonFrom(source: AsyncObservable): SingletonAsyncObservable { 80 | const observable = new SingletonAsyncObservable(source._generator); 81 | observable._scheduler = source._scheduler; 82 | return observable; 83 | } 84 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/buffer.ts: -------------------------------------------------------------------------------- 1 | import { type AsyncObservable, type OperatorFunction } from "@eventkit/async-observable"; 2 | 3 | import { arrRemove } from "../utils/array"; 4 | import { map } from "./map"; 5 | import { merge } from "./merge"; 6 | 7 | const kBufferNotifier = Symbol("bufferNotifier"); 8 | type BufferNotifier = typeof kBufferNotifier; 9 | 10 | /** 11 | * Buffers the source observable until the `pushNotifier` observable emits a value. 12 | * Each time the `pushNotifier` observable emits a value, the current buffer is emitted and a new 13 | * buffer is started. 14 | * 15 | * @param pushNotifier An observable that emits a value when the buffer should be emitted and a new 16 | * buffer should be started. 17 | * @group Operators 18 | */ 19 | export function buffer(pushNotifier: AsyncObservable): OperatorFunction { 20 | return (source) => 21 | new source.AsyncObservable(async function* () { 22 | let buffer: T[] = []; 23 | 24 | const notifier$ = pushNotifier.pipe(map(() => kBufferNotifier)); 25 | const merged$ = source.pipe(merge(notifier$)); 26 | 27 | for await (const value of merged$) { 28 | if (value === kBufferNotifier) { 29 | yield buffer; 30 | buffer = []; 31 | } else { 32 | buffer.push(value); 33 | } 34 | } 35 | yield buffer; 36 | }); 37 | } 38 | 39 | /** 40 | * Buffers the source observable until the size hits the maximum `bufferSize` given. 41 | * 42 | * @param bufferSize The maximum size of the buffer emitted. 43 | * @param startBufferEvery Interval at which to start a new buffer. 44 | * For example if `startBufferEvery` is `2`, then a new buffer will be started 45 | * on every other value from the source. A new buffer is started at the 46 | * beginning of the source by default. 47 | * @group Operators 48 | */ 49 | export function bufferCount( 50 | bufferSize: number, 51 | startBufferEvery: number | null = null 52 | ): OperatorFunction { 53 | startBufferEvery = startBufferEvery ?? bufferSize; 54 | return (source) => 55 | new source.AsyncObservable(async function* () { 56 | const buffers: T[][] = []; 57 | let count = 0; 58 | 59 | for await (const value of source) { 60 | let toEmit: T[][] | null = null; 61 | 62 | // Check to see if we need to start a buffer. 63 | // This will start one at the first value, and then 64 | // a new one every N after that. 65 | if (count++ % startBufferEvery! === 0) { 66 | buffers.push([]); 67 | } 68 | 69 | // Push our value into our active buffers. 70 | for (const buffer of buffers) { 71 | buffer.push(value); 72 | // Check to see if we're over the bufferSize 73 | // if we are, record it so we can emit it later. 74 | // If we emitted it now and removed it, it would 75 | // mutate the `buffers` array while we're looping 76 | // over it. 77 | if (bufferSize <= buffer.length) { 78 | toEmit = toEmit ?? []; 79 | toEmit.push(buffer); 80 | } 81 | } 82 | 83 | if (toEmit) { 84 | // We have found some buffers that are over the 85 | // `bufferSize`. Emit them, and remove them from our 86 | // buffers list. 87 | for (const buffer of toEmit) { 88 | arrRemove(buffers, buffer); 89 | yield buffer; 90 | } 91 | } 92 | } 93 | 94 | // When the source completes, emit all of our 95 | // active buffers. 96 | for (const buffer of buffers) { 97 | yield buffer; 98 | } 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/dlq.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PassthroughScheduler, 3 | type SchedulerLike, 4 | type AsyncObservable, 5 | type UnaryFunction, 6 | type SchedulerSubject, 7 | type ScheduledAction, 8 | CallbackAction, 9 | } from "@eventkit/async-observable"; 10 | 11 | import { Stream } from "../stream"; 12 | import { withOwnScheduler } from "./withScheduler"; 13 | 14 | /** 15 | * A scheduler that catches errors from callback actions and redirects them to a handler function. 16 | * 17 | * This scheduler is used by the `dlq()` operator to implement a dead letter queue pattern for 18 | * handling errors in subscriber callbacks without interrupting the observable chain. 19 | * 20 | * When a callback action is scheduled, it wraps the action in a new one that catches any errors 21 | * and passes them to the `onError` handler while resolving the original action successfully. 22 | * This allows the observable chain to continue processing values even when errors occur in 23 | * subscriber callbacks. 24 | * 25 | * @see {@link dlq} for usage with observables 26 | * @internal 27 | */ 28 | export class DLQScheduler extends PassthroughScheduler implements SchedulerLike { 29 | constructor( 30 | protected readonly onError: (err: any) => void, 31 | protected readonly parent: SchedulerLike, 32 | protected readonly pinningSubject?: SchedulerSubject 33 | ) { 34 | super(parent, pinningSubject); 35 | } 36 | 37 | schedule(subject: SchedulerSubject, action: ScheduledAction) { 38 | if (action instanceof CallbackAction) { 39 | // DLQ works by hijacking any callback action and wrapping it in a new one that catches 40 | // any errors and passes them to the onError handler. That way the status of the action is 41 | // still consistent with the rest of the observable chain, but errors are caught before the 42 | // action gets executed by the parent. 43 | super.schedule( 44 | subject, 45 | new CallbackAction(async () => { 46 | action._hasExecuted = true; 47 | try { 48 | const result = await action.callback(); 49 | action.signal.resolve(result); 50 | } catch (err) { 51 | this.onError(err); 52 | action.signal.resolve(undefined); 53 | } 54 | }) 55 | ); 56 | } else { 57 | super.schedule(subject, action); 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * Returns an array with two observables with the purpose of imposing a dead letter queue on the 64 | * source observable; the first observable being the values that are emitted on the source, and the 65 | * second one representing errors that were thrown when executing callback actions. 66 | * 67 | * Since the execution of an observable is arbitrary (subscribers will start/stop at any time), a 68 | * subscription to the errors observable is indefinitely active, but will only yield errors that 69 | * come from active subscribers. This also means that any active subscriptions against the errors 70 | * observable won't schedule blocking work against the parent scheduler (i.e. awaiting the source 71 | * observable won't wait for the errors observable to complete). 72 | * 73 | * Note: this will only yield errors that happen in subscriber callbacks. If an error occurs 74 | * elsewhere (like in cleanup or in the observable's generator), that implies a cancellation 75 | * and the error will be raised as normal. 76 | * 77 | * @group Operators 78 | */ 79 | export function dlq(): UnaryFunction< 80 | AsyncObservable, 81 | [AsyncObservable, AsyncObservable] 82 | > { 83 | return (source) => { 84 | const errors$ = new Stream({ scheduler: source._scheduler }); 85 | // Scheduler that catches errors and buffers them 86 | const scheduler = new DLQScheduler((err) => errors$.push(err), source._scheduler, source); 87 | // Observable that uses the scheduler 88 | const handled$ = source.pipe(withOwnScheduler(scheduler)); 89 | // Return the original observable and the errors observable 90 | return [handled$, errors$.asObservable()]; 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /packages/eventkit/lib/schedulers/subject-queue.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ScheduledAction, 3 | type SchedulerSubject, 4 | type SchedulerLike, 5 | Scheduler, 6 | CleanupAction, 7 | } from "@eventkit/async-observable"; 8 | 9 | import { type QueueSchedulerInit } from "../schedulers"; 10 | import { InvalidConcurrencyLimitError } from "../utils/errors"; 11 | 12 | /** 13 | * A scheduler that maintains separate queues for each subject with a concurrency limit applied 14 | * independently to each queue. 15 | * 16 | * The `SubjectQueueScheduler` ensures that actions from the same subject are processed in order, 17 | * with a maximum number of concurrent actions per subject. This allows multiple subjects to have 18 | * their actions processed in parallel, while maintaining order within each subject's actions. 19 | * 20 | * This is useful when you want to process actions from different subjects concurrently, 21 | * but need to ensure that actions belonging to the same subject are processed in sequence 22 | * or with limited concurrency. 23 | * 24 | * @group Scheduling 25 | */ 26 | export class SubjectQueueScheduler extends Scheduler implements SchedulerLike { 27 | /** @internal */ 28 | private _subjectQueues = new Map>>(); 29 | /** @internal */ 30 | private _runningBySubject = new Map(); 31 | /** @internal */ 32 | private readonly concurrency: number; 33 | 34 | /** 35 | * Creates a new SubjectQueueScheduler. 36 | * 37 | * @param init - Configuration options for the scheduler. 38 | */ 39 | constructor(init: QueueSchedulerInit = {}) { 40 | super(); 41 | 42 | this.concurrency = init.concurrency ?? 1; 43 | if (this.concurrency < 1) { 44 | throw new InvalidConcurrencyLimitError(); 45 | } 46 | } 47 | 48 | /** 49 | * Schedules an action for execution. The action will be queued in its subject's queue 50 | * and executed when there's room under that subject's concurrency limit. 51 | * 52 | * @param subject - The subject that "owns" the action. 53 | * @param action - The action to be scheduled. 54 | */ 55 | schedule(subject: SchedulerSubject, action: ScheduledAction) { 56 | // Add the action to the subject's work set 57 | this.add(subject, action); 58 | // CleanupAction's are handled separately by the base class 59 | if (action instanceof CleanupAction) return; 60 | 61 | // Add to subject-specific queue 62 | const queue = this._subjectQueues.get(subject) || []; 63 | if (!this._subjectQueues.has(subject)) { 64 | this._subjectQueues.set(subject, queue); 65 | } 66 | queue.push(action); 67 | this._processSubjectQueue(subject); 68 | } 69 | 70 | /** @internal */ 71 | private async _processSubjectQueue(subject: SchedulerSubject) { 72 | // Get or initialize the running count for this subject 73 | const runningCount = this._runningBySubject.get(subject) || 0; 74 | 75 | // If we've reached the concurrency limit for this subject, we'll wait 76 | if (runningCount >= this.concurrency) return; 77 | 78 | // Get the queue for this subject 79 | const queue = this._subjectQueues.get(subject); 80 | if (!queue || queue.length === 0) return; // No actions to process 81 | 82 | // Take the next action from the queue 83 | const action = queue.shift(); 84 | if (!action) return; 85 | 86 | // Mark that we're executing an action for this subject 87 | this._runningBySubject.set(subject, runningCount + 1); 88 | 89 | try { 90 | // Execute the action 91 | await action.execute(); 92 | } finally { 93 | // Mark that we're done executing this action 94 | const newRunningCount = (this._runningBySubject.get(subject) || 0) - 1; 95 | if (newRunningCount <= 0) { 96 | this._runningBySubject.delete(subject); 97 | } else { 98 | this._runningBySubject.set(subject, newRunningCount); 99 | } 100 | 101 | // Process the next action in this subject's queue if there is one 102 | this._processSubjectQueue(subject); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/pipe.ts: -------------------------------------------------------------------------------- 1 | import type { UnaryFunction } from "@eventkit/async-observable"; 2 | 3 | export function pipe(): (x: T) => T; 4 | /** @ignore */ 5 | export function pipe(fn1: UnaryFunction): UnaryFunction; 6 | /** @ignore */ 7 | export function pipe( 8 | fn1: UnaryFunction, 9 | fn2: UnaryFunction 10 | ): UnaryFunction; 11 | /** @ignore */ 12 | export function pipe( 13 | fn1: UnaryFunction, 14 | fn2: UnaryFunction, 15 | fn3: UnaryFunction 16 | ): UnaryFunction; 17 | /** @ignore */ 18 | export function pipe( 19 | fn1: UnaryFunction, 20 | fn2: UnaryFunction, 21 | fn3: UnaryFunction, 22 | fn4: UnaryFunction 23 | ): UnaryFunction; 24 | /** @ignore */ 25 | export function pipe( 26 | fn1: UnaryFunction, 27 | fn2: UnaryFunction, 28 | fn3: UnaryFunction, 29 | fn4: UnaryFunction, 30 | fn5: UnaryFunction 31 | ): UnaryFunction; 32 | /** @ignore */ 33 | export function pipe( 34 | fn1: UnaryFunction, 35 | fn2: UnaryFunction, 36 | fn3: UnaryFunction, 37 | fn4: UnaryFunction, 38 | fn5: UnaryFunction, 39 | fn6: UnaryFunction 40 | ): UnaryFunction; 41 | /** @ignore */ 42 | export function pipe( 43 | fn1: UnaryFunction, 44 | fn2: UnaryFunction, 45 | fn3: UnaryFunction, 46 | fn4: UnaryFunction, 47 | fn5: UnaryFunction, 48 | fn6: UnaryFunction, 49 | fn7: UnaryFunction 50 | ): UnaryFunction; 51 | /** @ignore */ 52 | export function pipe( 53 | fn1: UnaryFunction, 54 | fn2: UnaryFunction, 55 | fn3: UnaryFunction, 56 | fn4: UnaryFunction, 57 | fn5: UnaryFunction, 58 | fn6: UnaryFunction, 59 | fn7: UnaryFunction, 60 | fn8: UnaryFunction 61 | ): UnaryFunction; 62 | /** @ignore */ 63 | export function pipe( 64 | fn1: UnaryFunction, 65 | fn2: UnaryFunction, 66 | fn3: UnaryFunction, 67 | fn4: UnaryFunction, 68 | fn5: UnaryFunction, 69 | fn6: UnaryFunction, 70 | fn7: UnaryFunction, 71 | fn8: UnaryFunction, 72 | fn9: UnaryFunction 73 | ): UnaryFunction; 74 | /** @ignore */ 75 | export function pipe( 76 | fn1: UnaryFunction, 77 | fn2: UnaryFunction, 78 | fn3: UnaryFunction, 79 | fn4: UnaryFunction, 80 | fn5: UnaryFunction, 81 | fn6: UnaryFunction, 82 | fn7: UnaryFunction, 83 | fn8: UnaryFunction, 84 | fn9: UnaryFunction, 85 | ...fns: UnaryFunction[] 86 | ): UnaryFunction; 87 | 88 | /** 89 | * Creates a new function that pipes the value through a series of functions. 90 | * 91 | * The `pipe()` function is used to create custom operators by combining multiple 92 | * existing operators. It takes a sequence of operators and returns a new operator 93 | * that applies each function in sequence, passing the result of one function as 94 | * input to the next. 95 | * 96 | * This is particularly useful for creating reusable sequences of operators that 97 | * can be applied to observables. 98 | * 99 | * @example 100 | * ```ts 101 | * // Create a custom operator that filters even numbers and doubles them 102 | * function doubleEvens() { 103 | * return pipe( 104 | * filter((num: number) => num % 2 === 0), 105 | * map((num) => num * 2) 106 | * ); 107 | * } 108 | * 109 | * // Use the custom operator 110 | * const result = AsyncObservable.from([1, 2, 3, 4]) 111 | * .pipe(doubleEvens()) 112 | * .subscribe(console.log); 113 | * // Outputs: 4, 8 114 | * ``` 115 | * 116 | * @param fns - A series of functions to be composed together 117 | * @returns A function that represents the composition of all input functions 118 | * @group Utilities 119 | */ 120 | export function pipe(...fns: Array>): UnaryFunction { 121 | return fns.length === 1 ? fns[0] : (input: any) => fns.reduce(pipeReducer, input); 122 | } 123 | 124 | function pipeReducer(prev: any, fn: UnaryFunction): any { 125 | return fn(prev); 126 | } 127 | -------------------------------------------------------------------------------- /packages/eventkit/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # eventkit 2 | 3 | ## 0.3.1 4 | 5 | ### Patch Changes 6 | 7 | - [#9](https://github.com/hntrl/eventkit/pull/9) [`3ea1105`](https://github.com/hntrl/eventkit/commit/3ea1105c73b96a5e26aa80f0795b6dbf55941fef) Thanks [@hntrl](https://github.com/hntrl)! - The bundle now inlines all types associated with async-observable. This fixes an issue where types would be missing when installing with certain package managers. There may be some conflict for projects that use both `@eventkit/async-observable` and `@eventkit/base` in the same project with this change, but this pattern should be avoided anyways. 8 | 9 | - [#10](https://github.com/hntrl/eventkit/pull/10) [`d1d5dca`](https://github.com/hntrl/eventkit/commit/d1d5dcace45730de1feabfbc81216a7fd034b29f) Thanks [@hntrl](https://github.com/hntrl)! - Gave some TLC to the bundling process for each package. Each package bundle now contains sourcemaps for both cjs & esm builds, as well as a new `index.global.js` and `index.global.min.js` that is intended to be used with browser targets. 10 | 11 | ## 0.3.0 12 | 13 | ### Minor Changes 14 | 15 | - [#6](https://github.com/hntrl/eventkit/pull/6) [`03fb5d1`](https://github.com/hntrl/eventkit/commit/03fb5d13a3370d5164cf81527710c25c4e67e3e5) Thanks [@hntrl](https://github.com/hntrl)! - Introduces **10** new operators into eventkit: `find`, `findIndex`, `first`, `isEmpty`, `last`, `max`, `min`, `pairwise`, `skip`, and `every`. See the [docs](https://hntrl.github.io/eventkit/guide/concepts/transforming-data#available-operators) for a complete reference. 16 | 17 | ### Patch Changes 18 | 19 | - [#6](https://github.com/hntrl/eventkit/pull/6) [`7b6dbb1`](https://github.com/hntrl/eventkit/commit/7b6dbb1a1d96478fcc25c8325648c31d08e78467) Thanks [@hntrl](https://github.com/hntrl)! - Fixed some invariant behavior with the `reduce` operator where the chain of accumulator calls depending on the seed value wasn't consistent with the native array method 20 | 21 | - Updated dependencies []: 22 | - @eventkit/async-observable@0.3.0 23 | 24 | ## 0.2.0 25 | 26 | ### Minor Changes 27 | 28 | - [#4](https://github.com/hntrl/eventkit/pull/4) [`1371b77`](https://github.com/hntrl/eventkit/commit/1371b774b5409b5aa45e56fb215b27ab7233bd9b) Thanks [@hntrl](https://github.com/hntrl)! - Introduces `SingletonAsyncObservable`; a utility class for observables that lets you access the value emitted by observables that emit one (and only one) value (like the observables returned from `reduce()`, `count()`, etc.) using native await syntax. 29 | 30 | This makes the consumption of these single value operators a little bit more readable. For instance: 31 | 32 | ```ts 33 | const obs = AsyncObservable.from([1, 2, 3]); 34 | const singleton = obs.pipe(first()); 35 | 36 | // instead of this: 37 | let firstValue: number | undefined; 38 | await obs.subscribe((value) => { 39 | firstValue = value; 40 | }); 41 | console.log(firstValue); // 1 42 | 43 | // you can just do this: 44 | console.log(await singleton); // 1 45 | ``` 46 | 47 | ### Patch Changes 48 | 49 | - Updated dependencies [[`fa3aa52`](https://github.com/hntrl/eventkit/commit/fa3aa52410d95dbe79f093f6bd992b800d4768f2)]: 50 | - @eventkit/async-observable@0.2.0 51 | 52 | ## 0.1.1 53 | 54 | ### Patch Changes 55 | 56 | - [`a84a6cd`](https://github.com/hntrl/eventkit/commit/a84a6cdbf8f9ed93bfcc97d239e0c0b5376038d1) - Fixed an issue where some operators can become permanently blocked in some runtimes 57 | 58 | - [`35f0ed7`](https://github.com/hntrl/eventkit/commit/35f0ed7feca076852c835defbede22a17210466e) - Fixed an issue where an error would be thrown if multiple eventkit packages were used in the same file 59 | 60 | - [`2c27d80`](https://github.com/hntrl/eventkit/commit/2c27d8064695e5d33039843826b147b09d6b9750) - Fixed some invariant behavior where the merge operator would wait for the scheduler promise instead of completion 61 | 62 | - Updated dependencies [[`35f0ed7`](https://github.com/hntrl/eventkit/commit/35f0ed7feca076852c835defbede22a17210466e)]: 63 | - @eventkit/async-observable@0.1.1 64 | 65 | ## 0.1.0 66 | 67 | ### Minor Changes 68 | 69 | - [`78687a5`](https://github.com/hntrl/eventkit/commit/78687a55a2d53bad9e7011c8ba3ec32625774a89) - v0.1.0 is the first official release of eventkit 🎉! Refer to the [docs](https://hntrl.github.io/eventkit) to get started. 70 | 71 | ### Patch Changes 72 | 73 | - Updated dependencies [[`78687a5`](https://github.com/hntrl/eventkit/commit/78687a55a2d53bad9e7011c8ba3ec32625774a89)]: 74 | - @eventkit/async-observable@0.1.0 75 | -------------------------------------------------------------------------------- /docs/guide/what-is-eventkit.md: -------------------------------------------------------------------------------- 1 | # What is eventkit? 2 | 3 | Eventkit is a TypeScript library designed for defining, composing, and observing asynchronous streams of data. It simplifies the handling of data streams, making it easier to build reactive and event-driven applications. 4 | 5 |
6 | 7 | Want to try it out? Jump to [Getting Started](./getting-started.md). 8 | 9 |
10 | 11 | ## Use Cases 12 | 13 | - **Real-Time Applications**: Eventkit's ability to handle real-time data streams makes it ideal for building applications that require fast, responsive user interfaces. If you're developing dashboards, chat applications, or live analytics tools, eventkit provides the primitives needed to process and react to continuous data flows without the mountains of boilerplate. 14 | 15 | - **Reactive Programming**: Eventkit is built on well-established reactive principles which allows you to build applications that respond to changes in data in a declarative manner. The library's composable operators let you transform, filter, and combine streams, creating complex data processing pipelines with clean, maintainable code that accurately models your business logic. 16 | 17 | - **Web or Server**: Eventkit works natively in any modern JavaScript runtime, making it versatile for frontend applications, backend services, or full-stack implementations. The same code can power real-time UI updates in the browser and handle high-throughput event processing on the server, simplifying your architecture. (eventkit even gives you the tools for having streams communicate between the browser and server!) 18 | 19 | - **Data Processing**: Build efficient data transformation pipelines that process, filter, and aggregate streams of information with minimal overhead. Eventkit's scheduling capabilities give you fine-grained control over how and when work is executed, allowing you to optimize for throughput, latency, or resource usage depending on your specific requirements. 20 | 21 | ## Basic Example 22 | 23 | ```ts 24 | import { Stream, filter } from "@eventkit/base"; 25 | 26 | // Create a stream of events 27 | const stream = new Stream<{ type: string; payload: any }>(); 28 | 29 | // Filter for specific event types 30 | const userEvents = stream.pipe(filter((event) => event.type.startsWith("user."))); 31 | // Subscribe to the filtered stream 32 | userEvents.subscribe((event) => { 33 | console.log(`Received user event: ${event.type}`); 34 | }); 35 | 36 | // Push events to the stream 37 | stream.push({ type: "user.login", payload: { userId: "123" } }); 38 | stream.push({ type: "system.update", payload: { version: "1.0.1" } }); // This won't be logged 39 | 40 | // Wait for all events to be processed 41 | await stream.drain(); 42 | ``` 43 | 44 | ## Features 45 | 46 | ### ⚡️ Powerful Async Primitives 47 | 48 | Eventkit provides robust primitives for working with asynchronous data: 49 | 50 | - **AsyncObservable**: A powerful implementation of the observable pattern that works with async iterators, allowing you to handle multiple values over time. 51 | - **Stream**: A specialized observable that can be pushed to indefinitely, perfect for modeling event streams and real-time data. 52 | - **Operators**: A rich set of composable operators for transforming, filtering, and combining streams of data. 53 | 54 | ### ⏱️ Fine-Grained Control 55 | 56 | One of eventkit's most powerful features is its scheduling system: 57 | 58 | - **Independent Control**: Every observable can adopt different async behaviors, giving you precise control over execution timing. 59 | - **Side Effect Management**: Control exactly how and when side effects are executed in your application. 60 | - **Consistency Guarantees**: Use `drain()` to wait for specific operations to complete before proceeding, enabling strong consistency when needed. 61 | 62 | ### 🔍 Type-Safe by Design 63 | 64 | Eventkit is built with TypeScript from the ground up: 65 | 66 | - **Full Type Safety**: Get compile-time checks and IDE autocompletion for all operations. 67 | - **Predictable Interfaces**: Well-defined interfaces make it easy to understand how components interact. 68 | - **Error Handling**: Comprehensive error propagation ensures issues are caught and handled appropriately. 69 | 70 | ### 🔄 Composable Architecture 71 | 72 | Build complex data flows from simple building blocks: 73 | 74 | - **Pipe-Based API**: Chain operators together to create sophisticated data transformation pipelines. 75 | - **Reusable Components**: Create and share common patterns across your application. 76 | - **Declarative Style**: Express what you want to happen, not how it should happen. 77 | -------------------------------------------------------------------------------- /packages/async-observable/lib/utils/promise.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "./signal"; 2 | 3 | /** 4 | * Represents the resolution of a set of promises that are not immediately known when the 5 | * PromiseSet is created. Whereas `Promise.all` tracks the resolution of promises that are 6 | * passed in the constructor, PromiseSet tracks the resolution of promises that are added 7 | * using the `add` method. 8 | * 9 | * When a PromiseSet is awaited, the promise being observed won't resolve until all of the 10 | * promises added to the set have resolved, including any promises that are added in the time it 11 | * takes for any previous promises to resolve. 12 | * 13 | * Note that a PromiseSet is primarily used to track the resolution of promises added to it, but 14 | * not necessarily the values that get returned since we can't yield that in a meaningful way 15 | * 16 | * @internal 17 | */ 18 | export class PromiseSet implements PromiseLike { 19 | /** @internal */ 20 | _currentPromise: Promise | null = null; 21 | /** @internal */ 22 | _signal: Signal | null = null; 23 | /** @internal */ 24 | _error: any | null = null; 25 | 26 | /** 27 | * Adds a promise that will be tracked by the PromiseSet. 28 | */ 29 | add(promise: PromiseLike) { 30 | const nextPromise = Promise.all([this._currentPromise, promise]).then(() => Promise.resolve()); 31 | this._currentPromise = nextPromise; 32 | nextPromise.then( 33 | () => this._resolve(nextPromise), 34 | (error) => this._reject(error) 35 | ); 36 | } 37 | 38 | private _resolve(nextPromise: Promise) { 39 | // If the current promise is not the latest one, we shouldn't resolve the signal. This is what 40 | // makes promise sets work -- This function will get called every time the promise created in 41 | // `add` is resolved, but we don't resolve the signal unless the latest version of the chain is 42 | // passed in. 43 | if (this._currentPromise !== nextPromise) return; 44 | 45 | // The promise chain is resolved, so we can reset the promise chain. 46 | this._currentPromise = null; 47 | 48 | // If there isn't a current signal, there's nothing to resolve 49 | if (!this._signal) return; 50 | 51 | // Resolve the signal 52 | this._signal.resolve(); 53 | this._signal = null; 54 | } 55 | 56 | /** @internal */ 57 | private _reject(error: any) { 58 | // Throw out the promise chain since it's been rejected 59 | this._currentPromise = null; 60 | 61 | // Set the error state of this promise set 62 | this._error = error; 63 | 64 | // If there isn't a current signal, there's nothing to reject 65 | if (!this._signal) return; 66 | 67 | // Otherwise, reject the signal 68 | this._signal.reject(error); 69 | this._signal = null; 70 | } 71 | 72 | /** 73 | * Returns a promise that will resolve when all of the promises added to the set have resolved, 74 | * and reject if any of the promises added to the set have rejected. This method makes this object 75 | * a "custom thennable", which means that this is the logic that will be applied when the set is 76 | * used in an await statement. 77 | */ 78 | then( 79 | onfulfilled?: ((value: void) => TResult1 | PromiseLike) | null, 80 | onrejected?: ((reason: any) => TResult2 | PromiseLike) | null 81 | ): Promise { 82 | // If there is an error, we can just reject immediately 83 | if (this._error) { 84 | return Promise.reject(this._error).then(onfulfilled, onrejected); 85 | } 86 | // If there isn't any work being done, we can just resolve immediately 87 | if (!this._currentPromise) { 88 | return Promise.resolve().then(onfulfilled, onrejected); 89 | } 90 | // if there isn't an existing signal already, we need to create one 91 | if (!this._signal) { 92 | this._signal = new Signal(); 93 | } 94 | return this._signal.then(onfulfilled, onrejected); 95 | } 96 | 97 | /** 98 | * Adds a catch handler that will get called if any of the promises added to the set have 99 | * rejected. 100 | */ 101 | catch( 102 | onrejected?: ((reason: any) => TResult | PromiseLike) | null 103 | ): Promise { 104 | return this.then(undefined, onrejected); 105 | } 106 | 107 | /** 108 | * Adds a handler that will get called when all of the promises added to the set have 109 | * resolved, or if any of the promises added to the set have rejected. 110 | */ 111 | finally(onfinally?: (() => void) | null): Promise { 112 | return this.then().finally(onfinally); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /packages/eventkit/__tests__/operators/isEmpty.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { SingletonAsyncObservable } from "../../lib/singleton"; 3 | import { AsyncObservable } from "@eventkit/async-observable"; 4 | import { isEmpty } from "../../lib/operators/isEmpty"; 5 | 6 | describe("isEmpty", () => { 7 | it("should return a SingletonAsyncObservable", async () => { 8 | const obs = AsyncObservable.from([1, 2, 3]); 9 | const result = obs.pipe(isEmpty()); 10 | expect(result).toBeInstanceOf(SingletonAsyncObservable); 11 | }); 12 | 13 | describe("when source emits values", () => { 14 | it("should emit false immediately when first value is emitted", async () => { 15 | const source = AsyncObservable.from([1, 2, 3]); 16 | const result = await source.pipe(isEmpty()); 17 | expect(result).toBe(false); 18 | }); 19 | 20 | it("should cancel the source after emitting false", async () => { 21 | const cancelSpy = vi.fn(); 22 | const nextSpy = vi.fn(); 23 | 24 | const source = new AsyncObservable(async function* () { 25 | try { 26 | yield 1; 27 | nextSpy(); 28 | yield 2; 29 | yield 3; 30 | } finally { 31 | cancelSpy(); 32 | } 33 | }); 34 | 35 | await source.pipe(isEmpty()); 36 | expect(cancelSpy).toHaveBeenCalledTimes(1); 37 | expect(nextSpy).not.toHaveBeenCalled(); 38 | }); 39 | 40 | it("should work with await syntax", async () => { 41 | const source = AsyncObservable.from(["a", "b", "c"]); 42 | const result = await source.pipe(isEmpty()); 43 | expect(result).toBe(false); 44 | }); 45 | 46 | it("should handle any type of value", async () => { 47 | const numSource = AsyncObservable.from([1]); 48 | const strSource = AsyncObservable.from(["string"]); 49 | const objSource = AsyncObservable.from([{}]); 50 | const boolSource = AsyncObservable.from([true]); 51 | 52 | expect(await numSource.pipe(isEmpty())).toBe(false); 53 | expect(await strSource.pipe(isEmpty())).toBe(false); 54 | expect(await objSource.pipe(isEmpty())).toBe(false); 55 | expect(await boolSource.pipe(isEmpty())).toBe(false); 56 | }); 57 | }); 58 | 59 | describe("when source is empty", () => { 60 | it("should emit true when source completes without values", async () => { 61 | const source = AsyncObservable.from([]); 62 | const result = await source.pipe(isEmpty()); 63 | expect(result).toBe(true); 64 | }); 65 | 66 | it("should work with await syntax", async () => { 67 | const source = AsyncObservable.from([]); 68 | const result = await source.pipe(isEmpty()); 69 | expect(result).toBe(true); 70 | }); 71 | 72 | it("should handle empty observables", async () => { 73 | const source = new AsyncObservable(async function* () { 74 | // Empty generator that just completes 75 | }); 76 | 77 | const result = await source.pipe(isEmpty()); 78 | expect(result).toBe(true); 79 | }); 80 | }); 81 | 82 | describe("when source errors", () => { 83 | it("should propagate the error to the subscriber", async () => { 84 | const error = new Error("source error"); 85 | const source = new AsyncObservable(async function* () { 86 | throw error; 87 | }); 88 | 89 | let caughtError: Error | null = null; 90 | try { 91 | await source.pipe(isEmpty()); 92 | } catch (e) { 93 | caughtError = e as Error; 94 | } 95 | 96 | expect(caughtError).toBe(error); 97 | }); 98 | 99 | it("should not emit any value", async () => { 100 | const error = new Error("source error"); 101 | const source = new AsyncObservable(async function* () { 102 | throw error; 103 | }); 104 | 105 | const nextSpy = vi.fn(); 106 | try { 107 | await source.pipe(isEmpty()).subscribe(nextSpy); 108 | } catch (e) { 109 | // Expected error 110 | } 111 | 112 | expect(nextSpy).not.toHaveBeenCalled(); 113 | }); 114 | }); 115 | 116 | describe("performance characteristics", () => { 117 | it("should not process values after emitting false", async () => { 118 | const processSpy = vi.fn(); 119 | const source = new AsyncObservable(async function* () { 120 | yield 1; 121 | processSpy(); 122 | yield 2; 123 | processSpy(); 124 | yield 3; 125 | }); 126 | 127 | await source.pipe(isEmpty()); 128 | expect(processSpy).not.toHaveBeenCalled(); 129 | }); 130 | 131 | it("should complete quickly when source is empty", async () => { 132 | const source = AsyncObservable.from([]); 133 | 134 | const startTime = performance.now(); 135 | await source.pipe(isEmpty()); 136 | const endTime = performance.now(); 137 | 138 | // Should complete very quickly (under 50ms) 139 | expect(endTime - startTime).toBeLessThan(50); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /packages/eventkit/__tests__/singleton.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from "vitest"; 2 | import { AsyncObservable } from "@eventkit/async-observable"; 3 | import { SingletonAsyncObservable, singletonFrom } from "../lib/singleton"; 4 | import { NoValuesError } from "../lib/utils/errors"; 5 | 6 | describe("SingletonAsyncObservable", () => { 7 | describe("constructor", () => { 8 | it("should create a new SingletonAsyncObservable instance", async () => { 9 | const singleton = new SingletonAsyncObservable(async function* () { 10 | yield 42; 11 | }); 12 | expect(singleton).toBeInstanceOf(SingletonAsyncObservable); 13 | }); 14 | 15 | it("should inherit from AsyncObservable", async () => { 16 | const singleton = new SingletonAsyncObservable(async function* () { 17 | yield 42; 18 | }); 19 | expect(singleton).toBeInstanceOf(AsyncObservable); 20 | }); 21 | }); 22 | 23 | describe("then method", () => { 24 | it("should implement PromiseLike interface", async () => { 25 | const singleton = new SingletonAsyncObservable(async function* () { 26 | yield 42; 27 | }); 28 | expect(typeof singleton.then).toBe("function"); 29 | }); 30 | 31 | it("should resolve with the first emitted value", async () => { 32 | const singleton = new SingletonAsyncObservable(async function* () { 33 | yield 1; 34 | yield 2; 35 | yield 3; 36 | }); 37 | const result = await singleton; 38 | expect(result).toBe(1); 39 | }); 40 | 41 | it("should throw NoValuesError when no values are emitted", async () => { 42 | const singleton = new SingletonAsyncObservable(async function* () { 43 | // No values emitted 44 | }); 45 | await expect(singleton).rejects.toThrow(NoValuesError); 46 | }); 47 | 48 | it("should cancel the subscription after the first value is emitted", async () => { 49 | const mockCancel = vi.fn(); 50 | const singleton = new SingletonAsyncObservable(async function* () { 51 | try { 52 | yield 42; 53 | yield 43; 54 | yield 44; 55 | } finally { 56 | mockCancel(); 57 | } 58 | }); 59 | 60 | await singleton; 61 | // subscriber count should be 0 after the first value is emitted 62 | expect(mockCancel).toHaveBeenCalled(); 63 | }); 64 | 65 | it("should work with await syntax", async () => { 66 | const singleton = new SingletonAsyncObservable(async function* () { 67 | yield 42; 68 | }); 69 | const result = await singleton; 70 | expect(result).toBe(42); 71 | }); 72 | 73 | it("should support onfulfilled callback", async () => { 74 | const singleton = new SingletonAsyncObservable(async function* () { 75 | yield 42; 76 | }); 77 | 78 | const result = await singleton.then((value) => value * 2); 79 | expect(result).toBe(84); 80 | }); 81 | 82 | it("should support onrejected callback for error handling", async () => { 83 | const error = new Error("Test error"); 84 | const singleton = new SingletonAsyncObservable(async function* () { 85 | throw error; 86 | }); 87 | 88 | const result = await singleton.then( 89 | () => "success", 90 | () => "error handled" 91 | ); 92 | 93 | expect(result).toBe("error handled"); 94 | }); 95 | }); 96 | }); 97 | 98 | describe("singletonFrom function", () => { 99 | it("should create a SingletonAsyncObservable from an AsyncObservable", async () => { 100 | const source = new AsyncObservable(async function* () { 101 | yield 42; 102 | }); 103 | 104 | const singleton = singletonFrom(source); 105 | expect(singleton).toBeInstanceOf(SingletonAsyncObservable); 106 | }); 107 | 108 | it("should preserve the generator function from the source", async () => { 109 | const generator = async function* () { 110 | yield 42; 111 | }; 112 | 113 | const source = new AsyncObservable(generator); 114 | const singleton = singletonFrom(source); 115 | 116 | // Test that the generator function is preserved by checking if it produces the same result 117 | const result = await singleton; 118 | expect(result).toBe(42); 119 | }); 120 | 121 | it("should preserve the scheduler from the source", async () => { 122 | const source = new AsyncObservable(async function* () { 123 | yield 42; 124 | }); 125 | 126 | const customScheduler = source._scheduler; 127 | const singleton = singletonFrom(source); 128 | 129 | expect(singleton._scheduler).toBe(customScheduler); 130 | }); 131 | 132 | it("should allow awaiting the first value", async () => { 133 | const source = new AsyncObservable(async function* () { 134 | yield 1; 135 | yield 2; 136 | yield 3; 137 | }); 138 | 139 | const singleton = singletonFrom(source); 140 | const result = await singleton; 141 | 142 | expect(result).toBe(1); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to eventkit 2 | 3 | Thanks for taking the time to contribute to eventkit! 4 | 5 | When it comes to open source, there are many different ways to contribute, all of which are valuable. Here are a few guidelines that will help you contribute to eventkit. 6 | 7 | ## Did you find a bug? 8 | 9 | - Make sure the bug wasn't already reported by searching on GitHub under [Issues](https://github.com/hntrl/eventkit/issues). 10 | - If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/hntrl/eventkit/issues/new). Be sure to include a title and clear description, as much relevant information as possible, and a code sample if applicable. If possible, use the relevant issue templates to create the issue. 11 | - If you've encountered a security issue, please see our [SECURITY.md](SECURITY.md) guide for info on how to report it. 12 | 13 | ## Proposing new or changing existing features? 14 | 15 | Please provide thoughtful comments and some sample code that show what you'd like to do with eventkit. It helps the conversation if you can show us how you're limited by the current API first before jumping to a conclusion about what needs to be changed and/or added. 16 | 17 | ## Issue not getting attention? 18 | 19 | If you need a bug fixed and nobody is fixing it, your best bet is to provide a fix for it and make a [pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request). Open source code belongs to all of us, and it's all of our responsibility to push it forward. 20 | 21 | ## Making a Pull Request? 22 | 23 | When creating the PR in GitHub, make sure that you set the base to the correct branch. If you are submitting a PR that touches any code, this should be the dev branch. You set the base in GitHub when authoring the PR with the dropdown below the "Compare changes" heading. 24 | 25 | ### Setup 26 | 27 | Before you can contribute, you'll need to fork the repo. This will look a bit different depending on what type of contribution you are making: 28 | 29 | - All new features, bug-fixes, or anything that touches material code should be branched off of and merged into the `dev` branch. 30 | - Changes that only touch documentation should be branched off of and merged into the `main` branch. 31 | 32 | The following steps will get you setup to start issuing commits: 33 | 34 | 1. Fork the repo (click the Fork button at the top right of [this page](https://github.com/hntrl/eventkit)) 35 | 2. Clone your fork locally 36 | 37 | ```bash 38 | # in a terminal, cd to parent directory where you want your clone to be, then 39 | git clone https://github.com//eventkit.git 40 | cd eventkit 41 | 42 | # if you are making *any* code changes, make sure to checkout the dev branch 43 | git checkout dev 44 | ``` 45 | 46 | ### Tests 47 | 48 | All commits that fix bugs or add features should have tests. Do not merge code without tests! 49 | 50 | ### Docs + Examples 51 | 52 | All commits that add or change public APIs must also have accompanying updates to all relevant documentation and examples. PR's that don't have accompanying docs will not be merged. 53 | 54 | Documentation is defined using static markdown files in the `docs` directory, and using the [TypeDoc](https://typedoc.org/) comments found in the source code. You can preview the docs site locally by running `pnpm docs:dev` from the root directory. Any changes you make to either the docs or source code comments will be reflected in the docs site running locally. 55 | 56 | Once changes make their way into the `main` branch they will automatically be published to the docs site. 57 | 58 | ## Development 59 | 60 | ### Packages 61 | 62 | Eventkit uses a monorepo setup to host code for multiple packages. These packages live in the `packages` directory. 63 | 64 | We use [pnpm workspaces](https://pnpm.io/workspaces/) to manage installation of dependencies and running various scripts. To get everything installed, make sure you have [pnpm installed](https://pnpm.io/installation), and then run `pnpm install` from the repo root. 65 | 66 | ### Building 67 | 68 | Calling `pnpm build` from the root directory will run the build, which should take only a few seconds. It's important to build all the packages together because the individual packages have dependencies on one another. 69 | 70 | ### Testing 71 | 72 | Running `pnpm test` from the root directory will run **every** package's tests. If you want to run tests for a specific package, use `pnpm test --projects packages/`: 73 | 74 | ```bash 75 | # Test all packages 76 | pnpm test 77 | 78 | # Test only the base package 79 | pnpm test --projects packages/eventkit 80 | ``` 81 | 82 | ### Documenting changes 83 | 84 | We use [changesets](https://github.com/changesets/changesets) to manage the changelog and versioning of the packages. When you make any material changes to the codebase in a PR, you should document your changes by running `pnpm changeset` from the root directory. Follow the prompts to add a changelog entry, and the changelog's will be updated automatically come release time. 85 | 86 | ## New Releases 87 | 88 | Releases are managed by changesets and are automatically created when a PR is merged into `main`. When code lands in `main`, a new PR will open that will update the changelog and version of the packages. Once that PR is merged, a new release will be created and published to npm. 89 | 90 | --- 91 | 92 | This project is a volunteer effort, and we encourage you to contribute in any way you can. 93 | 94 | Thanks! ❤️ ❤️ ❤️ 95 | -------------------------------------------------------------------------------- /examples/http-streaming/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 49 | 50 | 51 |

Streaming Demo

52 | 53 |
54 |

Observable

55 |

This stream emits predefined messages with delays created from an AsyncObservable

56 | 57 |
58 |
59 | 60 |
61 |

Stream

62 |

63 | This stream emits messages that are pushed to it from any number of clients using a Stream 64 | object 65 |

66 |

67 | hint: open this in a second tab and push messages. you should see them in both tabs! 70 |

71 |
72 | 73 | 74 |
75 |
76 |
77 | 78 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /packages/async-observable/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { type AsyncObservable } from "./observable"; 2 | import { type ScheduledAction } from "./scheduler"; 3 | import { type Subscriber } from "./subscriber"; 4 | 5 | declare global { 6 | interface SymbolConstructor { 7 | readonly asyncObservable: symbol; 8 | // Ensure that the disposer symbols is defined in TypeScript 9 | readonly dispose: unique symbol; 10 | readonly asyncDispose: unique symbol; 11 | } 12 | } 13 | 14 | /** Utility Interfaces */ 15 | 16 | export type PromiseOrValue = T | Promise; 17 | 18 | /** Operator Interfaces */ 19 | 20 | /** 21 | * A function type interface that describes a function that accepts one 22 | * parameter `T` and returns another parameter `R`. 23 | * 24 | * Usually used to describe {@link OperatorFunction} - it always takes a single 25 | * parameter (the source AsyncObservable) and returns another AsyncObservable. 26 | */ 27 | export type UnaryFunction = (value: T) => R; 28 | 29 | /** 30 | * A function type interface that represents an operator that transforms an AsyncObservable of type 31 | * T into an AsyncObservable of type R. 32 | * 33 | * Operators are the building blocks for transforming, filtering, and manipulating observables. 34 | * They take a source observable as input and return a new observable with the applied 35 | * transformation. 36 | * 37 | * @template T - The type of the source AsyncObservable's values 38 | * @template R - The type of the resulting AsyncObservable's values 39 | * 40 | * @see [Transforming Data](/guide/concepts/transforming-data) 41 | */ 42 | export type OperatorFunction = UnaryFunction, AsyncObservable>; 43 | 44 | /** 45 | * A function type interface that describes a function that accepts and returns 46 | * a parameter of the same type. 47 | * 48 | * Used to describe {@link OperatorFunction} with the only one type: 49 | * `OperatorFunction`. 50 | * 51 | * @template T - The type of the source AsyncObservable's values 52 | * 53 | * @see [Transforming Data](/guide/concepts/transforming-data) 54 | */ 55 | export type MonoTypeOperatorFunction = OperatorFunction; 56 | 57 | /** Subscription Interfaces */ 58 | 59 | /** 60 | * The function signature for a subscriber callback. 61 | */ 62 | export type SubscriberCallback = (this: Subscriber, value: T) => PromiseOrValue; 63 | 64 | export interface SubscriptionLike { 65 | cancel(): Promise; 66 | } 67 | 68 | /** Scheduler Interfaces */ 69 | 70 | /** @internal */ 71 | export type SchedulerSubject = AsyncObservable | Subscriber; 72 | 73 | /** 74 | * The interface that defines the core scheduling capabilities in eventkit. 75 | * A scheduler is the logical unit that coordinates all work associated with a subject. 76 | */ 77 | export interface SchedulerLike { 78 | /** 79 | * Adds work to a subject. Work is represented as promise-like objects. 80 | * A subject is considered "complete" when all of its work promises have resolved. 81 | * @param subject The observable or subscriber to add work to 82 | * @param promise The work to be added, represented as a promise 83 | */ 84 | add(subject: SchedulerSubject, promise: PromiseLike): void; 85 | 86 | /** 87 | * Schedules work to be executed for a subject. This may internally call `add()` 88 | * to add the work and orchestrate/defer/forward the work's execution if needed. 89 | * @param subject The observable or subscriber to schedule work for 90 | * @param action The scheduled action representing the work to be executed 91 | */ 92 | schedule(subject: SchedulerSubject, action: ScheduledAction): void; 93 | 94 | /** 95 | * Returns a promise that resolves when all work associated with the subject is complete. 96 | * This is what's called when you `await observable.drain()` or `await subscriber`. 97 | * @param subject The observable or subscriber to wait for completion 98 | * @returns A promise that resolves when all work is complete 99 | */ 100 | promise(subject: SchedulerSubject): Promise; 101 | 102 | /** 103 | * Disposes of a subject early, canceling any pending work. 104 | * This is what's called when you `await observable.cancel()` or `await subscriber.cancel()`. 105 | * @param subject The observable or subscriber to dispose 106 | * @returns A promise that resolves when disposal is complete 107 | */ 108 | dispose(subject: SchedulerSubject): Promise; 109 | } 110 | 111 | /** Observable Interfaces */ 112 | 113 | /** 114 | * Describes what types can be used as observable inputs in the {@link from} function 115 | * 116 | * @template T - The type of the values emitted by an AsyncObservable created from this input 117 | */ 118 | export type AsyncObservableInput = 119 | | AsyncObservable 120 | | InteropAsyncObservable 121 | | ArrayLike 122 | | PromiseLike 123 | | AsyncIterable 124 | | Iterable 125 | | ReadableStreamLike; 126 | 127 | /** @ignore */ 128 | export interface InteropAsyncObservable { 129 | [Symbol.asyncObservable](): AsyncIterable; 130 | } 131 | 132 | /** Other Interfaces */ 133 | 134 | /** 135 | * Extracts the type from an `AsyncObservableInput`. If you have 136 | * `O extends AsyncObservableInput` and you pass in 137 | * `AsyncObservable`, or `Promise`, etc, it will type as 138 | * `number`. 139 | * @template O - The type thats emitted by the observable type 140 | */ 141 | export type ObservedValueOf = O extends AsyncObservableInput ? T : never; 142 | 143 | /** 144 | * The base signature eventkit will look for to identify and use 145 | * a [ReadableStream](https://streams.spec.whatwg.org/#rs-class) 146 | * as an {@link AsyncObservableInput} source. 147 | */ 148 | export type ReadableStreamLike = Pick, "getReader">; 149 | -------------------------------------------------------------------------------- /packages/eventkit/lib/operators/retry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type SchedulerSubject, 3 | type SchedulerLike, 4 | PassthroughScheduler, 5 | type OperatorFunction, 6 | type ScheduledAction, 7 | CallbackAction, 8 | } from "@eventkit/async-observable"; 9 | 10 | import { withOwnScheduler } from "./withScheduler"; 11 | 12 | /** 13 | * Configuration options for the retry operator. 14 | * 15 | * This type defines the parameters that control retry behavior when an error occurs 16 | * in an observable chain. It allows configuring the number of retry attempts, 17 | * delay between retries, and the backoff strategy for increasing delays. 18 | */ 19 | export type RetryStrategy = { 20 | /** 21 | * Maximum number of retry attempts before giving up. 22 | * If not specified, defaults to 1. 23 | */ 24 | limit?: number; 25 | /** 26 | * Time in milliseconds to wait before each retry attempt. 27 | * Required when using a backoff strategy. 28 | */ 29 | delay?: number; 30 | /** 31 | * Strategy for increasing delay between retry attempts. 32 | */ 33 | backoff?: RetryBackoff; 34 | }; 35 | 36 | /** 37 | * Defines how the delay between retry attempts increases. 38 | * 39 | * - `constant`: The delay remains the same for all retry attempts 40 | * - `linear`: The delay increases linearly with each retry (delay * retryCount) 41 | * - `exponential`: The delay increases exponentially with each retry (delay * (2^retryCount)) 42 | */ 43 | export type RetryBackoff = "constant" | "linear" | "exponential"; 44 | 45 | /** 46 | * A scheduler that implements retry logic for callback actions. 47 | * 48 | * This scheduler wraps callback actions with retry logic that will catch errors 49 | * and retry the callback according to the specified retry strategy. The retry 50 | * strategy can include a limit on the number of retries, a delay between retries, 51 | * and a backoff strategy for increasing the delay between retries. 52 | * 53 | * When an error occurs in a callback action, the scheduler will: 54 | * 1. Catch the error 55 | * 2. If retries are not exhausted, wait for the specified delay 56 | * 3. Retry the callback 57 | * 4. If all retries are exhausted, reject the action's signal with the error 58 | * 59 | * Note that this only affects callback actions (subscriber callbacks). Other types 60 | * of actions like generator execution or cleanup work are passed through to the 61 | * parent scheduler without retry logic. 62 | * 63 | * @see {@link retry} for usage with observables 64 | * @internal 65 | */ 66 | export class RetryScheduler extends PassthroughScheduler implements SchedulerLike { 67 | private readonly limit: number; 68 | private readonly delay: number; 69 | private readonly backoff: RetryBackoff; 70 | 71 | constructor( 72 | protected readonly strategy: RetryStrategy, 73 | protected readonly parent: SchedulerLike, 74 | protected readonly pinningSubject?: SchedulerSubject 75 | ) { 76 | super(parent, pinningSubject); 77 | this.limit = strategy.limit ?? 1; 78 | this.delay = strategy.delay ?? 0; 79 | this.backoff = strategy.backoff ?? "constant"; 80 | } 81 | 82 | schedule(subject: SchedulerSubject, action: ScheduledAction): void { 83 | if (action instanceof CallbackAction) { 84 | // Retry works by hijacking any callback action and wrapping it in a new one that catches 85 | // any errors and retries the callback according to the retry strategy. Because of the way 86 | // that scheduled actions work (an action's callback is guaranteed to only be called 87 | // once), the status of the provided action will be reflectively updated based on the status 88 | // of the retry instead of in its own execution. 89 | super.schedule( 90 | subject, 91 | new CallbackAction(async () => { 92 | let retryCount = 0; 93 | let currentDelay = this.delay; 94 | action._hasExecuted = true; 95 | 96 | while (retryCount <= this.limit) { 97 | try { 98 | const result = await action.callback(); 99 | action.signal.resolve(result); 100 | break; 101 | } catch (err) { 102 | // All retries exhausted, let the error propagate 103 | if (retryCount === this.limit) { 104 | // Avoids unhandled promise rejection for the signal, since the error is re-thrown 105 | // here it shouldn't be an issue 106 | action.signal.then(null, () => {}); 107 | action.signal.reject(err); 108 | throw err; 109 | } 110 | retryCount++; 111 | if (this.delay > 0) { 112 | await new Promise((resolve) => setTimeout(resolve, currentDelay)); 113 | // Calculate the next delay based on the backoff strategy 114 | if (this.backoff === "linear") currentDelay += this.delay; 115 | else if (this.backoff === "exponential") currentDelay *= 2; 116 | else if (this.backoff === "constant") currentDelay = this.delay; 117 | } 118 | } 119 | } 120 | }) 121 | ); 122 | } else { 123 | super.schedule(subject, action); 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * Returns an observable that will retry a callback action according to the provided retry strategy 130 | * if an error occurs. 131 | * 132 | * Note: this will only retry errors that happen in subscriber callbacks. If an error occurs 133 | * elsewhere (like in cleanup or in the observable's generator), that implies a cancellation and 134 | * the error will be raised as normal. 135 | * 136 | * @param strategy - The strategy for the retry scheduler. 137 | * @group Operators 138 | */ 139 | export function retry(strategy?: RetryStrategy): OperatorFunction { 140 | return (source) => { 141 | const scheduler = new RetryScheduler(strategy ?? {}, source._scheduler, source); 142 | return source.pipe(withOwnScheduler(scheduler)); 143 | }; 144 | } 145 | -------------------------------------------------------------------------------- /packages/eventkit/__tests__/operators/count.spec.ts: -------------------------------------------------------------------------------- 1 | import { AsyncObservable } from "@eventkit/async-observable"; 2 | import { count } from "../../lib/operators/count"; 3 | import { vi, describe, it, expect } from "vitest"; 4 | 5 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 6 | 7 | describe("count", () => { 8 | describe("when source completes", () => { 9 | it("should emit final count", async () => { 10 | const source = AsyncObservable.from([1, 2, 3, 4, 5]); 11 | 12 | const result: number[] = []; 13 | await source.pipe(count()).subscribe((value) => { 14 | result.push(value); 15 | }); 16 | 17 | expect(result).toEqual([5]); 18 | }); 19 | 20 | it("should complete after emitting count", async () => { 21 | const source = AsyncObservable.from([1, 2, 3]); 22 | 23 | const completionSpy = vi.fn(); 24 | const sub = source.pipe(count()).subscribe(() => {}); 25 | sub.finally(completionSpy); 26 | 27 | await sub; 28 | expect(completionSpy).toHaveBeenCalledTimes(1); 29 | }); 30 | 31 | it("should emit final count using singleton object", async () => { 32 | const source = AsyncObservable.from([1, 2, 3]); 33 | expect(await source.pipe(count())).toEqual(3); 34 | }); 35 | }); 36 | 37 | describe("when source emits no values", () => { 38 | it("should emit 0", async () => { 39 | const source = AsyncObservable.from([]); 40 | 41 | const result: number[] = []; 42 | await source.pipe(count()).subscribe((value) => { 43 | result.push(value); 44 | }); 45 | 46 | expect(result).toEqual([0]); 47 | }); 48 | 49 | it("should emit 0 using singleton object", async () => { 50 | const source = AsyncObservable.from([]); 51 | expect(await source.pipe(count())).toEqual(0); 52 | }); 53 | }); 54 | 55 | describe("when source emits multiple values", () => { 56 | it("should count all values when no predicate is provided", async () => { 57 | const source = AsyncObservable.from([1, 2, 3, 4, 5]); 58 | 59 | const result: number[] = []; 60 | await source.pipe(count()).subscribe((value) => { 61 | result.push(value); 62 | }); 63 | 64 | expect(result).toEqual([5]); 65 | }); 66 | 67 | it("should increment index for each value", async () => { 68 | const source = AsyncObservable.from(["a", "b", "c"]); 69 | const indexSpy = vi.fn(); 70 | 71 | await source 72 | .pipe( 73 | count((value, index) => { 74 | indexSpy(index); 75 | return true; 76 | }) 77 | ) 78 | .subscribe(() => {}); 79 | 80 | expect(indexSpy).toHaveBeenCalledTimes(3); 81 | expect(indexSpy).toHaveBeenNthCalledWith(1, 0); 82 | expect(indexSpy).toHaveBeenNthCalledWith(2, 1); 83 | expect(indexSpy).toHaveBeenNthCalledWith(3, 2); 84 | }); 85 | 86 | it("should emit final count using singleton object", async () => { 87 | const source = AsyncObservable.from(["a", "b", "c"]); 88 | expect(await source.pipe(count())).toEqual(3); 89 | }); 90 | }); 91 | 92 | describe("when predicate is provided", () => { 93 | it("should only count values that satisfy predicate", async () => { 94 | const source = AsyncObservable.from([1, 2, 3, 4, 5]); 95 | 96 | const result: number[] = []; 97 | await source 98 | .pipe( 99 | count((value) => value % 2 === 0) // Only count even numbers 100 | ) 101 | .subscribe((value) => { 102 | result.push(value); 103 | }); 104 | 105 | expect(result).toEqual([2]); // Only 2 and 4 are even 106 | }); 107 | 108 | it("should pass correct value and index to predicate", async () => { 109 | const source = AsyncObservable.from(["a", "b", "c"]); 110 | const predicateSpy = vi.fn().mockReturnValue(true); 111 | 112 | await source.pipe(count(predicateSpy)).subscribe(() => {}); 113 | 114 | expect(predicateSpy).toHaveBeenCalledTimes(3); 115 | expect(predicateSpy).toHaveBeenNthCalledWith(1, "a", 0); 116 | expect(predicateSpy).toHaveBeenNthCalledWith(2, "b", 1); 117 | expect(predicateSpy).toHaveBeenNthCalledWith(3, "c", 2); 118 | }); 119 | 120 | it("should handle predicate returning false for all values", async () => { 121 | const source = AsyncObservable.from([1, 2, 3, 4, 5]); 122 | 123 | const result: number[] = []; 124 | await source 125 | .pipe( 126 | count(() => false) // Predicate always returns false 127 | ) 128 | .subscribe((value) => { 129 | result.push(value); 130 | }); 131 | 132 | expect(result).toEqual([0]); // No values counted 133 | }); 134 | 135 | it("should pass final count for values that satisfy predicate using singleton object", async () => { 136 | const source = AsyncObservable.from([1, 2, 3, 4, 5]); 137 | expect(await source.pipe(count((value) => value % 2 === 0))).toEqual(2); 138 | }); 139 | }); 140 | 141 | describe("when source errors", () => { 142 | it("should propagate error", async () => { 143 | const error = new Error("test error"); 144 | const source = new AsyncObservable(async function* () { 145 | yield 1; 146 | await delay(5); 147 | throw error; 148 | }); 149 | 150 | let capturedError: Error | null = null; 151 | try { 152 | await source.pipe(count()).subscribe(() => {}); 153 | } catch (e) { 154 | capturedError = e as Error; 155 | } 156 | 157 | expect(capturedError).toBe(error); 158 | }); 159 | 160 | it("should propagate error when using singleton object", async () => { 161 | const error = new Error("test error"); 162 | const source = new AsyncObservable(async function* () { 163 | yield 1; 164 | await delay(5); 165 | throw error; 166 | }); 167 | await expect(source.pipe(count())).rejects.toThrow(error); 168 | }); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Colors: Palette 3 | * 4 | * The primitive colors used for accent colors. These colors are referenced 5 | * by functional colors such as "Text", "Background", or "Brand". 6 | * 7 | * Each colors have exact same color scale system with 3 levels of solid 8 | * colors with different brightness, and 1 soft color. 9 | * 10 | * - `XXX-1`: The most solid color used mainly for colored text. It must 11 | * satisfy the contrast ratio against when used on top of `XXX-soft`. 12 | * 13 | * - `XXX-2`: The color used mainly for hover state of the button. 14 | * 15 | * - `XXX-3`: The color for solid background, such as bg color of the button. 16 | * It must satisfy the contrast ratio with pure white (#ffffff) text on 17 | * top of it. 18 | * 19 | * - `XXX-soft`: The color used for subtle background such as custom container 20 | * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors 21 | * on top of it. 22 | * 23 | * The soft color must be semi transparent alpha channel. This is crucial 24 | * because it allows adding multiple "soft" colors on top of each other 25 | * to create a accent, such as when having inline code block inside 26 | * custom containers. 27 | * -------------------------------------------------------------------------- */ 28 | 29 | :root { 30 | --vp-c-green-1: #6db300; 31 | --vp-c-green-2: #82e000; 32 | --vp-c-green-3: #95ff00; 33 | --vp-c-green-soft: rgba(109, 179, 0, 0.15); 34 | } 35 | .dark { 36 | --vp-c-green-1: #a5ff2d; 37 | --vp-c-green-2: #95ff00; 38 | --vp-c-green-3: #7ed800; 39 | --vp-c-green-soft: rgba(149, 255, 0, 0.16); 40 | } 41 | 42 | /** 43 | * Colors: Function 44 | * 45 | * - `default`: The color used purely for subtle indication without any 46 | * special meanings attached to it such as bg color for menu hover state. 47 | * 48 | * - `brand`: Used for primary brand colors, such as link text, button with 49 | * brand theme, etc. 50 | * 51 | * - `tip`: Used to indicate useful information. The default theme uses the 52 | * brand color for this by default. 53 | * 54 | * - `warning`: Used to indicate warning to the users. Used in custom 55 | * container, badges, etc. 56 | * 57 | * - `danger`: Used to show error, or dangerous message to the users. Used 58 | * in custom container, badges, etc. 59 | * 60 | * To understand the scaling system, refer to "Colors: Palette" section. 61 | * -------------------------------------------------------------------------- */ 62 | 63 | :root { 64 | --vp-c-brand-1: var(--vp-c-green-1); 65 | --vp-c-brand-2: var(--vp-c-green-2); 66 | --vp-c-brand-3: var(--vp-c-green-3); 67 | --vp-c-brand-soft: var(--vp-c-green-soft); 68 | 69 | --vp-c-tip-1: var(--vp-c-brand-1); 70 | --vp-c-tip-2: var(--vp-c-brand-2); 71 | --vp-c-tip-3: var(--vp-c-brand-3); 72 | --vp-c-tip-soft: var(--vp-c-brand-soft); 73 | 74 | --vp-c-note-1: var(--vp-c-brand-1); 75 | --vp-c-note-2: var(--vp-c-brand-2); 76 | --vp-c-note-3: var(--vp-c-brand-3); 77 | --vp-c-note-soft: var(--vp-c-brand-soft); 78 | } 79 | 80 | /** 81 | * Colors: Background 82 | * 83 | * - `bg`: The bg color used for main screen. 84 | * 85 | * - `bg-alt`: The alternative bg color used in places such as "sidebar", 86 | * or "code block". 87 | * 88 | * - `bg-elv`: The elevated bg color. This is used at parts where it "floats", 89 | * such as "dialog". 90 | * 91 | * - `bg-soft`: The bg color to slightly distinguish some components from 92 | * the page. Used for things like "carbon ads" or "table". 93 | * -------------------------------------------------------------------------- */ 94 | :root { 95 | --vp-c-bg: #fcfcfa; 96 | --vp-c-bg-alt: #f8f8f6; 97 | --vp-c-bg-elv: #ffffff; 98 | --vp-c-bg-soft: #f6f6f4; 99 | } 100 | .dark { 101 | --vp-c-bg: #151518; 102 | --vp-c-bg-alt: #101012; 103 | --vp-c-bg-elv: #1c1c22; 104 | --vp-c-bg-soft: #212129; 105 | } 106 | 107 | /** 108 | * Colors: Borders 109 | * 110 | * - `divider`: This is used for separators. This is used to divide sections 111 | * within the same components, such as having separator on "h2" heading. 112 | * 113 | * - `border`: This is designed for borders on interactive components. 114 | * For example this should be used for a button outline. 115 | * 116 | * - `gutter`: This is used to divide components in the page. For example 117 | * the header and the lest of the page. 118 | * -------------------------------------------------------------------------- */ 119 | :root { 120 | --vp-c-divider: #e5e5e0; 121 | --vp-c-border: #c2c2c4; 122 | --vp-c-gutter: #e5e5e0; 123 | } 124 | .dark { 125 | --vp-c-divider: #27272b; 126 | --vp-c-border: #3c3f44; 127 | --vp-c-gutter: var(--vp-c-black); 128 | } 129 | 130 | /** 131 | * Component: Button 132 | * -------------------------------------------------------------------------- */ 133 | :root { 134 | --vp-button-brand-border: transparent; 135 | --vp-button-brand-text: var(--vp-c-black); 136 | --vp-button-brand-bg: var(--vp-c-brand-2); 137 | --vp-button-brand-hover-border: transparent; 138 | --vp-button-brand-hover-text: var(--vp-c-black); 139 | --vp-button-brand-hover-bg: var(--vp-c-brand-3); 140 | --vp-button-brand-active-border: transparent; 141 | --vp-button-brand-active-text: var(--vp-c-black); 142 | --vp-button-brand-active-bg: var(--vp-c-brand-1); 143 | } 144 | .dark { 145 | --vp-button-brand-text: var(--vp-c-black); 146 | --vp-button-brand-hover-text: var(--vp-c-black); 147 | --vp-button-brand-active-text: var(--vp-c-black); 148 | } 149 | 150 | /** Misc */ 151 | 152 | :root { 153 | --vp-code-block-bg: var(--vp-c-bg-soft); 154 | --vp-code-block-divider-color: var(--vp-c-divider); 155 | } 156 | 157 | .dark { 158 | --vp-code-color: var(--vp-c-brand-2); 159 | --vp-code-lang-color: #999999; 160 | } 161 | 162 | html.dark .light-only { 163 | display: none !important; 164 | } 165 | 166 | html:not(.dark) .dark-only { 167 | display: none !important; 168 | } 169 | 170 | .reference-image { 171 | padding: 24px; 172 | border-radius: 8px; 173 | border: 1px solid var(--vp-c-divider); 174 | } 175 | -------------------------------------------------------------------------------- /packages/eventkit/__tests__/operators/buffer.spec.ts: -------------------------------------------------------------------------------- 1 | import { AsyncObservable } from "@eventkit/async-observable"; 2 | import { buffer } from "../../lib/operators/buffer"; 3 | import { vi, describe, it, expect } from "vitest"; 4 | 5 | const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 6 | 7 | describe("buffer", () => { 8 | describe("when source completes", () => { 9 | it("should emit final buffer", async () => { 10 | const source = AsyncObservable.from([1, 2, 3]); 11 | const notifier = new AsyncObservable(async function* () { 12 | // Empty notifier that doesn't emit 13 | }); 14 | 15 | const result: number[][] = []; 16 | await source.pipe(buffer(notifier)).subscribe((value) => { 17 | result.push(value); 18 | }); 19 | 20 | expect(result).toEqual([[1, 2, 3]]); 21 | }); 22 | 23 | it("should complete after emitting final buffer", async () => { 24 | const source = AsyncObservable.from([1, 2, 3]); 25 | const notifier = new AsyncObservable(async function* () { 26 | // Empty notifier that doesn't emit 27 | }); 28 | 29 | const completionSpy = vi.fn(); 30 | const sub = source.pipe(buffer(notifier)).subscribe(() => {}); 31 | sub.finally(completionSpy); 32 | 33 | await sub; 34 | expect(completionSpy).toHaveBeenCalledTimes(1); 35 | }); 36 | }); 37 | 38 | describe("when pushNotifier emits", () => { 39 | it("should emit current buffer", async () => { 40 | // Create controlled source and notifier 41 | const source = new AsyncObservable(async function* () { 42 | yield 1; 43 | yield 2; 44 | await delay(10); 45 | yield 3; 46 | yield 4; 47 | }); 48 | const notifier = new AsyncObservable(async function* () { 49 | await delay(5); // Wait for first two values 50 | yield; 51 | await delay(20); // Wait for remaining values 52 | yield; 53 | }); 54 | const result: number[][] = []; 55 | await source.pipe(buffer(notifier)).subscribe((value) => { 56 | result.push(value); 57 | }); 58 | expect(result.length).toBe(3); 59 | expect(result[0]).toEqual([1, 2]); 60 | expect(result[1]).toEqual([3, 4]); 61 | expect(result[2]).toEqual([]); 62 | }); 63 | it("should start new empty buffer", async () => { 64 | // Create a source that we can control 65 | const source = new AsyncObservable(async function* () { 66 | yield 1; 67 | yield 2; 68 | await delay(50); // Wait a bit before emitting more 69 | yield 3; 70 | }); 71 | const notifier = new AsyncObservable(async function* () { 72 | await delay(5); // Wait for first values to emit 73 | yield "notify"; // Should emit buffer [1, 2] and start empty buffer 74 | }); 75 | const result: number[][] = []; 76 | await source.pipe(buffer(notifier)).subscribe((value) => { 77 | result.push(value); 78 | }); 79 | expect(result.length).toBe(2); 80 | expect(result[0]).toEqual([1, 2]); 81 | expect(result[1]).toEqual([3]); 82 | }); 83 | }); 84 | describe("when source emits multiple values", () => { 85 | it("should accumulate values in buffer until notifier emits", async () => { 86 | const source = new AsyncObservable(async function* () { 87 | yield 1; 88 | yield 2; 89 | await delay(5); 90 | yield 3; 91 | yield 4; 92 | await delay(10); 93 | yield 5; 94 | }); 95 | const notifier = new AsyncObservable(async function* () { 96 | await delay(3); // After values 1,2 are emitted 97 | yield "first"; 98 | await delay(7); // After values 3,4 are emitted 99 | yield "second"; 100 | }); 101 | const result: number[][] = []; 102 | await source.pipe(buffer(notifier)).subscribe((value) => { 103 | result.push(value); 104 | }); 105 | expect(result.length).toBe(3); 106 | expect(result[0]).toEqual([1, 2]); 107 | expect(result[1]).toEqual([3, 4]); 108 | expect(result[2]).toEqual([5]); 109 | }); 110 | }); 111 | describe("when pushNotifier completes", () => { 112 | it("should continue buffering until source completes", async () => { 113 | const source = new AsyncObservable(async function* () { 114 | yield 1; 115 | await delay(5); 116 | yield 2; 117 | await delay(5); 118 | yield 3; 119 | await delay(5); 120 | yield 4; 121 | }); 122 | // Notifier that completes immediately 123 | const notifier = AsyncObservable.from([]); 124 | const result: number[][] = []; 125 | await source.pipe(buffer(notifier)).subscribe((value) => { 126 | result.push(value); 127 | }); 128 | expect(result).toEqual([[1, 2, 3, 4]]); 129 | }); 130 | }); 131 | describe("when source errors", () => { 132 | it("should propagate error", async () => { 133 | const error = new Error("test error"); 134 | const source = new AsyncObservable(async function* () { 135 | yield 1; 136 | await delay(5); 137 | throw error; 138 | }); 139 | const notifier = new AsyncObservable(async function* () { 140 | yield 1; 141 | // Empty notifier 142 | }); 143 | let capturedError: Error | null = null; 144 | try { 145 | await source.pipe(buffer(notifier)).subscribe(() => {}); 146 | } catch (e) { 147 | capturedError = e as Error; 148 | } 149 | expect(capturedError).toBe(error); 150 | }); 151 | }); 152 | }); 153 | // describe("bufferCount", () => { 154 | // describe("when source completes", () => { 155 | // it("should emit final buffer", async () => {}); 156 | // it("should complete after emitting final buffer", async () => {}); 157 | // }); 158 | // describe("when buffer size is reached", () => { 159 | // it("should emit buffer", async () => {}); 160 | // it("should start new buffer", async () => {}); 161 | // }); 162 | // describe("when startBufferEvery is specified", () => { 163 | // it("should start new buffer at specified intervals", async () => {}); 164 | // it("should maintain correct buffer sizes", async () => {}); 165 | // }); 166 | // describe("when source emits fewer values than buffer size", () => { 167 | // it("should emit final buffer with remaining values", async () => {}); 168 | // }); 169 | // describe("when source errors", () => { 170 | // it("should propagate error", async () => {}); 171 | // }); 172 | // }); 173 | --------------------------------------------------------------------------------