├── .editorconfig ├── .eslintignore ├── .github ├── renovate.json └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .releaserc.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MIGRATION.md ├── README.md ├── SECURITY.md ├── bun.lock ├── deno.lock ├── package-lock.json ├── package.config.ts ├── package.json ├── src ├── EventSource.ts ├── errors.ts ├── index.ts └── types.ts ├── test ├── browser │ ├── browser-test.html │ ├── browser-test.ts │ └── client.browser.test.ts ├── bun │ └── client.bun.test.ts ├── deno │ └── client.deno.test.ts ├── fixtures.ts ├── helpers.ts ├── node │ └── client.node.test.ts ├── server.ts ├── tests.ts ├── type-compatible.ts └── waffletest │ ├── index.ts │ ├── reporters │ ├── defaultReporter.ts │ ├── helpers.ts │ └── nodeReporter.ts │ ├── runner.ts │ └── types.ts ├── tsconfig.dist.json ├── tsconfig.json └── tsconfig.settings.json /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /coverage 3 | /demo/dist 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "dependencyDashboard": true, 5 | "schedule": ["before 3am on Friday"], 6 | "semanticCommitType": "chore", 7 | "rangeStrategy": "bump", 8 | "packageRules": [ 9 | { 10 | "matchUpdateTypes": ["major"], 11 | "semanticCommitType": "chore", 12 | "automerge": false, 13 | "dependencyDashboardApproval": true 14 | }, 15 | { 16 | "matchUpdateTypes": ["minor", "patch"], 17 | "matchPackagePatterns": ["*"], 18 | "semanticCommitType": "chore", 19 | "schedule": ["before 3am on Friday"], 20 | "automerge": false 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | # Workflow name based on selected inputs. 4 | # Fallback to default GitHub naming when expression evaluates to empty string 5 | run-name: > 6 | ${{ inputs.release && 'Release ➤ Publish to NPM' || inputs.dryrun && 'Release ➤ Dry-run' || '' }} 7 | on: 8 | pull_request: 9 | push: 10 | branches: [main] 11 | workflow_dispatch: 12 | inputs: 13 | release: 14 | description: 'Publish new release' 15 | default: false 16 | type: boolean 17 | dryrun: 18 | description: 'Dry run' 19 | default: false 20 | type: boolean 21 | 22 | concurrency: 23 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | dryrun: 28 | # only run if opt-in during workflow_dispatch 29 | name: 'Release: Dry-run release process' 30 | if: always() && github.event.inputs.dryrun == 'true' 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | with: 35 | # Need to fetch entire commit history to 36 | # analyze every commit since last release 37 | fetch-depth: 0 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: lts/* 41 | cache: npm 42 | - run: npm ci 43 | - run: npx semantic-release --dry-run 44 | if: always() 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 48 | release: 49 | name: 'Release: Publish to NPM' 50 | permissions: 51 | issues: write # for release notes, comments… 52 | contents: write # for checkout + push + release creation 53 | id-token: write # to enable use of OIDC for npm provenanc 54 | if: always() && github.event.inputs.release == 'true' && github.event.inputs.dryrun == 'false' 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v4 58 | with: 59 | # Need to fetch entire commit history to 60 | # analyze every commit since last release 61 | fetch-depth: 0 62 | - uses: actions/setup-node@v4 63 | with: 64 | node-version: lts/* 65 | cache: npm 66 | - run: npm ci 67 | - run: npx semantic-release 68 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 69 | # e.g. git tags were pushed but it exited before `npm publish` 70 | if: always() && github.event.inputs.release == 'true' 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 74 | NPM_CONFIG_PROVENANCE: true 75 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | testBrowser: 8 | name: 'Test: Browsers' 9 | timeout-minutes: 15 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - name: Cache node modules 17 | id: cache-node-modules 18 | uses: actions/cache@v4 19 | env: 20 | cache-name: cache-node-modules 21 | with: 22 | path: '**/node_modules' 23 | key: ${{ runner.os }}-modules-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 24 | restore-keys: | 25 | ${{ runner.os }}-modules-${{ env.cache-name }}- 26 | ${{ runner.os }}-modules- 27 | ${{ runner.os }}- 28 | - name: Install dependencies 29 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 30 | run: npx playwright install && npm ci 31 | - name: Install Playwright Browsers 32 | run: npx playwright install --with-deps 33 | - name: Run browser tests 34 | run: npm run test:browser 35 | 36 | testNode: 37 | name: 'Test: Node.js ${{ matrix.node-version }}' 38 | timeout-minutes: 15 39 | runs-on: ubuntu-latest 40 | strategy: 41 | matrix: 42 | node-version: ['18.x', '20.x', '22.x'] 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: actions/setup-node@v4 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | - name: Cache node modules 49 | id: cache-node-modules 50 | uses: actions/cache@v4 51 | env: 52 | cache-name: cache-node-modules 53 | with: 54 | path: '**/node_modules' 55 | key: ${{ runner.os }}-modules-${{ env.cache-name }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 56 | restore-keys: | 57 | ${{ runner.os }}-modules-${{ env.cache-name }}--node-${{ matrix.node-version }}- 58 | ${{ runner.os }}-modules-${{ env.cache-name }} 59 | ${{ runner.os }}-modules- 60 | ${{ runner.os }}- 61 | - name: Install dependencies 62 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 63 | run: npm ci 64 | - name: Run tests 65 | run: npm run test:node 66 | 67 | testDeno: 68 | name: 'Test: Deno' 69 | timeout-minutes: 15 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: actions/checkout@v4 73 | - uses: denoland/setup-deno@v2 74 | with: 75 | deno-version: v2.x 76 | - name: Install dependencies 77 | run: deno install 78 | - name: Run tests 79 | run: npm run test:deno 80 | 81 | testBun: 82 | name: 'Test: Bun' 83 | timeout-minutes: 15 84 | runs-on: ubuntu-latest 85 | steps: 86 | - uses: actions/checkout@v4 87 | - uses: oven-sh/setup-bun@v2 88 | with: 89 | bun-version: latest 90 | - name: Install Dependencies 91 | run: bun install --frozen-lockfile 92 | - name: Run tests 93 | run: npm run test:bun 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # macOS finder cache file 40 | .DS_Store 41 | 42 | # VS Code settings 43 | .vscode 44 | 45 | # Cache 46 | .cache 47 | 48 | # Compiled output 49 | /dist 50 | 51 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [4.0.0](https://github.com/EventSource/eventsource/compare/v3.0.7...v4.0.0) (2025-05-13) 9 | 10 | ### ⚠ BREAKING CHANGES 11 | 12 | * `FetchLikeInit` is now removed. Use 13 | `EventSourceFetchInit`. 14 | * Drop support for Node.js v18, as it is end-of-life. 15 | 16 | ### Features 17 | 18 | * require node.js v20 or higher ([91a3a48](https://github.com/EventSource/eventsource/commit/91a3a486fbc73e2299fc23ef376552b4de662144)) 19 | 20 | ### Bug Fixes 21 | 22 | * drop `FetchLikeInit` type. Use `EventSourceFetchInit` instead. ([6786e46](https://github.com/EventSource/eventsource/commit/6786e467bb7d99281b42348f869006caefb33b7f)) 23 | 24 | ## [3.0.7](https://github.com/EventSource/eventsource/compare/v3.0.6...v3.0.7) (2025-05-09) 25 | 26 | ### Bug Fixes 27 | 28 | * mark fetch init properties required in typings ([1282872](https://github.com/EventSource/eventsource/commit/12828720c73c6df0561dd4b0f4d37dc7a3ddd59f)) 29 | 30 | ## [3.0.6](https://github.com/EventSource/eventsource/compare/v3.0.5...v3.0.6) (2025-03-27) 31 | 32 | ### Bug Fixes 33 | 34 | * upgrade parser to latest version, improving performance ([59a5ddd](https://github.com/EventSource/eventsource/commit/59a5ddd135da957456ab93e72fd65a87a9578204)) 35 | 36 | ## [3.0.5](https://github.com/EventSource/eventsource/compare/v3.0.4...v3.0.5) (2025-01-28) 37 | 38 | ### Bug Fixes 39 | 40 | * include `message` and `code` on errors when logging in node.js and deno ([f2596b3](https://github.com/EventSource/eventsource/commit/f2596b34de972db0c979651173ab9dc37d3ad1a6)) 41 | 42 | ## [3.0.4](https://github.com/EventSource/eventsource/compare/v3.0.3...v3.0.4) (2025-01-28) 43 | 44 | ### Bug Fixes 45 | 46 | * ensure `message` is set on ErrorEvent on network errors ([d1dc711](https://github.com/EventSource/eventsource/commit/d1dc71170750112e7e0d33cafd4aa4e0ab3c536c)) 47 | 48 | ## [3.0.3](https://github.com/EventSource/eventsource/compare/v3.0.2...v3.0.3) (2025-01-27) 49 | 50 | ### Bug Fixes 51 | 52 | * bundle event listener typings ([2c51349](https://github.com/EventSource/eventsource/commit/2c51349fc239f69e4333e92bfe0cd783881ed290)) 53 | 54 | ## [3.0.2](https://github.com/EventSource/eventsource/compare/v3.0.1...v3.0.2) (2024-12-13) 55 | 56 | ### Bug Fixes 57 | 58 | * reference possibly missing event typings ([d3b6849](https://github.com/EventSource/eventsource/commit/d3b684996f9653ebe59454cef7e8437c0a31c9b8)) 59 | 60 | ## [3.0.1](https://github.com/EventSource/eventsource/compare/v3.0.0...v3.0.1) (2024-12-07) 61 | 62 | ### Bug Fixes 63 | 64 | * run build prior to publishing ([f86df19](https://github.com/EventSource/eventsource/commit/f86df19333b0e4f06a20d79458d52a2b57265e74)) 65 | 66 | ## [3.0.0](https://github.com/EventSource/eventsource/compare/v2.0.2...v3.0.0) (2024-12-07) 67 | 68 | ### ⚠ BREAKING CHANGES 69 | 70 | * Drop support for Node.js versions below v18 71 | * The module now uses a named export instead of a default export. 72 | * UMD bundle dropped. Use a bundler. 73 | * `headers` in init dict dropped, pass a custom `fetch` function instead. 74 | * HTTP/HTTPS proxy support dropped. Pass a custom `fetch` function instead. 75 | * `https.*` options dropped. Pass a custom `fetch` function that provides an agent/dispatcher instead. 76 | * New default reconnect delay: 3 seconds instead of 1 second. 77 | * Reconnecting after a redirect will now always use the original URL, even if the status code was HTTP 307. 78 | 79 | ### Features 80 | 81 | * modernize - use `fetch`, WebStreams, TypeScript, ESM ([#330](https://github.com/EventSource/eventsource/issues/330)) ([40655f7](https://github.com/EventSource/eventsource/commit/40655f7c418b8fff274e471c47f5fd2acd056318)) 82 | 83 | ### Bug Fixes 84 | 85 | * `dispatchEvent` now emits entire event object ([eb430c0](https://github.com/EventSource/eventsource/commit/eb430c0d70941956fb1042b946806c3adef94061)) 86 | * empty options no longer disable certificate checks ([372d387](https://github.com/EventSource/eventsource/commit/372d387b0ca0046e798f272bbe8f42a002103c3a)) 87 | 88 | ## [2.0.2](https://github.com/EventSource/eventsource/compare/v2.0.1...v2.0.2) (2022-05-12) 89 | 90 | 91 | ### Bug Fixes 92 | 93 | * strip sensitive headers on redirect to different origin ([10ee0c4](https://github.com/EventSource/eventsource/commit/10ee0c4881a6ba2fe65ec18ed195ac35889583c4)) 94 | 95 | 96 | ## [2.0.1](https://github.com/EventSource/eventsource/compare/v2.0.0...v2.0.1) (2022-04-25) 97 | 98 | ### Bug Fixes 99 | 100 | * Fix `URL is not a constructor` error for browser ([#268](https://github.com/EventSource/eventsource/pull/268) Ajinkya Rajput) 101 | 102 | ## [2.0.0](https://github.com/EventSource/eventsource/compare/v1.1.0...v2.0.0) (2022-03-02) 103 | 104 | ### ⚠ BREAKING CHANGES 105 | 106 | * Node >= 12 now required ([#152](https://github.com/EventSource/eventsource/pull/152) @HonkingGoose) 107 | 108 | ### Bug Fixes 109 | 110 | * Preallocate buffer size when reading data for increased performance with large messages ([#239](https://github.com/EventSource/eventsource/pull/239) Pau Freixes) 111 | * Removed dependency on url-parser. Fixes [CVE-2022-0512](https://www.whitesourcesoftware.com/vulnerability-database/CVE-2022-0512) & [CVE-2022-0691](https://nvd.nist.gov/vuln/detail/CVE-2022-0691) ([#249](https://github.com/EventSource/eventsource/pull/249) Alex Hladin) 112 | 113 | ### Bug Fixes 114 | 115 | * NPM download badge links to malware ([8954d63](https://github.com/EventSource/eventsource/commit/8954d633f0b222a79d1650b05f37e9d118c27ed5)) 116 | 117 | ## [1.1.2](https://github.com/EventSource/eventsource/compare/v1.1.1...v1.1.2) (2022-06-08) 118 | 119 | ### Features 120 | 121 | * Inline origin resolution, drops `original` dependency ([#281](https://github.com/EventSource/eventsource/pull/281) Espen Hovlandsdal) 122 | 123 | ## [1.1.1](https://github.com/EventSource/eventsource/compare/v1.1.0...v1.1.1) (2022-05-11) 124 | 125 | ### Bug Fixes 126 | 127 | * Do not include authorization and cookie headers on redirect to different origin ([#273](https://github.com/EventSource/eventsource/pull/273) Espen Hovlandsdal) 128 | 129 | ## [1.1.0](https://github.com/EventSource/eventsource/compare/v1.0.7...v1.1.0) (2021-03-18) 130 | 131 | ### Features 132 | 133 | * Improve performance for large messages across many chunks ([#130](https://github.com/EventSource/eventsource/pull/130) Trent Willis) 134 | * Add `createConnection` option for http or https requests ([#120](https://github.com/EventSource/eventsource/pull/120) Vasily Lavrov) 135 | * Support HTTP 302 redirects ([#116](https://github.com/EventSource/eventsource/pull/116) Ryan Bonte) 136 | 137 | ### Bug Fixes 138 | 139 | * Prevent sequential errors from attempting multiple reconnections ([#125](https://github.com/EventSource/eventsource/pull/125) David Patty) 140 | * Add `new` to correct test ([#111](https://github.com/EventSource/eventsource/pull/101) Stéphane Alnet) 141 | * Fix reconnections attempts now happen more than once ([#136](https://github.com/EventSource/eventsource/pull/136) Icy Fish) 142 | 143 | ## [1.0.7](https://github.com/EventSource/eventsource/compare/v1.0.6...v1.0.7) (2018-08-27) 144 | 145 | ### Features 146 | 147 | * Add dispatchEvent to EventSource ([#101](https://github.com/EventSource/eventsource/pull/101) Ali Afroozeh) 148 | * Added `checkServerIdentity` option ([#104](https://github.com/EventSource/eventsource/pull/104) cintolas) 149 | * Surface request error message ([#107](https://github.com/EventSource/eventsource/pull/107) RasPhilCo) 150 | 151 | ## [1.0.6](https://github.com/EventSource/eventsource/compare/v1.0.5...v1.0.6) (2018-08-23) 152 | 153 | ### Bug Fixes 154 | 155 | * Fix issue where a unicode sequence split in two chunks would lead to invalid messages ([#108](https://github.com/EventSource/eventsource/pull/108) Espen Hovlandsdal) 156 | 157 | ## [1.0.5](https://github.com/EventSource/eventsource/compare/v1.0.4...v1.0.5) (2017-07-18) 158 | 159 | ### Bug Fixes 160 | 161 | * Check for `window` existing before polyfilling. ([#80](https://github.com/EventSource/eventsource/pull/80) Neftaly Hernandez) 162 | 163 | ## [1.0.4](https://github.com/EventSource/eventsource/compare/v1.0.2...v1.0.4) (2017-06-19) 164 | 165 | ### Bug Fixes 166 | 167 | * Pass withCredentials on to the XHR. ([#79](https://github.com/EventSource/eventsource/pull/79) Ken Mayer) 168 | 169 | ## [1.0.2](https://github.com/EventSource/eventsource/compare/v1.0.1...v1.0.2) (2017-05-28) 170 | 171 | ### Bug Fixes 172 | 173 | * Fix proxy not working when proxy and target URL uses different protocols. ([#76](https://github.com/EventSource/eventsource/pull/76) Espen Hovlandsdal) 174 | * Make `close()` a prototype method instead of an instance method. ([#77](https://github.com/EventSource/eventsource/pull/77) Espen Hovlandsdal) 175 | 176 | ## [1.0.1](https://github.com/EventSource/eventsource/compare/v1.0.0...v1.0.1) (2017-05-10) 177 | 178 | ### Bug Fixes 179 | 180 | * Reconnect if server responds with HTTP 500, 502, 503 or 504. ([#74](https://github.com/EventSource/eventsource/pull/74) Vykintas Narmontas) 181 | 182 | ## [1.0.0](https://github.com/EventSource/eventsource/compare/v0.2.3...v1.0.0) (2017-04-17) 183 | 184 | ### Features 185 | 186 | * Add missing `removeEventListener`-method. ([#51](https://github.com/EventSource/eventsource/pull/51) Yucheng Tu / Espen Hovlandsdal) 187 | * Add ability to customize https options. ([#53](https://github.com/EventSource/eventsource/pull/53) Rafael Alfaro) 188 | * Add readyState constants to EventSource instances. ([#66](https://github.com/EventSource/eventsource/pull/66) Espen Hovlandsdal) 189 | 190 | ### Bug Fixes 191 | 192 | * Fix EventSource reconnecting on non-200 responses. ([af84476](https://github.com/EventSource/eventsource/commit/af84476b519a01e61b8c80727261df52ae40022c) Espen Hovlandsdal) 193 | 194 | ## [0.2.3](https://github.com/EventSource/eventsource/compare/v0.2.2...v0.2.3) (2017-04-17) 195 | 196 | ### Bug Fixes 197 | 198 | * Fix `onConnectionClosed` firing multiple times resulting in multiple connections. ([#61](https://github.com/EventSource/eventsource/pull/61) Phil Strong / Duncan Wong) 199 | 200 | ### Reverts 201 | 202 | * Revert "Protects against multiple connects" ([3887a4a](https://github.com/EventSource/eventsource/commit/3887a4af701c3ec307d5866f26eb442433d43fda)) 203 | 204 | ## [0.2.2](https://github.com/EventSource/eventsource/compare/v0.2.1...v0.2.2) (2017-02-28) 205 | 206 | ### Bug Fixes 207 | 208 | * Don't include test files in npm package. ([#56](https://github.com/EventSource/eventsource/pull/56) eanplatter) 209 | 210 | ## [0.2.1](https://github.com/EventSource/eventsource/compare/v0.2.0...v0.2.1) (2016-02-28) 211 | 212 | ### Features 213 | 214 | * Add http/https proxy function. ([#46](https://github.com/EventSource/eventsource/pull/46) Eric Lu) 215 | * Drop support for Node 0.10.x and older (Aslak Hellesøy). 216 | 217 | ### Bug Fixes 218 | 219 | * Fix `close()` for polyfill. ([#52](https://github.com/EventSource/eventsource/pull/52) brian-medendorp) 220 | * Fix reconnect for polyfill. Only disable reconnect when server status is 204. (Aslak Hellesøy). 221 | 222 | ## [0.2.0](https://github.com/EventSource/eventsource/compare/v0.1.6...v0.2.0) (2016-02-11) 223 | 224 | ### Features 225 | 226 | * Renamed repository to `eventsource` (since it's not just Node, but also browser polyfill). (Aslak Hellesøy). 227 | * Compatibility with webpack/browserify. ([#44](https://github.com/EventSource/eventsource/pull/44) Adriano Raiano). 228 | 229 | ## [0.1.6](https://github.com/EventSource/eventsource/compare/v0.1.5...v0.1.6) (2015-02-09) 230 | 231 | ### Bug Fixes 232 | 233 | * Ignore headers without a value. ([#41](https://github.com/EventSource/eventsource/issues/41), [#43](https://github.com/EventSource/eventsource/pull/43) Adriano Raiano) 234 | 235 | ## [0.1.5](https://github.com/EventSource/eventsource/compare/v0.1.4...v0.1.5) (2015-02-08) 236 | 237 | ### Features 238 | 239 | * Refactor tests to support Node.js 0.12.0 and Io.js 1.1.0. (Aslak Hellesøy) 240 | 241 | ## [0.1.4](https://github.com/EventSource/eventsource/compare/v0.1.3...v0.1.4) (2014-10-31) 242 | 243 | ### Features 244 | 245 | * Expose `status` property on `error` events. ([#40](https://github.com/EventSource/eventsource/pull/40) Adriano Raiano) 246 | 247 | ### Bug Fixes 248 | 249 | * Added missing origin property. ([#39](https://github.com/EventSource/eventsource/pull/39), [#38](https://github.com/EventSource/eventsource/issues/38) Arnout Kazemier) 250 | 251 | ## [0.1.3](https://github.com/EventSource/eventsource/compare/v0.1.2...v0.1.3) (2014-09-17) 252 | 253 | ### Bug Fixes 254 | 255 | * Made message properties enumerable. ([#37](https://github.com/EventSource/eventsource/pull/37) Golo Roden) 256 | 257 | ## [0.1.2](https://github.com/EventSource/eventsource/compare/v0.1.1...v0.1.2) (2014-08-07) 258 | 259 | ### Bug Fixes 260 | 261 | * Blank lines not read. ([#35](https://github.com/EventSource/eventsource/issues/35), [#36](https://github.com/EventSource/eventsource/pull/36) Lesterpig) 262 | 263 | ## [0.1.1](https://github.com/EventSource/eventsource/compare/v0.1.0...v0.1.1) (2014-05-18) 264 | 265 | ### Bug Fixes 266 | 267 | * Fix message type. ([#33](https://github.com/EventSource/eventsource/pull/33) Romain Gauthier) 268 | 269 | ## [0.1.0](https://github.com/EventSource/eventsource/compare/v0.0.10...v0.1.0) (2014-03-07) 270 | 271 | ### Bug Fixes 272 | 273 | * High CPU usage by replacing Jison with port of WebKit's parser. ([#25](https://github.com/EventSource/eventsource/issues/25), [#32](https://github.com/EventSource/eventsource/pull/32), [#18](https://github.com/EventSource/eventsource/issues/18) qqueue) 274 | 275 | ## [0.0.10](https://github.com/EventSource/eventsource/compare/v0.0.9...v0.0.10) (2013-11-21) 276 | 277 | ### Features 278 | 279 | * Provide `Event` argument on `open` and `error` event ([#30](https://github.com/EventSource/eventsource/issues/30), [#31](https://github.com/EventSource/eventsource/pull/31) Donghwan Kim) 280 | * Expose `lastEventId` on messages. ([#28](https://github.com/EventSource/eventsource/pull/28) mbieser) 281 | 282 | ## [0.0.9](https://github.com/EventSource/eventsource/compare/v0.0.8...v0.0.9) (2013-10-24) 283 | 284 | ### Bug Fixes 285 | 286 | * Old "last-event-id" used on reconnect ([#27](https://github.com/EventSource/eventsource/pull/27) Aslak Hellesøy) 287 | 288 | ## [0.0.8](https://github.com/EventSource/eventsource/compare/v0.0.7...v0.0.8) (2013-09-12) 289 | 290 | ### Features 291 | 292 | * Allow unauthorized HTTPS connections by setting `rejectUnauthorized` to false. (Aslak Hellesøy) 293 | 294 | ### Bug Fixes 295 | 296 | * EventSource still reconnected when closed ([#24](https://github.com/EventSource/eventsource/pull/24) FrozenCow) 297 | 298 | ## [0.0.7](https://github.com/EventSource/eventsource/compare/v0.0.6...v0.0.7) (2013-04-19) 299 | 300 | ### Features 301 | 302 | * Explicitly raise an error when server returns http 403 and don't continue ([#20](https://github.com/EventSource/eventsource/pull/20) Scott Moak) 303 | * Added ability to send custom http headers to server ([#21](https://github.com/EventSource/eventsource/pull/21), [#9](https://github.com/EventSource/eventsource/issues/9) Scott Moak) 304 | * Switched from testing with Nodeunit to Mocha (Aslak Hellesøy) 305 | 306 | ### Bug Fixes 307 | 308 | * Fix Unicode support to cope with Javascript Unicode size limitations ([#23](https://github.com/EventSource/eventsource/pull/23), [#22](https://github.com/EventSource/eventsource/issues/22) Devon Adkisson) 309 | * Graceful handling of parse errors ([#19](https://github.com/EventSource/eventsource/issues/19) Aslak Hellesøy) 310 | 311 | ## [0.0.6](https://github.com/EventSource/eventsource/compare/v0.0.5...v0.0.6) (2013-01-24) 312 | 313 | ### Features 314 | 315 | * Add Accept: text/event-stream header ([#17](https://github.com/EventSource/eventsource/pull/17) William Wicks) 316 | 317 | ## [0.0.5](https://github.com/EventSource/eventsource/compare/v0.0.4...v0.0.5) (2012-02-12) 318 | 319 | ### Features 320 | 321 | * Add no-cache and https support ([#10](https://github.com/EventSource/eventsource/pull/10) Einar Otto Stangvik) 322 | * Ensure that Last-Event-ID is sent to the server for reconnects, as defined in the spec ([#8](https://github.com/EventSource/eventsource/pull/8) Einar Otto Stangvik) 323 | * Verify that CR and CRLF are accepted alongside LF ([#7](https://github.com/EventSource/eventsource/pull/7) Einar Otto Stangvik) 324 | * Emit 'open' event ([#4](https://github.com/EventSource/eventsource/issues/4) Einar Otto Stangvik) 325 | 326 | ## [0.0.4](https://github.com/EventSource/eventsource/compare/v0.0.3...v0.0.4) (2012-02-10) 327 | 328 | ### Features 329 | 330 | * Automatic reconnect every second if the server is down. Reconnect interval can be set with `reconnectInterval` (not in W3C spec). (Aslak Hellesøy) 331 | 332 | ## [0.0.3](https://github.com/EventSource/eventsource/compare/v0.0.2...v0.0.3) (2012-02-10) 333 | 334 | ### Features 335 | 336 | * Jison based eventstream parser ([#2](https://github.com/EventSource/eventsource/pull/2) Einar Otto Stangvik) 337 | 338 | ## [0.0.2](https://github.com/EventSource/eventsource/compare/v0.0.1...v0.0.2) (2012-02-08) 339 | 340 | ### Features 341 | 342 | * Use native EventListener (Aslak Hellesøy) 343 | 344 | ## 0.0.1 (2012-02-08) 345 | 346 | ### Features 347 | 348 | * First release (Aslak Hellesøy) 349 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [mailto:espen@hovlandsdal.com](espen@hovlandsdal.com). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to EventSource 2 | 3 | Contributions are welcome, no matter how large or small, but: 4 | 5 | - Please open an issue before starting work on a feature or large change. 6 | - We generally do not accept PRs that extend the API or surface of the library. The idea behind this module is to provide a (mostly) spec-compliant implementation of the EventSource API, and we want to keep it as simple as possible. 7 | - Changes needs to be compatible with the [EventSource specification](https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events) as far as possible, as well as for all the [supported environments](https://github.com/EventSource/eventsource/blob/current/README.md#supported-engines). 8 | 9 | ## Getting started 10 | 11 | Before contributing, please read our [code of conduct](https://github.com/EventSource/eventsource/blob/current/CODE_OF_CONDUCT.md). 12 | 13 | Then make sure you have _Node.js version 18 or newer_. 14 | 15 | ```sh 16 | git clone git@github.com:EventSource/eventsource.git 17 | cd eventsource 18 | npm install 19 | npm run build 20 | npm test 21 | ``` 22 | 23 | # Workflow guidelines 24 | 25 | - Anything in the `main` branch is scheduled for the next release and should generally be ready to released, although there are exceptions when there are multiple features that are dependent on each other. 26 | - To work on something new, create a descriptively named branch off of `main` (ie: `feat/some-new-feature`). 27 | - Commit to that branch locally and regularly push your work to the same named branch on the remote. 28 | - Rebase your feature branch regularly against `main`. Make sure its even with `main` while it is awaiting review. 29 | - Pull requests should be as ready as possible for merge. Unless stated otherwise, it should be safe to assume that: 30 | 31 | - The changes/feature are reviewed and tested by you 32 | - You think it's production ready 33 | - The code is linted and the test suite is passing 34 | 35 | ## Commit messages 36 | 37 | We use Conventional Commits for our commit messages. This means that each commit should be prefixed with a type and a message. The message should be in the imperative mood. For example: 38 | 39 | ``` 40 | feat: allow specifying something 41 | fix: double reconnect attempt on error 42 | docs: clarify usage of `fetch` option 43 | ``` 44 | 45 | # How to file a security issue 46 | 47 | If you find a security vulnerability, do **NOT** open an issue. Use the [https://github.com/EventSource/eventsource/security/advisories/new](GitHub Security Advisory) page instead. 48 | 49 | ## How to report a bug 50 | 51 | When filing an issue, make sure to answer these six questions: 52 | 53 | - Which versions of the `eventsource` module are you using? 54 | - What operating system are you using? 55 | - Which versions of Node.js/browser/runtime are you running? 56 | - What happened that caused the issue? 57 | - What did you expect to happen? 58 | - What actually happend? 59 | - What was the data sent from the server that caused the issue? 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) EventSource GitHub organisation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | # Migration guide 2 | 3 | ## v3 to v4 4 | 5 | ### Runtime support 6 | 7 | Dropped support for Node.js version 18, as it is no longer maintained. While 8 | there are no explicit changes that makes it incompatible, we make no guarantees of it being supported going forward. 9 | 10 | ### Code changes 11 | 12 | #### Dropped `FetchLikeInit` type 13 | 14 | If you're using TypeScript, `FetchLikeInit` is now called `EventSourceFetchInit` and 15 | most of it's properties are now marked as required - since they will always be passed 16 | from the EventSource library to your custom `fetch` method. This makes it easier to use. 17 | 18 | ## v2 to v3 19 | 20 | ### Code changes 21 | 22 | #### Named export 23 | 24 | The module now uses named exports instead of a default export. This means you need to change your import statements from: 25 | 26 | **ESM:** 27 | 28 | ```diff 29 | -import EventSource from 'eventsource' 30 | import {EventSource} from 'eventsource' 31 | ``` 32 | 33 | **CommonJS:** 34 | 35 | ```diff 36 | -const EventSource = require('eventsource') 37 | const {EventSource} = require('eventsource') 38 | ``` 39 | 40 | #### UMD bundle dropped 41 | 42 | If you were previously importing/using the `eventsource-polyfill.js` file/module, you should instead use a bundler like Vite, Rollup or similar. You can theoretically also use something like [esm.sh](https://esm.sh/) to load the module directly in the browser - eg: 43 | 44 | ```ts 45 | import {EventSource} from 'https://esm.sh/eventsource@3.0.0-beta.0' 46 | ``` 47 | 48 | #### Custom headers dropped 49 | 50 | In v2 you could specify custom headers through the `headers` property in the options/init object to the constructor. In v3, the same can be achieved by passing a custom `fetch` function: 51 | 52 | ```diff 53 | const es = new EventSource('https://my-server.com/sse', { 54 | - headers: {Authorization: 'Bearer foobar'} 55 | + fetch: (input, init) => fetch(input, { 56 | + ...init, 57 | + headers: {...init.headers, Authorization: 'Bearer foobar'}, 58 | + }), 59 | }) 60 | ``` 61 | 62 | #### HTTP/HTTPS proxy dropped 63 | 64 | Use a package like [`undici`](https://github.com/nodejs/undici) to add proxy support, either through environment variables or explicit configuration. 65 | 66 | ```ts 67 | // npm install undici --save 68 | import {fetch, EnvHttpProxyAgent} from 'undici' 69 | 70 | const proxyAgent = new EnvHttpProxyAgent() 71 | 72 | const es = new EventSource('https://my-server.com/sse', { 73 | fetch: (input, init) => fetch(input, {...init, dispatcher: proxyAgent}), 74 | }) 75 | ``` 76 | 77 | #### Custom HTTPS/connection options dropped 78 | 79 | Use a package like [`undici`](https://github.com/nodejs/undici) for more control of fetch options through the use of an [`Agent`](https://undici.nodejs.org/#/docs/api/Agent.md). 80 | 81 | ```ts 82 | // npm install undici --save 83 | import {fetch, Agent} from 'undici' 84 | 85 | const unsafeAgent = new Agent({ 86 | connect: { 87 | rejectUnauthorized: false, 88 | }, 89 | }) 90 | 91 | await fetch('https://my-server.com/sse', { 92 | dispatcher: unsafeAgent, 93 | }) 94 | ``` 95 | 96 | ### Behavior changes 97 | 98 | #### New default reconnect timeout 99 | 100 | The default reconnect timeout is now 3 seconds - up from 1 second in v1/v2. This aligns better with browsers (Chrome and Safari, Firefox uses 5 seconds). Servers are (as always) free to set their own reconnect timeout through the `retry` field. 101 | 102 | #### Redirect handling 103 | 104 | Redirect handling now matches Chrome/Safari. On disconnects, we will always reconnect to the _original_ URL. In v1/v2, only HTTP 307 would reconnect to the original, while 301 and 302 would both redirect to the _destination_. 105 | 106 | While the _ideal_ behavior would be for 301 and 308 to reconnect to the redirect _destination_, and 302/307 to reconnect to the _original_ URL, this is not possible to do cross-platform (cross-origin requests in browsers do not allow reading location headers, and redirect handling will have to be done manually). 107 | 108 | #### Strict checking of Content-Type header 109 | 110 | The `Content-Type` header is now checked. It's value must be `text/event-stream` (or 111 | `text/event-stream; charset=utf-8`), and the connection will be failed otherwise. 112 | 113 | To maintain the previous behaviour, you can use the `fetch` option to override the 114 | returned `Content-Type` header if your server does not send the required header: 115 | 116 | ```ts 117 | const es = new EventSource('https://my-server.com/sse', { 118 | fetch: async (input, init) => { 119 | const response = await fetch(input, init) 120 | 121 | if (response.headers.get('content-type').startsWith('text/event-stream')) { 122 | // Valid header, forward response 123 | return response 124 | } 125 | 126 | // Server did not respond with the correct content-type - override it 127 | const newHeaders = new Headers(response.headers) 128 | newHeaders.set('content-type', 'text/event-stream') 129 | return new Response(response.body, { 130 | status: response.status, 131 | statusText: response.statusText, 132 | headers: newHeaders, 133 | }) 134 | }, 135 | }) 136 | ``` 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eventsource 2 | 3 | [![npm version](https://img.shields.io/npm/v/eventsource.svg?style=flat-square)](https://www.npmjs.com/package/eventsource)[![npm bundle size](https://img.shields.io/bundlephobia/minzip/eventsource?style=flat-square)](https://bundlephobia.com/result?p=eventsource)[![npm weekly downloads](https://img.shields.io/npm/dw/eventsource.svg?style=flat-square)](https://www.npmjs.com/package/eventsource) 4 | 5 | WhatWG/W3C-compatible [server-sent events/eventsource](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) client. The module attempts to implement an absolute minimal amount of features/changes beyond the specification. 6 | 7 | If you're looking for a modern alternative with a less constrained API, check out the [`eventsource-client` package](https://www.npmjs.com/package/eventsource-client). 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm install --save eventsource 13 | ``` 14 | 15 | ## Supported engines 16 | 17 | - Node.js >= 20 18 | - Chrome >= 63 19 | - Safari >= 11.3 20 | - Firefox >= 65 21 | - Edge >= 79 22 | - Deno >= 1.30 23 | - Bun >= 1.1.23 24 | 25 | Basically, any environment that supports: 26 | 27 | - [fetch](https://developer.mozilla.org/en-US/docs/Web/API/fetch) 28 | - [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) 29 | - [TextDecoderStream](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoderStream) 30 | - [URL](https://developer.mozilla.org/en-US/docs/Web/API/URL) 31 | - [Event](https://developer.mozilla.org/en-US/docs/Web/API/Event), [MessageEvent](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent), [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) 32 | 33 | If you need to support older runtimes, try the `2.x` branch/version range (note: 2.x branch is primarily targetted at Node.js, not browsers). 34 | 35 | ## Usage 36 | 37 | ```ts 38 | import {EventSource} from 'eventsource' 39 | 40 | const es = new EventSource('https://my-server.com/sse') 41 | 42 | /* 43 | * This will listen for events with the field `event: notice`. 44 | */ 45 | es.addEventListener('notice', (event) => { 46 | console.log(event.data) 47 | }) 48 | 49 | /* 50 | * This will listen for events with the field `event: update`. 51 | */ 52 | es.addEventListener('update', (event) => { 53 | console.log(event.data) 54 | }) 55 | 56 | /* 57 | * The event "message" is a special case, as it will capture events _without_ an 58 | * event field, as well as events that have the specific type `event: message`. 59 | * It will not trigger on any other event type. 60 | */ 61 | es.addEventListener('message', (event) => { 62 | console.log(event.data) 63 | }) 64 | 65 | /** 66 | * To explicitly close the connection, call the `close` method. 67 | * This will prevent any reconnection from happening. 68 | */ 69 | setTimeout(() => { 70 | es.close() 71 | }, 10_000) 72 | ``` 73 | 74 | ### TypeScript 75 | 76 | Make sure you have configured your TSConfig so it matches the environment you are targetting. If you are targetting browsers, this would be `dom`: 77 | 78 | ```jsonc 79 | { 80 | "compilerOptions": { 81 | "lib": ["dom"], 82 | }, 83 | } 84 | ``` 85 | 86 | If you're using Node.js, ensure you have `@types/node` installed (and it is version 18 or higher). Cloudflare workers have `@cloudflare/workers-types` etc. 87 | 88 | The following errors are caused by targetting an environment that does not have the necessary types available: 89 | 90 | ``` 91 | error TS2304: Cannot find name 'Event'. 92 | error TS2304: Cannot find name 'EventTarget'. 93 | error TS2304: Cannot find name 'MessageEvent'. 94 | ``` 95 | 96 | ## Migrating from v1 / v2 97 | 98 | See [MIGRATION.md](MIGRATION.md#v2-to-v3) for a detailed migration guide. 99 | 100 | ## Extensions to the WhatWG/W3C API 101 | 102 | ### Message and code properties on errors 103 | 104 | The `error` event has a `message` and `code` property that can be used to get more information about the error. In the specification, the Event 105 | 106 | ```ts 107 | es.addEventListener('error', (err) => { 108 | if (err.code === 401 || err.code === 403) { 109 | console.log('not authorized') 110 | } 111 | }) 112 | ``` 113 | 114 | ### Specify `fetch` implementation 115 | 116 | The `EventSource` constructor accepts an optional `fetch` property in the second argument that can be used to specify the `fetch` implementation to use. 117 | 118 | This can be useful in environments where the global `fetch` function is not available - but it can also be used to alter the request/response behaviour. 119 | 120 | #### Setting HTTP request headers 121 | 122 | ```ts 123 | const es = new EventSource('https://my-server.com/sse', { 124 | fetch: (input, init) => 125 | fetch(input, { 126 | ...init, 127 | headers: { 128 | ...init.headers, 129 | Authorization: 'Bearer myToken', 130 | }, 131 | }), 132 | }) 133 | ``` 134 | 135 | #### HTTP/HTTPS proxy 136 | 137 | Use a package like [`undici`](https://github.com/nodejs/undici) to add proxy support, either through environment variables or explicit configuration. 138 | 139 | ```ts 140 | // npm install undici --save 141 | import {fetch, EnvHttpProxyAgent} from 'undici' 142 | 143 | const proxyAgent = new EnvHttpProxyAgent() 144 | 145 | const es = new EventSource('https://my-server.com/sse', { 146 | fetch: (input, init) => fetch(input, {...init, dispatcher: proxyAgent}), 147 | }) 148 | ``` 149 | 150 | #### Allow unauthorized HTTPS requests 151 | 152 | Use a package like [`undici`](https://github.com/nodejs/undici) for more control of fetch options through the use of an [`Agent`](https://undici.nodejs.org/#/docs/api/Agent.md). 153 | 154 | ```ts 155 | // npm install undici --save 156 | import {fetch, Agent} from 'undici' 157 | 158 | const unsafeAgent = new Agent({ 159 | connect: { 160 | rejectUnauthorized: false, 161 | }, 162 | }) 163 | 164 | await fetch('https://my-server.com/sse', { 165 | dispatcher: unsafeAgent, 166 | }) 167 | ``` 168 | 169 | ## License 170 | 171 | MIT-licensed. See [LICENSE](LICENSE). 172 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 3.x.x | :white_check_mark: | 8 | | 2.x.x | :white_check_mark: | 9 | | < 2.0 | :x: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | If you find a security vulnerability, do **NOT** open an issue. Use the [GitHub Security Advisory](https://github.com/EventSource/eventsource/security/advisories/new) page instead. 14 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | import {visualizer} from 'rollup-plugin-visualizer' 3 | 4 | import {name, version} from './package.json' 5 | 6 | export default defineConfig({ 7 | rollup: { 8 | plugins: [ 9 | visualizer({ 10 | emitFile: true, 11 | filename: 'stats.html', 12 | gzipSize: true, 13 | title: `${name}@${version} bundle analysis`, 14 | }), 15 | ], 16 | }, 17 | 18 | tsconfig: 'tsconfig.dist.json', 19 | }) 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eventsource", 3 | "version": "4.0.0", 4 | "description": "WhatWG/W3C compliant EventSource client for Node.js and browsers", 5 | "sideEffects": false, 6 | "type": "module", 7 | "main": "./dist/index.cjs", 8 | "module": "./dist/index.js", 9 | "types": "./dist/index.d.ts", 10 | "exports": { 11 | ".": { 12 | "deno": "./dist/index.js", 13 | "bun": "./dist/index.js", 14 | "source": "./src/index.ts", 15 | "import": "./dist/index.js", 16 | "require": "./dist/index.cjs", 17 | "default": "./dist/index.js" 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "scripts": { 22 | "build": "pkg-utils build && pkg-utils --strict", 23 | "build:watch": "pkg-utils watch", 24 | "clean": "rimraf dist coverage", 25 | "lint": "eslint . && tsc --noEmit", 26 | "posttest": "npm run lint", 27 | "prebuild": "npm run clean", 28 | "prepare": "npm run build", 29 | "test": "npm run test:node && npm run test:browser", 30 | "test:browser": "tsx test/browser/client.browser.test.ts", 31 | "test:bun": "bun run test/bun/client.bun.test.ts", 32 | "test:deno": "deno run --allow-net --allow-read --allow-env --unstable-sloppy-imports test/deno/client.deno.test.ts", 33 | "test:node": "tsx test/node/client.node.test.ts" 34 | }, 35 | "files": [ 36 | "!dist/stats.html", 37 | "dist", 38 | "src" 39 | ], 40 | "repository": { 41 | "type": "git", 42 | "url": "git://git@github.com/EventSource/eventsource.git" 43 | }, 44 | "keywords": [ 45 | "sse", 46 | "eventsource", 47 | "server-sent-events" 48 | ], 49 | "author": "Espen Hovlandsdal ", 50 | "contributors": [ 51 | "Aslak Hellesøy ", 52 | "Einar Otto Stangvik " 53 | ], 54 | "license": "MIT", 55 | "engines": { 56 | "node": ">=20.0.0" 57 | }, 58 | "browserslist": [ 59 | "node >= 20", 60 | "chrome >= 71", 61 | "safari >= 14.1", 62 | "firefox >= 105", 63 | "edge >= 79" 64 | ], 65 | "dependencies": { 66 | "eventsource-parser": "^3.0.1" 67 | }, 68 | "devDependencies": { 69 | "@sanity/pkg-utils": "^7.2.2", 70 | "@sanity/semantic-release-preset": "^5.0.0", 71 | "@tsconfig/strictest": "^2.0.5", 72 | "@types/sinon": "^17.0.4", 73 | "@typescript-eslint/eslint-plugin": "^6.11.0", 74 | "@typescript-eslint/parser": "^6.11.0", 75 | "esbuild": "^0.25.1", 76 | "eslint": "^8.57.0", 77 | "eslint-config-prettier": "^9.1.0", 78 | "eslint-config-sanity": "^7.1.4", 79 | "eventsource-encoder": "^1.0.1", 80 | "playwright": "^1.52.0", 81 | "prettier": "^3.5.3", 82 | "rimraf": "^6.0.1", 83 | "rollup-plugin-visualizer": "^5.14.0", 84 | "semantic-release": "^24.2.3", 85 | "sinon": "^20.0.0", 86 | "tsx": "^4.19.4", 87 | "typescript": "^5.8.3", 88 | "undici": "^7.8.0" 89 | }, 90 | "overrides": { 91 | "cross-spawn": "7.0.6" 92 | }, 93 | "bugs": { 94 | "url": "https://github.com/EventSource/eventsource/issues" 95 | }, 96 | "homepage": "https://github.com/EventSource/eventsource#readme", 97 | "prettier": { 98 | "semi": false, 99 | "printWidth": 100, 100 | "bracketSpacing": false, 101 | "singleQuote": true 102 | }, 103 | "eslintConfig": { 104 | "parserOptions": { 105 | "ecmaVersion": 9, 106 | "sourceType": "module", 107 | "ecmaFeatures": { 108 | "modules": true 109 | } 110 | }, 111 | "extends": [ 112 | "sanity", 113 | "sanity/typescript", 114 | "prettier" 115 | ], 116 | "ignorePatterns": [ 117 | "lib/**/" 118 | ], 119 | "globals": { 120 | "globalThis": false 121 | }, 122 | "rules": { 123 | "no-undef": "off", 124 | "no-empty": "off" 125 | } 126 | }, 127 | "publishConfig": { 128 | "provenance": true 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/EventSource.ts: -------------------------------------------------------------------------------- 1 | import {createParser, type EventSourceMessage, type EventSourceParser} from 'eventsource-parser' 2 | 3 | import {ErrorEvent, flattenError, syntaxError} from './errors.js' 4 | import type { 5 | AddEventListenerOptions, 6 | EventListenerOptions, 7 | EventListenerOrEventListenerObject, 8 | EventSourceEventMap, 9 | EventSourceFetchInit, 10 | EventSourceInit, 11 | FetchLike, 12 | FetchLikeResponse, 13 | } from './types.js' 14 | 15 | /** 16 | * An `EventSource` instance opens a persistent connection to an HTTP server, which sends events 17 | * in `text/event-stream` format. The connection remains open until closed by calling `.close()`. 18 | * 19 | * @public 20 | * @example 21 | * ```js 22 | * const eventSource = new EventSource('https://example.com/stream') 23 | * eventSource.addEventListener('error', (error) => { 24 | * console.error(error) 25 | * }) 26 | * eventSource.addEventListener('message', (event) => { 27 | * console.log('Received message:', event.data) 28 | * }) 29 | * ``` 30 | */ 31 | export class EventSource extends EventTarget { 32 | /** 33 | * ReadyState representing an EventSource currently trying to connect 34 | * 35 | * @public 36 | */ 37 | static CONNECTING = 0 as const 38 | 39 | /** 40 | * ReadyState representing an EventSource connection that is open (eg connected) 41 | * 42 | * @public 43 | */ 44 | static OPEN = 1 as const 45 | 46 | /** 47 | * ReadyState representing an EventSource connection that is closed (eg disconnected) 48 | * 49 | * @public 50 | */ 51 | static CLOSED = 2 as const 52 | 53 | /** 54 | * ReadyState representing an EventSource currently trying to connect 55 | * 56 | * @public 57 | */ 58 | readonly CONNECTING = 0 as const 59 | 60 | /** 61 | * ReadyState representing an EventSource connection that is open (eg connected) 62 | * 63 | * @public 64 | */ 65 | readonly OPEN = 1 as const 66 | 67 | /** 68 | * ReadyState representing an EventSource connection that is closed (eg disconnected) 69 | * 70 | * @public 71 | */ 72 | readonly CLOSED = 2 as const 73 | 74 | /** 75 | * Returns the state of this EventSource object's connection. It can have the values described below. 76 | * 77 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState) 78 | * 79 | * Note: typed as `number` instead of `0 | 1 | 2` for compatibility with the `EventSource` interface, 80 | * defined in the TypeScript `dom` library. 81 | * 82 | * @public 83 | */ 84 | public get readyState(): number { 85 | return this.#readyState 86 | } 87 | 88 | /** 89 | * Returns the URL providing the event stream. 90 | * 91 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url) 92 | * 93 | * @public 94 | */ 95 | public get url(): string { 96 | return this.#url.href 97 | } 98 | 99 | /** 100 | * Returns true if the credentials mode for connection requests to the URL providing the event stream is set to "include", and false otherwise. 101 | * 102 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials) 103 | */ 104 | public get withCredentials(): boolean { 105 | return this.#withCredentials 106 | } 107 | 108 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/error_event) */ 109 | public get onerror(): ((ev: ErrorEvent) => unknown) | null { 110 | return this.#onError 111 | } 112 | public set onerror(value: ((ev: ErrorEvent) => unknown) | null) { 113 | this.#onError = value 114 | } 115 | 116 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/message_event) */ 117 | public get onmessage(): ((ev: MessageEvent) => unknown) | null { 118 | return this.#onMessage 119 | } 120 | public set onmessage(value: ((ev: MessageEvent) => unknown) | null) { 121 | this.#onMessage = value 122 | } 123 | 124 | /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/open_event) */ 125 | public get onopen(): ((ev: Event) => unknown) | null { 126 | return this.#onOpen 127 | } 128 | public set onopen(value: ((ev: Event) => unknown) | null) { 129 | this.#onOpen = value 130 | } 131 | 132 | override addEventListener( 133 | type: K, 134 | listener: (this: EventSource, ev: EventSourceEventMap[K]) => unknown, 135 | options?: boolean | AddEventListenerOptions, 136 | ): void 137 | override addEventListener( 138 | type: string, 139 | listener: (this: EventSource, event: MessageEvent) => unknown, 140 | options?: boolean | AddEventListenerOptions, 141 | ): void 142 | override addEventListener( 143 | type: string, 144 | listener: EventListenerOrEventListenerObject, 145 | options?: boolean | AddEventListenerOptions, 146 | ): void 147 | override addEventListener( 148 | type: string, 149 | listener: 150 | | ((this: EventSource, event: MessageEvent) => unknown) 151 | | EventListenerOrEventListenerObject, 152 | options?: boolean | AddEventListenerOptions, 153 | ): void { 154 | const listen = listener as (this: EventSource, event: Event) => unknown 155 | super.addEventListener(type, listen, options) 156 | } 157 | 158 | override removeEventListener( 159 | type: K, 160 | listener: (this: EventSource, ev: EventSourceEventMap[K]) => unknown, 161 | options?: boolean | EventListenerOptions, 162 | ): void 163 | override removeEventListener( 164 | type: string, 165 | listener: (this: EventSource, event: MessageEvent) => unknown, 166 | options?: boolean | EventListenerOptions, 167 | ): void 168 | override removeEventListener( 169 | type: string, 170 | listener: EventListenerOrEventListenerObject, 171 | options?: boolean | EventListenerOptions, 172 | ): void 173 | override removeEventListener( 174 | type: string, 175 | listener: 176 | | ((this: EventSource, event: MessageEvent) => unknown) 177 | | EventListenerOrEventListenerObject, 178 | options?: boolean | EventListenerOptions, 179 | ): void { 180 | const listen = listener as (this: EventSource, event: Event) => unknown 181 | super.removeEventListener(type, listen, options) 182 | } 183 | 184 | constructor(url: string | URL, eventSourceInitDict?: EventSourceInit) { 185 | super() 186 | 187 | try { 188 | if (url instanceof URL) { 189 | this.#url = url 190 | } else if (typeof url === 'string') { 191 | this.#url = new URL(url, getBaseURL()) 192 | } else { 193 | throw new Error('Invalid URL') 194 | } 195 | } catch (err) { 196 | throw syntaxError('An invalid or illegal string was specified') 197 | } 198 | 199 | this.#parser = createParser({ 200 | onEvent: this.#onEvent, 201 | onRetry: this.#onRetryChange, 202 | }) 203 | 204 | this.#readyState = this.CONNECTING 205 | this.#reconnectInterval = 3000 206 | this.#fetch = eventSourceInitDict?.fetch ?? globalThis.fetch 207 | this.#withCredentials = eventSourceInitDict?.withCredentials ?? false 208 | 209 | this.#connect() 210 | } 211 | 212 | /** 213 | * Aborts any instances of the fetch algorithm started for this EventSource object, and sets the readyState attribute to CLOSED. 214 | * 215 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close) 216 | * 217 | * @public 218 | */ 219 | close(): void { 220 | if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer) 221 | if (this.#readyState === this.CLOSED) return 222 | if (this.#controller) this.#controller.abort() 223 | this.#readyState = this.CLOSED 224 | this.#controller = undefined 225 | } 226 | 227 | // PRIVATES FOLLOW 228 | 229 | /** 230 | * Current connection state 231 | * 232 | * @internal 233 | */ 234 | #readyState: number 235 | 236 | /** 237 | * Original URL used to connect. 238 | * 239 | * Note that this will stay the same even after a redirect. 240 | * 241 | * @internal 242 | */ 243 | #url: URL 244 | 245 | /** 246 | * The destination URL after a redirect. Is reset on reconnection. 247 | * 248 | * @internal 249 | */ 250 | #redirectUrl: URL | undefined 251 | 252 | /** 253 | * Whether to include credentials in the request 254 | * 255 | * @internal 256 | */ 257 | #withCredentials: boolean 258 | 259 | /** 260 | * The fetch implementation to use 261 | * 262 | * @internal 263 | */ 264 | #fetch: FetchLike 265 | 266 | /** 267 | * The reconnection time in milliseconds 268 | * 269 | * @internal 270 | */ 271 | #reconnectInterval: number 272 | 273 | /** 274 | * Reference to an ongoing reconnect attempt, if any 275 | * 276 | * @internal 277 | */ 278 | #reconnectTimer: ReturnType | undefined 279 | 280 | /** 281 | * The last event ID seen by the EventSource, which will be sent as `Last-Event-ID` in the 282 | * request headers on a reconnection attempt. 283 | * 284 | * @internal 285 | */ 286 | #lastEventId: string | null = null 287 | 288 | /** 289 | * The AbortController instance used to abort the fetch request 290 | * 291 | * @internal 292 | */ 293 | #controller: AbortController | undefined 294 | 295 | /** 296 | * Instance of an EventSource parser (`eventsource-parser` npm module) 297 | * 298 | * @internal 299 | */ 300 | #parser: EventSourceParser 301 | 302 | /** 303 | * Holds the current error handler, attached through `onerror` property directly. 304 | * Note that `addEventListener('error', …)` will not be stored here. 305 | * 306 | * @internal 307 | */ 308 | #onError: ((ev: ErrorEvent) => unknown) | null = null 309 | 310 | /** 311 | * Holds the current message handler, attached through `onmessage` property directly. 312 | * Note that `addEventListener('message', …)` will not be stored here. 313 | * 314 | * @internal 315 | */ 316 | #onMessage: ((ev: MessageEvent) => unknown) | null = null 317 | 318 | /** 319 | * Holds the current open handler, attached through `onopen` property directly. 320 | * Note that `addEventListener('open', …)` will not be stored here. 321 | * 322 | * @internal 323 | */ 324 | #onOpen: ((ev: Event) => unknown) | null = null 325 | 326 | /** 327 | * Connect to the given URL and start receiving events 328 | * 329 | * @internal 330 | */ 331 | #connect() { 332 | this.#readyState = this.CONNECTING 333 | this.#controller = new AbortController() 334 | 335 | // Browser tests are failing if we directly call `this.#fetch()`, thus the indirection. 336 | const fetch = this.#fetch 337 | fetch(this.#url, this.#getRequestOptions()) 338 | .then(this.#onFetchResponse) 339 | .catch(this.#onFetchError) 340 | } 341 | 342 | /** 343 | * Handles the fetch response 344 | * 345 | * @param response - The Fetch(ish) response 346 | * @internal 347 | */ 348 | #onFetchResponse = async (response: FetchLikeResponse) => { 349 | this.#parser.reset() 350 | 351 | const {body, redirected, status, headers} = response 352 | 353 | // [spec] a client can be told to stop reconnecting using the HTTP 204 No Content response code. 354 | if (status === 204) { 355 | // We still need to emit an error event - this mirrors the browser behavior, 356 | // and without it there is no way to tell the user that the connection was closed. 357 | this.#failConnection('Server sent HTTP 204, not reconnecting', 204) 358 | this.close() 359 | return 360 | } 361 | 362 | // [spec] …Event stream requests can be redirected using HTTP 301 and 307 redirects as with 363 | // [spec] normal HTTP requests. 364 | // Spec does not say anything about other redirect codes (302, 308), but this seems an 365 | // unintended omission, rather than a feature. Browsers will happily redirect on other 3xxs's. 366 | if (redirected) { 367 | this.#redirectUrl = new URL(response.url) 368 | } else { 369 | this.#redirectUrl = undefined 370 | } 371 | 372 | // [spec] if res's status is not 200, …, then fail the connection. 373 | if (status !== 200) { 374 | this.#failConnection(`Non-200 status code (${status})`, status) 375 | return 376 | } 377 | 378 | // [spec] …or if res's `Content-Type` is not `text/event-stream`, then fail the connection. 379 | const contentType = headers.get('content-type') || '' 380 | if (!contentType.startsWith('text/event-stream')) { 381 | this.#failConnection('Invalid content type, expected "text/event-stream"', status) 382 | return 383 | } 384 | 385 | // [spec] …if the readyState attribute is set to a value other than CLOSED… 386 | if (this.#readyState === this.CLOSED) { 387 | return 388 | } 389 | 390 | // [spec] …sets the readyState attribute to OPEN and fires an event 391 | // [spec] …named open at the EventSource object. 392 | this.#readyState = this.OPEN 393 | 394 | const openEvent = new Event('open') 395 | this.#onOpen?.(openEvent) 396 | this.dispatchEvent(openEvent) 397 | 398 | // Ensure that the response stream is a web stream 399 | if (typeof body !== 'object' || !body || !('getReader' in body)) { 400 | this.#failConnection('Invalid response body, expected a web ReadableStream', status) 401 | this.close() // This should only happen if `fetch` provided is "faulty" - don't reconnect 402 | return 403 | } 404 | 405 | const decoder = new TextDecoder() 406 | 407 | const reader = body.getReader() 408 | let open = true 409 | 410 | do { 411 | const {done, value} = await reader.read() 412 | if (value) { 413 | this.#parser.feed(decoder.decode(value, {stream: !done})) 414 | } 415 | 416 | if (!done) { 417 | continue 418 | } 419 | 420 | open = false 421 | this.#parser.reset() 422 | 423 | this.#scheduleReconnect() 424 | } while (open) 425 | } 426 | 427 | /** 428 | * Handles rejected requests for the EventSource endpoint 429 | * 430 | * @param err - The error from `fetch()` 431 | * @internal 432 | */ 433 | #onFetchError = (err: Error & {type?: string}) => { 434 | this.#controller = undefined 435 | 436 | // We expect abort errors when the user manually calls `close()` - ignore those 437 | if (err.name === 'AbortError' || err.type === 'aborted') { 438 | return 439 | } 440 | 441 | this.#scheduleReconnect(flattenError(err)) 442 | } 443 | 444 | /** 445 | * Get request options for the `fetch()` request 446 | * 447 | * @returns The request options 448 | * @internal 449 | */ 450 | #getRequestOptions(): EventSourceFetchInit { 451 | const lastEvent = this.#lastEventId ? {'Last-Event-ID': this.#lastEventId} : undefined 452 | 453 | const init: EventSourceFetchInit = { 454 | // [spec] Let `corsAttributeState` be `Anonymous`… 455 | // [spec] …will have their mode set to "cors"… 456 | mode: 'cors', 457 | redirect: 'follow', 458 | headers: {Accept: 'text/event-stream', ...lastEvent}, 459 | cache: 'no-store', 460 | signal: this.#controller?.signal, 461 | } 462 | 463 | // Some environments crash if attempting to set `credentials` where it is not supported, 464 | // eg on Cloudflare Workers. To avoid this, we only set it in browser-like environments. 465 | if ('window' in globalThis) { 466 | // [spec] …and their credentials mode set to "same-origin" 467 | // [spec] …if the `withCredentials` attribute is `true`, set the credentials mode to "include"… 468 | init.credentials = this.withCredentials ? 'include' : 'same-origin' 469 | } 470 | 471 | return init 472 | } 473 | 474 | /** 475 | * Called by EventSourceParser instance when an event has successfully been parsed 476 | * and is ready to be processed. 477 | * 478 | * @param event - The parsed event 479 | * @internal 480 | */ 481 | #onEvent = (event: EventSourceMessage) => { 482 | if (typeof event.id === 'string') { 483 | this.#lastEventId = event.id 484 | } 485 | 486 | const messageEvent = new MessageEvent(event.event || 'message', { 487 | data: event.data, 488 | origin: this.#redirectUrl ? this.#redirectUrl.origin : this.#url.origin, 489 | lastEventId: event.id || '', 490 | }) 491 | 492 | // The `onmessage` property of the EventSource instance only triggers on messages without an 493 | // `event` field, or ones that explicitly set `message`. 494 | if (this.#onMessage && (!event.event || event.event === 'message')) { 495 | this.#onMessage(messageEvent) 496 | } 497 | 498 | this.dispatchEvent(messageEvent) 499 | } 500 | 501 | /** 502 | * Called by EventSourceParser instance when a new reconnection interval is received 503 | * from the EventSource endpoint. 504 | * 505 | * @param value - The new reconnection interval in milliseconds 506 | * @internal 507 | */ 508 | #onRetryChange = (value: number) => { 509 | this.#reconnectInterval = value 510 | } 511 | 512 | /** 513 | * Handles the process referred to in the EventSource specification as "failing a connection". 514 | * 515 | * @param error - The error causing the connection to fail 516 | * @param code - The HTTP status code, if available 517 | * @internal 518 | */ 519 | #failConnection(message?: string, code?: number) { 520 | // [spec] …if the readyState attribute is set to a value other than CLOSED, 521 | // [spec] sets the readyState attribute to CLOSED… 522 | if (this.#readyState !== this.CLOSED) { 523 | this.#readyState = this.CLOSED 524 | } 525 | 526 | // [spec] …and fires an event named `error` at the `EventSource` object. 527 | // [spec] Once the user agent has failed the connection, it does not attempt to reconnect. 528 | // [spec] > Implementations are especially encouraged to report detailed information 529 | // [spec] > to their development consoles whenever an error event is fired, since little 530 | // [spec] > to no information can be made available in the events themselves. 531 | // Printing to console is not very programatically helpful, though, so we emit a custom event. 532 | const errorEvent = new ErrorEvent('error', {code, message}) 533 | 534 | this.#onError?.(errorEvent) 535 | this.dispatchEvent(errorEvent) 536 | } 537 | 538 | /** 539 | * Schedules a reconnection attempt against the EventSource endpoint. 540 | * 541 | * @param message - The error causing the connection to fail 542 | * @param code - The HTTP status code, if available 543 | * @internal 544 | */ 545 | #scheduleReconnect(message?: string, code?: number) { 546 | // [spec] If the readyState attribute is set to CLOSED, abort the task. 547 | if (this.#readyState === this.CLOSED) { 548 | return 549 | } 550 | 551 | // [spec] Set the readyState attribute to CONNECTING. 552 | this.#readyState = this.CONNECTING 553 | 554 | // [spec] Fire an event named `error` at the EventSource object. 555 | const errorEvent = new ErrorEvent('error', {code, message}) 556 | this.#onError?.(errorEvent) 557 | this.dispatchEvent(errorEvent) 558 | 559 | // [spec] Wait a delay equal to the reconnection time of the event source. 560 | this.#reconnectTimer = setTimeout(this.#reconnect, this.#reconnectInterval) 561 | } 562 | 563 | /** 564 | * Reconnects to the EventSource endpoint after a disconnect/failure 565 | * 566 | * @internal 567 | */ 568 | #reconnect = () => { 569 | this.#reconnectTimer = undefined 570 | 571 | // [spec] If the EventSource's readyState attribute is not set to CONNECTING, then return. 572 | if (this.#readyState !== this.CONNECTING) { 573 | return 574 | } 575 | 576 | this.#connect() 577 | } 578 | } 579 | 580 | /** 581 | * According to spec, when constructing a URL: 582 | * > 1. Let baseURL be environment's base URL, if environment is a Document object 583 | * > 2. Return the result of applying the URL parser to url, with baseURL. 584 | * 585 | * Thus we should use `document.baseURI` if available, since it can be set through a base tag. 586 | * 587 | * @returns The base URL, if available - otherwise `undefined` 588 | * @internal 589 | */ 590 | function getBaseURL(): string | undefined { 591 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 592 | const doc = 'document' in globalThis ? (globalThis as any).document : undefined 593 | return doc && typeof doc === 'object' && 'baseURI' in doc && typeof doc.baseURI === 'string' 594 | ? doc.baseURI 595 | : undefined 596 | } 597 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An extended version of the `Event` emitted by the `EventSource` object when an error occurs. 3 | * While the spec does not include any additional properties, we intentionally go beyond the spec 4 | * and provide some (minimal) additional information to aid in debugging. 5 | * 6 | * @public 7 | */ 8 | export class ErrorEvent extends Event { 9 | /** 10 | * HTTP status code, if this was triggered by an HTTP error 11 | * Note: this is not part of the spec, but is included for better error handling. 12 | * 13 | * @public 14 | */ 15 | public code?: number | undefined 16 | 17 | /** 18 | * Optional message attached to the error. 19 | * Note: this is not part of the spec, but is included for better error handling. 20 | * 21 | * @public 22 | */ 23 | public message?: string | undefined 24 | 25 | /** 26 | * Constructs a new `ErrorEvent` instance. This is typically not called directly, 27 | * but rather emitted by the `EventSource` object when an error occurs. 28 | * 29 | * @param type - The type of the event (should be "error") 30 | * @param errorEventInitDict - Optional properties to include in the error event 31 | */ 32 | constructor( 33 | type: string, 34 | errorEventInitDict?: {message?: string | undefined; code?: number | undefined}, 35 | ) { 36 | super(type) 37 | this.code = errorEventInitDict?.code ?? undefined 38 | this.message = errorEventInitDict?.message ?? undefined 39 | } 40 | 41 | /** 42 | * Node.js "hides" the `message` and `code` properties of the `ErrorEvent` instance, 43 | * when it is `console.log`'ed. This makes it harder to debug errors. To ease debugging, 44 | * we explicitly include the properties in the `inspect` method. 45 | * 46 | * This is automatically called by Node.js when you `console.log` an instance of this class. 47 | * 48 | * @param _depth - The current depth 49 | * @param options - The options passed to `util.inspect` 50 | * @param inspect - The inspect function to use (prevents having to import it from `util`) 51 | * @returns A string representation of the error 52 | */ 53 | [Symbol.for('nodejs.util.inspect.custom')]( 54 | _depth: number, 55 | options: {colors: boolean}, 56 | inspect: (obj: unknown, inspectOptions: {colors: boolean}) => string, 57 | ): string { 58 | return inspect(inspectableError(this), options) 59 | } 60 | 61 | /** 62 | * Deno "hides" the `message` and `code` properties of the `ErrorEvent` instance, 63 | * when it is `console.log`'ed. This makes it harder to debug errors. To ease debugging, 64 | * we explicitly include the properties in the `inspect` method. 65 | * 66 | * This is automatically called by Deno when you `console.log` an instance of this class. 67 | * 68 | * @param inspect - The inspect function to use (prevents having to import it from `util`) 69 | * @param options - The options passed to `Deno.inspect` 70 | * @returns A string representation of the error 71 | */ 72 | [Symbol.for('Deno.customInspect')]( 73 | inspect: (obj: unknown, inspectOptions: {colors: boolean}) => string, 74 | options: {colors: boolean}, 75 | ): string { 76 | return inspect(inspectableError(this), options) 77 | } 78 | } 79 | 80 | /** 81 | * For environments where DOMException may not exist, we will use a SyntaxError instead. 82 | * While this isn't strictly according to spec, it is very close. 83 | * 84 | * @param message - The message to include in the error 85 | * @returns A `DOMException` or `SyntaxError` instance 86 | * @internal 87 | */ 88 | export function syntaxError(message: string): SyntaxError { 89 | // If someone can figure out a way to make this work without depending on DOM/Node.js typings, 90 | // and without casting to `any`, please send a PR 🙏 91 | 92 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 93 | const DomException = (globalThis as any).DOMException 94 | if (typeof DomException === 'function') { 95 | return new DomException(message, 'SyntaxError') 96 | } 97 | 98 | return new SyntaxError(message) 99 | } 100 | 101 | /** 102 | * Flatten an error into a single error message string. 103 | * Unwraps nested errors and joins them with a comma. 104 | * 105 | * @param err - The error to flatten 106 | * @returns A string representation of the error 107 | * @internal 108 | */ 109 | export function flattenError(err: unknown): string { 110 | if (!(err instanceof Error)) { 111 | return `${err}` 112 | } 113 | 114 | if ('errors' in err && Array.isArray(err.errors)) { 115 | return err.errors.map(flattenError).join(', ') 116 | } 117 | 118 | if ('cause' in err && err.cause instanceof Error) { 119 | return `${err}: ${flattenError(err.cause)}` 120 | } 121 | 122 | return err.message 123 | } 124 | 125 | /** 126 | * Convert an `ErrorEvent` instance into a plain object for inspection. 127 | * 128 | * @param err - The `ErrorEvent` instance to inspect 129 | * @returns A plain object representation of the error 130 | * @internal 131 | */ 132 | function inspectableError(err: ErrorEvent) { 133 | return { 134 | type: err.type, 135 | message: err.message, 136 | code: err.code, 137 | defaultPrevented: err.defaultPrevented, 138 | cancelable: err.cancelable, 139 | timeStamp: err.timeStamp, 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {ErrorEvent} from './errors.js' 2 | export {EventSource} from './EventSource.js' 3 | export type * from './types.js' 4 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type {ErrorEvent} from './errors.js' 2 | 3 | /** 4 | * Stripped down version of `fetch()`, only defining the parts we care about. 5 | * This ensures it should work with "most" fetch implementations. 6 | * 7 | * @public 8 | */ 9 | export type FetchLike = ( 10 | url: string | URL, 11 | init: EventSourceFetchInit, 12 | ) => Promise 13 | 14 | /** 15 | * Subset of `RequestInit` used for `fetch()` calls made by the `EventSource` class. 16 | * As we know that we will be passing certain values, we can be more specific and have 17 | * users not have to do optional chaining and similar for things that will always be there. 18 | * 19 | * @public 20 | */ 21 | export interface EventSourceFetchInit { 22 | /** An AbortSignal to set request's signal. Typed as `any` because of polyfill inconsistencies. */ 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | signal: {aborted: boolean} | any 25 | 26 | /** A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ 27 | headers: { 28 | [key: string]: string 29 | Accept: 'text/event-stream' 30 | } 31 | 32 | /** A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode. */ 33 | mode: 'cors' | 'no-cors' | 'same-origin' 34 | 35 | /** A string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. Sets request's credentials. */ 36 | credentials?: 'include' | 'omit' | 'same-origin' 37 | 38 | /** Controls how the request is cached. */ 39 | cache: 'no-store' 40 | 41 | /** A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */ 42 | redirect: 'error' | 'follow' | 'manual' 43 | } 44 | 45 | /** 46 | * Stripped down version of `ReadableStreamDefaultReader`, only defining the parts we care about. 47 | * 48 | * @public 49 | */ 50 | export interface ReaderLike { 51 | read(): Promise<{done: false; value: unknown} | {done: true; value?: undefined}> 52 | cancel(): Promise 53 | } 54 | 55 | /** 56 | * Minimal version of the `Response` type returned by `fetch()`. 57 | * 58 | * @public 59 | */ 60 | export interface FetchLikeResponse { 61 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 62 | readonly body: {getReader(): ReaderLike} | Response['body'] | null 63 | readonly url: string 64 | readonly status: number 65 | readonly redirected: boolean 66 | readonly headers: {get(name: string): string | null} 67 | } 68 | 69 | /** 70 | * Mirrors the official DOM typings, with the exception of the extended ErrorEvent. 71 | * 72 | * @public 73 | */ 74 | export interface EventSourceEventMap { 75 | error: ErrorEvent 76 | message: MessageEvent 77 | open: Event 78 | } 79 | 80 | /** 81 | * Mirrors the official DOM typings (for the most part) 82 | * 83 | * @public 84 | */ 85 | export interface EventSourceInit { 86 | /** 87 | * A boolean value, defaulting to `false`, indicating if CORS should be set to `include` credentials. 88 | */ 89 | withCredentials?: boolean 90 | 91 | /** 92 | * Optional fetch implementation to use. Defaults to `globalThis.fetch`. 93 | * Can also be used for advanced use cases like mocking, proxying, custom certs etc. 94 | */ 95 | fetch?: FetchLike 96 | } 97 | 98 | /** 99 | * Mirrors the official DOM typings (sorta). 100 | * 101 | * @public 102 | */ 103 | export interface EventListenerOptions { 104 | /** Not directly used by Node.js. Added for API completeness. Default: `false`. */ 105 | capture?: boolean 106 | } 107 | 108 | /** 109 | * Mirrors the official DOM typings (sorta). 110 | * 111 | * @public 112 | */ 113 | export interface AddEventListenerOptions extends EventListenerOptions { 114 | /** When `true`, the listener is automatically removed when it is first invoked. Default: `false`. */ 115 | once?: boolean 116 | /** When `true`, serves as a hint that the listener will not call the `Event` object's `preventDefault()` method. Default: false. */ 117 | passive?: boolean 118 | /** The listener will be removed when the given AbortSignal object's `abort()` method is called. */ 119 | signal?: AbortSignal 120 | } 121 | 122 | /** 123 | * Mirrors the official DOM typings. 124 | * 125 | * @public 126 | */ 127 | export type EventListenerOrEventListenerObject = EventListener | EventListenerObject 128 | 129 | /** 130 | * Mirrors the official DOM typings. 131 | * 132 | * @public 133 | */ 134 | export interface EventListener { 135 | (evt: Event | MessageEvent): void 136 | } 137 | 138 | /** 139 | * Mirrors the official DOM typings. 140 | * 141 | * @public 142 | */ 143 | export interface EventListenerObject { 144 | handleEvent(object: Event): void 145 | } 146 | -------------------------------------------------------------------------------- /test/browser/browser-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | eventsource-client tests 6 | 7 | 37 | 38 | 39 |
40 |
Preparing test environment…
41 |
42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /test/browser/browser-test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Compiled by ESBuild for the browser 3 | */ 4 | import {registerTests} from '../tests.js' 5 | import {createRunner, type TestEvent} from '../waffletest/index.js' 6 | 7 | if (!windowHasBeenExtended(window)) { 8 | throw new Error('window.reportTest has not been defined by playwright') 9 | } 10 | 11 | const runner = registerTests({ 12 | environment: 'browser', 13 | runner: createRunner({onEvent: window.reportTest}), 14 | port: 3883, 15 | }) 16 | 17 | const el = document.getElementById('waffletest') 18 | if (el) { 19 | el.innerText = `Running ${runner.getTestCount()} tests…` 20 | } 21 | 22 | runner.runTests().then((result) => { 23 | if (!el) { 24 | console.error('Could not find element with id "waffletest"') 25 | return 26 | } 27 | 28 | el.innerText = `Tests completed ${result.success ? 'successfully' : 'with errors'}` 29 | el.className = result.success ? 'success' : 'fail' 30 | }) 31 | 32 | // Added by our playwright-based test runner 33 | interface ExtendedWindow extends Window { 34 | reportTest: (event: TestEvent) => void 35 | } 36 | 37 | function windowHasBeenExtended(win: Window): win is ExtendedWindow { 38 | return 'reportTest' in win && typeof win.reportTest === 'function' 39 | } 40 | -------------------------------------------------------------------------------- /test/browser/client.browser.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /** 3 | * This module: 4 | * - Starts a development server 5 | * - Spawns browsers and points them at the server 6 | * - Runs the tests in the browser (using waffletest) 7 | * - Reports results from browser to node using the registered function `reportTest` 8 | * - Prints the test results to the console 9 | * 10 | * Is this weird? Yes. 11 | * Is there a better way? Maybe. But I haven't found one. 12 | * 13 | * Supported flags: 14 | * 15 | * --browser=firefox|chromium|webkit 16 | * --no-close 17 | * --no-headless 18 | * --serial 19 | */ 20 | import {type BrowserType, chromium, firefox, webkit} from 'playwright' 21 | 22 | import {getServer} from '../server.js' 23 | import {type TestEvent} from '../waffletest/index.js' 24 | import {nodeReporter} from '../waffletest/reporters/nodeReporter.js' 25 | 26 | type BrowserName = 'firefox' | 'chromium' | 'webkit' 27 | 28 | const browsers: Record = { 29 | firefox, 30 | chromium, 31 | webkit, 32 | } 33 | 34 | const {onPass: reportPass, onFail: reportFail, onEnd: reportEnd} = nodeReporter 35 | 36 | const BROWSER_TEST_PORT = 3883 37 | const RUN_IN_SERIAL = process.argv.includes('--serial') 38 | const NO_HEADLESS = process.argv.includes('--no-headless') 39 | const NO_CLOSE = process.argv.includes('--no-close') 40 | 41 | const browserFlag = getBrowserFlag() 42 | if (browserFlag && !isDefinedBrowserType(browserFlag)) { 43 | throw new Error(`Invalid browser flag. Must be one of: ${Object.keys(browsers).join(', ')}`) 44 | } 45 | 46 | const browserFlagType = isDefinedBrowserType(browserFlag) ? browsers[browserFlag] : undefined 47 | 48 | // Run the tests in browsers 49 | ;(async function run() { 50 | const server = await getServer(BROWSER_TEST_PORT) 51 | const jobs = 52 | browserFlag && browserFlagType 53 | ? [{name: browserFlag, browserType: browserFlagType}] 54 | : Object.entries(browsers).map(([name, browserType]) => ({name, browserType})) 55 | 56 | // Run all browsers in parallel, unless --serial is defined 57 | let totalFailures = 0 58 | let totalTests = 0 59 | 60 | if (RUN_IN_SERIAL) { 61 | for (const job of jobs) { 62 | const {failures, tests} = reportBrowserResult(job.name, await runBrowserTest(job.browserType)) 63 | totalFailures += failures 64 | totalTests += tests 65 | } 66 | } else { 67 | await Promise.all( 68 | jobs.map(async (job) => { 69 | const {failures, tests} = reportBrowserResult( 70 | job.name, 71 | await runBrowserTest(job.browserType), 72 | ) 73 | totalFailures += failures 74 | totalTests += tests 75 | }), 76 | ) 77 | } 78 | 79 | function reportBrowserResult( 80 | browserName: string, 81 | events: TestEvent[], 82 | ): {failures: number; passes: number; tests: number} { 83 | console.log(`Browser: ${browserName}`) 84 | 85 | let passes = 0 86 | let failures = 0 87 | for (const event of events) { 88 | switch (event.event) { 89 | case 'start': 90 | // Ignored 91 | break 92 | case 'pass': 93 | passes++ 94 | reportPass(event) 95 | break 96 | case 'fail': 97 | failures++ 98 | reportFail(event) 99 | break 100 | case 'end': 101 | reportEnd(event) 102 | break 103 | default: 104 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 105 | throw new Error(`Unexpected event: ${(event as any).event}`) 106 | } 107 | } 108 | 109 | return {failures, passes, tests: passes + failures} 110 | } 111 | 112 | console.log(`Ran ${totalTests} tests against ${jobs.length} browsers`) 113 | 114 | await server.close() 115 | 116 | if (totalFailures > 0) { 117 | // eslint-disable-next-line no-process-exit 118 | process.exit(1) 119 | } 120 | })() 121 | 122 | async function runBrowserTest(browserType: BrowserType): Promise { 123 | let resolve: (value: TestEvent[] | PromiseLike) => void 124 | const promise = new Promise((_resolve) => { 125 | resolve = _resolve 126 | }) 127 | 128 | const domain = getBaseUrl(BROWSER_TEST_PORT) 129 | const browser = await browserType.launch({headless: !NO_HEADLESS}) 130 | const context = await browser.newContext() 131 | await context.clearCookies() 132 | 133 | const page = await context.newPage() 134 | const events: TestEvent[] = [] 135 | 136 | await page.exposeFunction('reportTest', async (event: TestEvent) => { 137 | events.push(event) 138 | 139 | if (event.event !== 'end') { 140 | return 141 | } 142 | 143 | // Teardown 144 | if (!NO_CLOSE) { 145 | await context.close() 146 | await browser.close() 147 | } 148 | resolve(events) 149 | }) 150 | 151 | await page.goto(`${domain}/browser-test`) 152 | 153 | return promise 154 | } 155 | 156 | function isDefinedBrowserType(browserName: string | undefined): browserName is BrowserName { 157 | return typeof browserName === 'string' && browserName in browsers 158 | } 159 | 160 | function getBrowserFlag(): BrowserName | undefined { 161 | const resolved = (function getFlag() { 162 | // Look for --browser 163 | const flagIndex = process.argv.indexOf('--browser') 164 | let flag = flagIndex === -1 ? undefined : process.argv[flagIndex + 1] 165 | if (flag) { 166 | return flag 167 | } 168 | 169 | // Look for --browser= 170 | flag = process.argv.find((arg) => arg.startsWith('--browser=')) 171 | return flag ? flag.split('=')[1] : undefined 172 | })() 173 | 174 | if (!resolved) { 175 | return undefined 176 | } 177 | 178 | if (!isDefinedBrowserType(resolved)) { 179 | throw new Error(`Invalid browser flag. Must be one of: ${Object.keys(browsers).join(', ')}`) 180 | } 181 | 182 | return resolved 183 | } 184 | 185 | function getBaseUrl(port: number): string { 186 | return typeof document === 'undefined' 187 | ? `http://127.0.0.1:${port}` 188 | : `${location.protocol}//${location.hostname}:${port}` 189 | } 190 | -------------------------------------------------------------------------------- /test/bun/client.bun.test.ts: -------------------------------------------------------------------------------- 1 | import {getServer} from '../server.js' 2 | import {registerTests} from '../tests.js' 3 | import {createRunner} from '../waffletest/index.js' 4 | import {nodeReporter} from '../waffletest/reporters/nodeReporter.js' 5 | 6 | const BUN_TEST_PORT = 3946 7 | 8 | // Run the tests in bun 9 | ;(async function run() { 10 | const server = await getServer(BUN_TEST_PORT) 11 | 12 | const runner = registerTests({ 13 | environment: 'bun', 14 | runner: createRunner(nodeReporter), 15 | fetch: globalThis.fetch, 16 | port: BUN_TEST_PORT, 17 | }) 18 | 19 | const result = await runner.runTests() 20 | 21 | // Teardown 22 | await Promise.race([server.close(), new Promise((resolve) => setTimeout(resolve, 5000))]) 23 | 24 | // eslint-disable-next-line no-process-exit 25 | process.exit(result.failures) 26 | })() 27 | -------------------------------------------------------------------------------- /test/deno/client.deno.test.ts: -------------------------------------------------------------------------------- 1 | import {getServer} from '../server.js' 2 | import {registerTests} from '../tests.js' 3 | import {createRunner} from '../waffletest/index.js' 4 | import {nodeReporter} from '../waffletest/reporters/nodeReporter.js' 5 | 6 | const DENO_TEST_PORT = 3950 7 | 8 | // Run the tests in deno 9 | ;(async function run() { 10 | const server = await getServer(DENO_TEST_PORT) 11 | 12 | const runner = registerTests({ 13 | environment: 'deno', 14 | runner: createRunner(nodeReporter), 15 | fetch: globalThis.fetch, 16 | port: DENO_TEST_PORT, 17 | }) 18 | 19 | const result = await runner.runTests() 20 | 21 | // Teardown 22 | await server.close() 23 | 24 | if (typeof process !== 'undefined' && 'exit' in process && typeof process.exit === 'function') { 25 | // eslint-disable-next-line no-process-exit 26 | process.exit(result.failures) 27 | } else if (typeof globalThis.Deno !== 'undefined') { 28 | globalThis.Deno.exit(result.failures) 29 | } else if (result.failures > 0) { 30 | throw new Error(`Tests failed: ${result.failures}`) 31 | } 32 | })() 33 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | export const unicodeLines = [ 2 | '🦄 are cool. 🐾 in the snow. Allyson Felix, 🏃🏽‍♀️ 🥇 2012 London!', 3 | 'Espen ♥ Kokos', 4 | ] 5 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import sinon, {type SinonSpy} from 'sinon' 2 | 3 | import {EventSource} from '../src/EventSource' 4 | 5 | type MessageReceiver = SinonSpy & { 6 | waitForCallCount: (num: number, timeout?: number) => Promise 7 | } 8 | 9 | const TYPE_ASSERTER = Symbol.for('waffle.type-asserter') 10 | const PATTERN_ASSERTER = Symbol.for('waffle.pattern-asserter') 11 | 12 | export class ExpectationError extends Error { 13 | type = 'ExpectationError' 14 | 15 | public expected: unknown 16 | public got: unknown 17 | 18 | constructor(message: string, expected?: unknown, got?: unknown) { 19 | super(message) 20 | this.name = 'ExpectationError' 21 | this.expected = expected 22 | this.got = got 23 | } 24 | } 25 | 26 | interface CallCounterOptions { 27 | name?: string 28 | onCall?: (info: {numCalls: number}) => void 29 | } 30 | 31 | export function getCallCounter({name = '', onCall}: CallCounterOptions = {}): MessageReceiver { 32 | const listeners: [number, () => void][] = [] 33 | 34 | let numCalls = 0 35 | const spy = sinon.fake(() => { 36 | numCalls++ 37 | 38 | if (onCall) { 39 | onCall({numCalls}) 40 | } 41 | 42 | listeners.forEach(([wanted, resolve]) => { 43 | if (wanted === numCalls) { 44 | resolve() 45 | } 46 | }) 47 | }) 48 | 49 | const fn = spy as unknown as MessageReceiver 50 | fn.waitForCallCount = (num: number, timeout: number = 10000) => { 51 | return Promise.race([ 52 | new Promise((resolve, reject) => { 53 | if (numCalls > num) { 54 | reject(new Error(`Already past ${name} call count of ${num}`)) 55 | } else if (numCalls === num) { 56 | resolve() 57 | } else { 58 | listeners.push([num, resolve]) 59 | } 60 | }), 61 | new Promise((_, reject) => { 62 | setTimeout(reject, timeout, new Error(`Timeout waiting for ${name} call count of ${num}`)) 63 | }), 64 | ]) 65 | } 66 | 67 | return fn 68 | } 69 | 70 | export function deferClose(es: EventSource, timeout = 25): Promise { 71 | return new Promise((resolve) => setTimeout(() => resolve(es.close()), timeout)) 72 | } 73 | 74 | export function expect( 75 | thing: unknown, 76 | descriptor: string = '', 77 | ): { 78 | toBe(expected: unknown): void 79 | notToBe(expected: unknown): void 80 | toBeLessThan(thanNum: number): void 81 | toMatchObject(expected: Record): void 82 | toThrowError(expectedMessage: RegExp): void 83 | } { 84 | return { 85 | toBe(expected: unknown) { 86 | if (thing === expected) { 87 | return 88 | } 89 | 90 | if (descriptor) { 91 | throw new ExpectationError( 92 | `Expected ${descriptor} to be ${JSON.stringify(expected)}, got ${JSON.stringify(thing)}`, 93 | ) 94 | } 95 | 96 | throw new ExpectationError( 97 | `Expected ${JSON.stringify(thing)} to be ${JSON.stringify(expected)}`, 98 | ) 99 | }, 100 | 101 | notToBe(expected: unknown) { 102 | if (thing !== expected) { 103 | return 104 | } 105 | 106 | if (descriptor) { 107 | throw new ExpectationError( 108 | `Expected ${descriptor} NOT to be ${JSON.stringify(expected)}, got ${JSON.stringify(thing)}`, 109 | ) 110 | } 111 | 112 | throw new ExpectationError( 113 | `Expected ${JSON.stringify(thing)} NOT to be ${JSON.stringify(expected)}`, 114 | ) 115 | }, 116 | 117 | toBeLessThan(thanNum: number) { 118 | if (typeof thing !== 'number' || thing >= thanNum) { 119 | throw new ExpectationError(`Expected ${thing} to be less than ${thanNum}`) 120 | } 121 | }, 122 | 123 | toMatchObject(expected: Record) { 124 | if (!isPlainObject(thing)) { 125 | throw new ExpectationError(`Expected an object, was... not`) 126 | } 127 | 128 | Object.keys(expected).forEach((key) => { 129 | if (!(key in thing)) { 130 | throw new ExpectationError( 131 | `Expected key "${key}" to be in ${descriptor || 'object'}, was not`, 132 | expected, 133 | thing, 134 | ) 135 | } 136 | 137 | if ( 138 | typeof expected[key] === 'object' && 139 | expected[key] !== null && 140 | TYPE_ASSERTER in expected[key] 141 | ) { 142 | if (typeof thing[key] !== expected[key][TYPE_ASSERTER]) { 143 | throw new ExpectationError( 144 | `Expected key "${key}" of ${descriptor || 'object'} to be any of type ${expect[key][TYPE_ASSERTER]}, got ${typeof thing[key]}`, 145 | ) 146 | } 147 | return 148 | } 149 | 150 | if ( 151 | typeof expected[key] === 'object' && 152 | expected[key] !== null && 153 | PATTERN_ASSERTER in expected[key] 154 | ) { 155 | if (typeof thing[key] !== 'string') { 156 | throw new ExpectationError( 157 | `Expected key "${key}" of ${descriptor || 'object'} to be a string, got ${typeof thing[key]}`, 158 | ) 159 | } 160 | 161 | if (typeof expected[key][PATTERN_ASSERTER] === 'string') { 162 | if (!thing[key].includes(expected[key][PATTERN_ASSERTER])) { 163 | throw new ExpectationError( 164 | `Expected key "${key}" of ${descriptor || 'object'} to include "${expected[key][PATTERN_ASSERTER]}", got "${thing[key]}"`, 165 | ) 166 | } 167 | return 168 | } 169 | 170 | if (expected[key][PATTERN_ASSERTER] instanceof RegExp) { 171 | if (!expected[key][PATTERN_ASSERTER].test(thing[key])) { 172 | throw new ExpectationError( 173 | `Expected key "${key}" of ${descriptor || 'object'} to match pattern ${expected[key][PATTERN_ASSERTER]}, got "${thing[key]}"`, 174 | ) 175 | } 176 | return 177 | } 178 | 179 | throw new Error('Invalid pattern asserter') 180 | } 181 | 182 | if (thing[key] !== expected[key]) { 183 | throw new ExpectationError( 184 | `Expected key "${key}" of ${descriptor || 'object'} to be ${JSON.stringify(expected[key])}, was ${JSON.stringify( 185 | thing[key], 186 | )}`, 187 | ) 188 | } 189 | }) 190 | }, 191 | 192 | toThrowError(expectedMessage: RegExp) { 193 | if (typeof thing !== 'function') { 194 | throw new ExpectationError( 195 | `Expected a function that was going to throw, but wasn't a function`, 196 | ) 197 | } 198 | 199 | try { 200 | thing() 201 | } catch (err: unknown) { 202 | const message = err instanceof Error ? err.message : `${err}` 203 | if (!expectedMessage.test(message)) { 204 | throw new ExpectationError( 205 | `Expected error message to match ${expectedMessage}, got ${message}`, 206 | ) 207 | } 208 | return 209 | } 210 | 211 | throw new ExpectationError('Expected function to throw error, but did not') 212 | }, 213 | } 214 | } 215 | 216 | expect.any = ( 217 | type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function', 218 | ) => { 219 | return { 220 | [TYPE_ASSERTER]: type, 221 | } 222 | } 223 | 224 | expect.stringMatching = (expected: string | RegExp) => { 225 | return { 226 | [PATTERN_ASSERTER]: expected, 227 | } 228 | } 229 | 230 | function isPlainObject(obj: unknown): obj is Record { 231 | return typeof obj === 'object' && obj !== null && !Array.isArray(obj) 232 | } 233 | -------------------------------------------------------------------------------- /test/node/client.node.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module: 3 | * - Starts a development server 4 | * - Runs tests against them using a ducktaped simple test/assertion thing 5 | * - Prints the test results to the console 6 | * 7 | * Could we use a testing library? Yes. 8 | * Would that add a whole lot of value? No. 9 | */ 10 | import {getServer} from '../server.js' 11 | import {registerTests} from '../tests.js' 12 | import {nodeReporter} from '../waffletest/reporters/nodeReporter.js' 13 | import {createRunner} from '../waffletest/runner.js' 14 | 15 | const NODE_TEST_PORT = 3944 16 | 17 | // Run the tests in node.js 18 | ;(async function run() { 19 | const server = await getServer(NODE_TEST_PORT) 20 | 21 | const runner = registerTests({ 22 | environment: 'node', 23 | runner: createRunner(nodeReporter), 24 | fetch: globalThis.fetch, 25 | port: NODE_TEST_PORT, 26 | }) 27 | 28 | const result = await runner.runTests() 29 | 30 | // Teardown 31 | await server.close() 32 | 33 | // eslint-disable-next-line no-process-exit 34 | process.exit(result.failures) 35 | })() 36 | -------------------------------------------------------------------------------- /test/server.ts: -------------------------------------------------------------------------------- 1 | import {createHash} from 'node:crypto' 2 | import {createReadStream} from 'node:fs' 3 | import { 4 | createServer, 5 | type IncomingMessage, 6 | type OutgoingHttpHeaders, 7 | type Server, 8 | type ServerResponse, 9 | } from 'node:http' 10 | import {dirname, resolve as resolvePath} from 'node:path' 11 | import {fileURLToPath} from 'node:url' 12 | 13 | import esbuild from 'esbuild' 14 | import {encode} from 'eventsource-encoder' 15 | 16 | import {unicodeLines} from './fixtures.js' 17 | 18 | const isDeno = typeof globalThis.Deno !== 'undefined' 19 | /* {[client id]: number of connects} */ 20 | const connectCounts = new Map() 21 | 22 | export async function getServer(port: number): Promise<{close: () => Promise}> { 23 | const server = await promServer(port) 24 | 25 | const closeServer = () => 26 | new Promise((resolve, reject) => { 27 | server.close((err) => (err ? reject(err) : resolve())) 28 | }) 29 | 30 | return { 31 | close: closeServer, 32 | } 33 | 34 | function promServer(portNumber: number) { 35 | return new Promise((resolve, reject) => { 36 | const srv = createServer(onRequest) 37 | .on('error', reject) 38 | .listen(portNumber, isDeno ? '127.0.0.1' : '::', () => resolve(srv)) 39 | }) 40 | } 41 | } 42 | 43 | function onRequest(req: IncomingMessage, res: ServerResponse) { 44 | // Disable Nagle's algorithm for testing 45 | if (res.socket && 'setNoDelay' in res.socket) { 46 | res.socket.setNoDelay(true) 47 | } 48 | 49 | const path = new URL(req.url || '/', 'http://localhost').pathname 50 | switch (path) { 51 | // Server-Sent Event endpoints 52 | case '/': 53 | return writeDefault(req, res) 54 | case '/counter': 55 | return writeCounter(req, res) 56 | case '/identified': 57 | return writeIdentifiedListeners(req, res) 58 | case '/end-after-one': 59 | return writeOne(req, res) 60 | case '/slow-connect': 61 | return writeSlowConnect(req, res) 62 | case '/debug': 63 | return writeDebug(req, res) 64 | case '/set-cookie': 65 | return writeCookies(req, res) 66 | case '/authed': 67 | return writeAuthed(req, res) 68 | case '/cors': 69 | return writeCors(req, res) 70 | case '/stalled': 71 | return writeStalledConnection(req, res) 72 | case '/trickle': 73 | return writeTricklingConnection(req, res) 74 | case '/unicode': 75 | return writeUnicode(req, res) 76 | case '/redirect': 77 | return writeRedirect(req, res) 78 | case '/redirect-target': 79 | return writeRedirectTarget(req, res) 80 | 81 | // Browser test endpoints (HTML/JS) 82 | case '/browser-test': 83 | return writeBrowserTestPage(req, res) 84 | case '/browser-test.js': 85 | return writeBrowserTestScript(req, res) 86 | 87 | // Fallback, eg 404 88 | default: 89 | return writeFallback(req, res) 90 | } 91 | } 92 | 93 | function writeDefault(_req: IncomingMessage, res: ServerResponse) { 94 | res.writeHead(200, { 95 | 'Content-Type': 'text/event-stream', 96 | 'Cache-Control': 'no-cache', 97 | Connection: 'keep-alive', 98 | }) 99 | 100 | tryWrite( 101 | res, 102 | encode({ 103 | event: 'welcome', 104 | data: 'Hello, world!', 105 | }), 106 | ) 107 | 108 | // For some reason, Bun seems to need this to flush 109 | tryWrite(res, ':\n') 110 | } 111 | 112 | /** 113 | * Writes 3 messages, then closes connection. 114 | * Picks up event ID and continues from there. 115 | */ 116 | async function writeCounter(req: IncomingMessage, res: ServerResponse) { 117 | res.writeHead(200, { 118 | 'Content-Type': 'text/event-stream', 119 | 'Cache-Control': 'no-cache', 120 | Connection: 'keep-alive', 121 | }) 122 | 123 | const event = new URL(req.url || '/', 'http://localhost').searchParams.get('event') 124 | 125 | tryWrite(res, encode({retry: 50})) 126 | 127 | let counter = parseInt(getLastEventId(req) || '0', 10) 128 | for (let i = 0; i < 3; i++) { 129 | counter++ 130 | tryWrite( 131 | res, 132 | encode({ 133 | ...(event === '' ? {} : {event: event || 'counter'}), 134 | data: `Counter is at ${counter}`, 135 | id: `${counter}`, 136 | }), 137 | ) 138 | await delay(5) 139 | } 140 | 141 | res.end() 142 | } 143 | 144 | async function writeIdentifiedListeners(req: IncomingMessage, res: ServerResponse) { 145 | const url = new URL(req.url || '/', 'http://localhost') 146 | const clientId = url.searchParams.get('client-id') 147 | if (!clientId) { 148 | res.writeHead(400, { 149 | 'Content-Type': 'application/json', 150 | 'Cache-Control': 'no-cache', 151 | Connection: 'keep-alive', 152 | }) 153 | tryWrite(res, JSON.stringify({error: 'Missing "id" or "client-id" query parameter'})) 154 | res.end() 155 | return 156 | } 157 | 158 | // SSE endpoint, tracks how many listeners have connected with a given client ID 159 | if ((req.headers.accept || '').includes('text/event-stream')) { 160 | connectCounts.set(clientId, (connectCounts.get(clientId) || 0) + 1) 161 | 162 | res.writeHead(200, { 163 | 'Content-Type': 'text/event-stream', 164 | 'Cache-Control': 'no-cache', 165 | Connection: 'keep-alive', 166 | }) 167 | tryWrite(res, encode({retry: 250})) 168 | tryWrite(res, encode({data: `${connectCounts.get(clientId)}`})) 169 | 170 | if (url.searchParams.get('auto-close')) { 171 | res.end() 172 | } 173 | 174 | return 175 | } 176 | 177 | // JSON endpoint, returns the number of connects for a given client ID 178 | res.writeHead(200, { 179 | 'Content-Type': 'application/json', 180 | 'Cache-Control': 'no-cache', 181 | }) 182 | tryWrite(res, JSON.stringify({clientIdConnects: connectCounts.get(clientId) ?? 0})) 183 | res.end() 184 | } 185 | 186 | function writeOne(req: IncomingMessage, res: ServerResponse) { 187 | const last = getLastEventId(req) 188 | res.writeHead(last ? 204 : 200, { 189 | 'Content-Type': 'text/event-stream', 190 | 'Cache-Control': 'no-cache', 191 | Connection: 'keep-alive', 192 | }) 193 | 194 | if (!last) { 195 | tryWrite(res, encode({retry: 50})) 196 | tryWrite( 197 | res, 198 | encode({ 199 | event: 'progress', 200 | data: '100%', 201 | id: 'prct-100', 202 | }), 203 | ) 204 | } 205 | 206 | res.end() 207 | } 208 | 209 | async function writeSlowConnect(_req: IncomingMessage, res: ServerResponse) { 210 | await delay(200) 211 | 212 | res.writeHead(200, { 213 | 'Content-Type': 'text/event-stream', 214 | 'Cache-Control': 'no-cache', 215 | Connection: 'keep-alive', 216 | }) 217 | 218 | tryWrite( 219 | res, 220 | encode({ 221 | event: 'welcome', 222 | data: 'That was a slow connect, was it not?', 223 | }), 224 | ) 225 | 226 | res.end() 227 | } 228 | 229 | async function writeStalledConnection(req: IncomingMessage, res: ServerResponse) { 230 | res.writeHead(200, { 231 | 'Content-Type': 'text/event-stream', 232 | 'Cache-Control': 'no-cache', 233 | Connection: 'keep-alive', 234 | }) 235 | 236 | const lastId = getLastEventId(req) 237 | const reconnected = lastId === '1' 238 | 239 | tryWrite( 240 | res, 241 | encode({ 242 | id: reconnected ? '2' : '1', 243 | event: 'welcome', 244 | data: reconnected 245 | ? 'Welcome back' 246 | : 'Connected - now I will sleep for "too long" without sending data', 247 | }), 248 | ) 249 | 250 | if (reconnected) { 251 | await delay(250) 252 | tryWrite( 253 | res, 254 | encode({ 255 | id: '3', 256 | event: 'success', 257 | data: 'You waited long enough!', 258 | }), 259 | ) 260 | 261 | res.end() 262 | } 263 | 264 | // Intentionally not closing on first-connect that never sends data after welcome 265 | } 266 | 267 | async function writeUnicode(_req: IncomingMessage, res: ServerResponse) { 268 | res.writeHead(200, { 269 | 'Content-Type': 'text/event-stream', 270 | 'Cache-Control': 'no-cache', 271 | Connection: 'keep-alive', 272 | }) 273 | 274 | tryWrite( 275 | res, 276 | encode({ 277 | event: 'welcome', 278 | data: 'Connected - I will now send some chonks (cuter chunks) with unicode', 279 | }), 280 | ) 281 | 282 | tryWrite( 283 | res, 284 | encode({ 285 | event: 'unicode', 286 | data: unicodeLines[0], 287 | }), 288 | ) 289 | 290 | await delay(100) 291 | 292 | // Start of a valid SSE chunk 293 | tryWrite(res, 'event: unicode\ndata: ') 294 | 295 | // Write "Espen ❤️ Kokos" in two halves: 296 | // 1st: Espen � [..., 226, 153] 297 | // 2st: � Kokos [165, 32, ...] 298 | tryWrite(res, new Uint8Array([69, 115, 112, 101, 110, 32, 226, 153])) 299 | 300 | // Give time to the client to process the first half 301 | await delay(1000) 302 | 303 | tryWrite(res, new Uint8Array([165, 32, 75, 111, 107, 111, 115])) 304 | 305 | // Closing end of packet 306 | tryWrite(res, '\n\n\n\n') 307 | 308 | tryWrite(res, encode({event: 'disconnect', data: 'Thanks for listening'})) 309 | res.end() 310 | } 311 | 312 | /* Holds a record of unique IDs and how many times they've been seen on the redirect route */ 313 | const redirects = new Map() 314 | 315 | async function writeRedirect(req: IncomingMessage, res: ServerResponse) { 316 | const host = req.headers.host && `http://${req.headers.host}` 317 | const url = new URL(req.url || '/', host || 'http://localhost') 318 | const id = url.searchParams.get('id') 319 | if (!id) { 320 | res.writeHead(400, { 321 | 'Content-Type': 'application/json', 322 | 'Cache-Control': 'no-cache', 323 | Connection: 'keep-alive', 324 | }) 325 | tryWrite(res, JSON.stringify({error: 'Missing "id" query parameter'})) 326 | res.end() 327 | return 328 | } 329 | 330 | redirects.set(id, (redirects.get(id) || 0) + 1) 331 | 332 | const redirectUrl = url.searchParams.get('code') || '301' 333 | const cors = url.searchParams.get('cors') === 'true' 334 | 335 | const xOriginUrl = new URL(url) 336 | xOriginUrl.hostname = url.hostname === 'localhost' ? '127.0.0.1' : 'localhost' 337 | 338 | const status = parseInt(redirectUrl, 10) 339 | const path = `/redirect-target?from=${encodeURIComponent(url.toString())}&id=${id}` 340 | 341 | res.writeHead(status, { 342 | 'Cache-Control': 'no-cache', 343 | Location: cors ? `${xOriginUrl.origin}${path}` : path, 344 | Connection: 'keep-alive', 345 | }) 346 | 347 | res.end() 348 | } 349 | 350 | async function writeRedirectTarget(req: IncomingMessage, res: ServerResponse) { 351 | const host = req.headers.host && `http://${req.headers.host}` 352 | const url = new URL(req.url || '/', host || 'http://localhost') 353 | const id = url.searchParams.get('id') 354 | const origin = req.headers.origin 355 | const cors: OutgoingHttpHeaders = origin 356 | ? {'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Credentials': 'true'} 357 | : {} 358 | 359 | if (req.headers['access-control-request-headers']) { 360 | cors['Access-Control-Allow-Headers'] = req.headers['access-control-request-headers'] 361 | } 362 | 363 | res.writeHead(200, { 364 | 'Content-Type': 'text/event-stream', 365 | 'Cache-Control': 'no-cache', 366 | Connection: 'keep-alive', 367 | ...cors, 368 | }) 369 | 370 | tryWrite( 371 | res, 372 | encode({ 373 | retry: 25, 374 | data: JSON.stringify({ 375 | origin: url.origin, 376 | from: url.searchParams.get('from'), 377 | redirects: redirects.get(id || '') || 0, 378 | auth: req.headers.authorization || null, 379 | }), 380 | }), 381 | ) 382 | 383 | // Bun behaves weirdly when transfer-encoding is not chunked, which it automatically 384 | // does for smaller packets. By trickling out some comments, we hackishly prevent 385 | // this from happening. Trying to get a reproducible test case for this so I can file 386 | // a bug report upstream, but working around it for now. 387 | for (let i = 0; i < 20; i++) { 388 | await delay(20) 389 | tryWrite(res, ':\n') 390 | } 391 | 392 | res.end() 393 | } 394 | 395 | async function writeTricklingConnection(_req: IncomingMessage, res: ServerResponse) { 396 | res.writeHead(200, { 397 | 'Content-Type': 'text/event-stream', 398 | 'Cache-Control': 'no-cache', 399 | Connection: 'keep-alive', 400 | }) 401 | 402 | tryWrite( 403 | res, 404 | encode({ 405 | event: 'welcome', 406 | data: 'Connected - now I will keep sending "comments" for a while', 407 | }), 408 | ) 409 | 410 | for (let i = 0; i < 60; i++) { 411 | await delay(500) 412 | tryWrite(res, ':\n') 413 | } 414 | 415 | tryWrite(res, encode({event: 'disconnect', data: 'Thanks for listening'})) 416 | res.end() 417 | } 418 | 419 | function writeCors(req: IncomingMessage, res: ServerResponse) { 420 | const origin = req.headers.origin 421 | const cors = origin ? {'Access-Control-Allow-Origin': origin} : {} 422 | 423 | res.writeHead(200, { 424 | 'Content-Type': 'text/event-stream', 425 | 'Cache-Control': 'no-cache', 426 | Connection: 'keep-alive', 427 | ...cors, 428 | }) 429 | 430 | tryWrite( 431 | res, 432 | encode({ 433 | event: 'origin', 434 | data: origin || '', 435 | }), 436 | ) 437 | 438 | res.end() 439 | } 440 | 441 | async function writeDebug(req: IncomingMessage, res: ServerResponse) { 442 | const hash = new Promise((resolve, reject) => { 443 | const bodyHash = createHash('sha256') 444 | req.on('error', reject) 445 | req.on('data', (chunk) => bodyHash.update(chunk)) 446 | req.on('end', () => resolve(bodyHash.digest('hex'))) 447 | }) 448 | 449 | let bodyHash: string 450 | try { 451 | bodyHash = await hash 452 | } catch (err: unknown) { 453 | res.writeHead(500, 'Internal Server Error') 454 | tryWrite(res, err instanceof Error ? err.message : `${err}`) 455 | res.end() 456 | return 457 | } 458 | 459 | res.writeHead(200, { 460 | 'Content-Type': 'text/event-stream', 461 | 'Cache-Control': 'no-cache', 462 | Connection: 'keep-alive', 463 | }) 464 | 465 | tryWrite( 466 | res, 467 | encode({ 468 | event: 'debug', 469 | data: JSON.stringify({ 470 | method: req.method, 471 | headers: req.headers, 472 | bodyHash, 473 | }), 474 | }), 475 | ) 476 | 477 | res.end() 478 | } 479 | 480 | /** 481 | * Ideally we'd just set these in the storage state, but Playwright does not seem to 482 | * be able to for some obscure reason - is not set if passed in page context or through 483 | * `addCookies()`. 484 | */ 485 | function writeCookies(req: IncomingMessage, res: ServerResponse) { 486 | res.writeHead(200, { 487 | 'Access-Control-Allow-Origin': req.headers.origin || '*', 488 | 'Access-Control-Allow-Credentials': 'true', 489 | 'Content-Type': 'application/json', 490 | 'Cache-Control': 'no-cache', 491 | 'Set-Cookie': 'someSession=someValue; Path=/authed; HttpOnly; SameSite=Lax;', 492 | Connection: 'keep-alive', 493 | }) 494 | tryWrite(res, JSON.stringify({cookiesWritten: true})) 495 | res.end() 496 | } 497 | 498 | function writeAuthed(req: IncomingMessage, res: ServerResponse) { 499 | const headers = { 500 | 'Access-Control-Allow-Origin': req.headers.origin || '*', 501 | 'Access-Control-Allow-Credentials': 'true', 502 | 'Cache-Control': 'no-cache', 503 | Connection: 'keep-alive', 504 | } 505 | 506 | if (req.method === 'OPTIONS') { 507 | res.writeHead(204, headers) 508 | res.end() 509 | return 510 | } 511 | 512 | res.writeHead(200, {...headers, 'content-type': 'text/event-stream'}) 513 | 514 | tryWrite( 515 | res, 516 | encode({ 517 | event: 'authInfo', 518 | data: JSON.stringify({cookies: req.headers.cookie || ''}), 519 | }), 520 | ) 521 | 522 | res.end() 523 | } 524 | 525 | function writeFallback(_req: IncomingMessage, res: ServerResponse) { 526 | res.writeHead(404, { 527 | 'Content-Type': 'text/plain', 528 | 'Cache-Control': 'no-cache', 529 | Connection: 'close', 530 | }) 531 | 532 | tryWrite(res, 'File not found') 533 | res.end() 534 | } 535 | 536 | function writeBrowserTestPage(_req: IncomingMessage, res: ServerResponse) { 537 | res.writeHead(200, { 538 | 'Content-Type': 'text/html; charset=utf-8', 539 | 'Cache-Control': 'no-cache', 540 | Connection: 'close', 541 | }) 542 | 543 | const thisDir = dirname(fileURLToPath(import.meta.url)) 544 | createReadStream(resolvePath(thisDir, './browser/browser-test.html')).pipe(res) 545 | } 546 | 547 | async function writeBrowserTestScript(_req: IncomingMessage, res: ServerResponse) { 548 | res.writeHead(200, { 549 | 'Content-Type': 'text/javascript; charset=utf-8', 550 | 'Cache-Control': 'no-cache', 551 | Connection: 'close', 552 | }) 553 | 554 | const thisDir = dirname(fileURLToPath(import.meta.url)) 555 | const build = await esbuild.build({ 556 | bundle: true, 557 | target: ['chrome71', 'edge79', 'firefox105', 'safari14.1'], 558 | entryPoints: [resolvePath(thisDir, './browser/browser-test.ts')], 559 | sourcemap: 'inline', 560 | write: false, 561 | outdir: 'out', 562 | }) 563 | 564 | tryWrite(res, build.outputFiles.map((file) => file.text).join('\n\n')) 565 | res.end() 566 | } 567 | 568 | function delay(ms: number): Promise { 569 | return new Promise((resolve) => setTimeout(resolve, ms)) 570 | } 571 | 572 | function getLastEventId(req: IncomingMessage): string | undefined { 573 | const lastId = req.headers['last-event-id'] 574 | return typeof lastId === 'string' ? lastId : undefined 575 | } 576 | 577 | function tryWrite(res: ServerResponse, chunk: string | Uint8Array) { 578 | try { 579 | res.write(chunk) 580 | } catch (err: unknown) { 581 | // Deno/Bun sometimes throws on write after close 582 | if (err instanceof TypeError && err.message.includes('cannot close or enqueue')) { 583 | return 584 | } 585 | 586 | if (err instanceof Error && err.message.includes('Stream already ended')) { 587 | return 588 | } 589 | 590 | throw err 591 | } 592 | } 593 | -------------------------------------------------------------------------------- /test/tests.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventSource as OurEventSource, 3 | type EventSourceFetchInit, 4 | type FetchLike, 5 | } from '../src/index.js' 6 | import {unicodeLines} from './fixtures.js' 7 | import {deferClose, expect, getCallCounter} from './helpers.js' 8 | import type {TestRunner} from './waffletest/index.js' 9 | 10 | export function registerTests(options: { 11 | environment: string 12 | runner: TestRunner 13 | port: number 14 | fetch?: typeof fetch 15 | }): TestRunner { 16 | const {port, fetch, runner, environment} = options 17 | 18 | // eslint-disable-next-line no-empty-function 19 | const browserTest = environment === 'browser' ? runner.registerTest : function noop() {} 20 | const test = runner.registerTest 21 | 22 | const baseUrl = 23 | typeof document === 'undefined' 24 | ? 'http://127.0.0.1' 25 | : `${location.protocol}//${location.hostname}` 26 | 27 | test('can connect, receive message, manually disconnect', async () => { 28 | const onMessage = getCallCounter({name: 'onMessage'}) 29 | const es = new OurEventSource(new URL(`${baseUrl}:${port}/`)) 30 | es.addEventListener('welcome', onMessage, false) 31 | 32 | await onMessage.waitForCallCount(1) 33 | 34 | expect(onMessage.callCount).toBe(1) 35 | expect(onMessage.lastCall.lastArg).toMatchObject({ 36 | data: 'Hello, world!', 37 | origin: `${baseUrl}:${port}`, 38 | }) 39 | 40 | await deferClose(es) 41 | }) 42 | 43 | test('`target` on open, message and error events is the EventSource instance', async () => { 44 | const onOpen = getCallCounter({name: 'onOpen'}) 45 | const onMessage = getCallCounter({name: 'onMessage'}) 46 | const onError = getCallCounter({name: 'onError'}) 47 | const es = new OurEventSource(`${baseUrl}:${port}/end-after-one`) 48 | 49 | es.addEventListener('open', onOpen) 50 | es.addEventListener('progress', onMessage) 51 | es.addEventListener('error', onError) 52 | 53 | await onOpen.waitForCallCount(1) 54 | await onMessage.waitForCallCount(1) 55 | await onError.waitForCallCount(1) 56 | 57 | expect(onOpen.lastCall.lastArg.target).toBe(es) 58 | expect(onMessage.lastCall.lastArg.target).toBe(es) 59 | expect(onError.lastCall.lastArg.target).toBe(es) 60 | 61 | await deferClose(es) 62 | }) 63 | 64 | test('can connect using URL string only', async () => { 65 | const es = new OurEventSource(`${baseUrl}:${port}/`) 66 | const onMessage = getCallCounter({name: 'onMessage'}) 67 | es.addEventListener('welcome', onMessage, false) 68 | 69 | await onMessage.waitForCallCount(1) 70 | await deferClose(es) 71 | }) 72 | 73 | test('passes `no-store` to `fetch`, avoiding cache', async () => { 74 | let passedInit: EventSourceFetchInit | undefined 75 | 76 | const onMessage = getCallCounter({name: 'onMessage'}) 77 | const es = new OurEventSource(new URL(`${baseUrl}:${port}/debug`), { 78 | fetch: (url, init) => { 79 | passedInit = init 80 | return (fetch || globalThis.fetch)(url, init) 81 | }, 82 | }) 83 | 84 | es.addEventListener('debug', onMessage, false) 85 | await onMessage.waitForCallCount(1) 86 | 87 | expect(passedInit).toMatchObject({cache: 'no-store'}) 88 | await deferClose(es) 89 | }) 90 | 91 | test('can handle unicode data correctly', async () => { 92 | const onMessage = getCallCounter({name: 'onMessage'}) 93 | const es = new OurEventSource(`${baseUrl}:${port}/unicode`, {fetch}) 94 | 95 | const messages: Array<{data: string}> = [] 96 | es.addEventListener('unicode', (evt) => { 97 | messages.push(evt) 98 | onMessage() 99 | }) 100 | 101 | await onMessage.waitForCallCount(2) 102 | expect(messages[0].data).toBe(unicodeLines[0]) 103 | expect(messages[1].data).toBe(unicodeLines[1]) 104 | 105 | await deferClose(es) 106 | }) 107 | 108 | test('can use `es.onopen` to listen for open events, nulling it unsubscribes', async () => { 109 | const onError = getCallCounter({name: 'onError'}) 110 | const onOpen = getCallCounter({name: 'onOpen'}) 111 | const es = new OurEventSource(`${baseUrl}:${port}/counter`, {fetch}) 112 | es.addEventListener('error', onError) 113 | es.onopen = onOpen 114 | 115 | await onOpen.waitForCallCount(2) 116 | es.onopen = null 117 | 118 | await onError.waitForCallCount(4) // 4 disconnects 119 | 120 | // If `es.onopen = null` did not work, this should be 4 121 | expect(onOpen.callCount).toBe(2) 122 | await deferClose(es) 123 | }) 124 | 125 | test('can use `es.onerror` to listen for error events, nulling it unsubscribes', async () => { 126 | const onError = getCallCounter({name: 'onError'}) 127 | const onOpen = getCallCounter({name: 'onOpen'}) 128 | const es = new OurEventSource(`${baseUrl}:${port}/counter`, {fetch}) 129 | es.addEventListener('open', onOpen) 130 | es.onerror = onError 131 | 132 | await onOpen.waitForCallCount(3) 133 | es.onerror = null 134 | 135 | await onOpen.waitForCallCount(4) // 4 connects 136 | 137 | // If `es.onerror = null` did not work, this should be 4 138 | expect(onError.callCount).toBe(2) 139 | await deferClose(es) 140 | }) 141 | 142 | test('can use `es.onmessage` to listen for explicit `message` events, nulling it unsubscribes', async () => { 143 | const onMessage = getCallCounter({name: 'onMessage'}) 144 | const onError = getCallCounter({name: 'onError'}) 145 | const es = new OurEventSource(`${baseUrl}:${port}/counter?event=message`, {fetch}) 146 | es.addEventListener('error', onError) 147 | es.onmessage = onMessage 148 | 149 | await onError.waitForCallCount(2) 150 | es.onmessage = null 151 | 152 | await onError.waitForCallCount(3) // 3 disconnects 153 | 154 | // If `es.onmessage = null` did not work, this should be 9, 155 | // since each connect emits 3 message then closes 156 | expect(onMessage.callCount).toBe(6) 157 | await deferClose(es) 158 | }) 159 | 160 | test('can use `es.onmessage` to listen for implicit `message` events, nulling it unsubscribes', async () => { 161 | const onMessage = getCallCounter({name: 'onMessage'}) 162 | const onError = getCallCounter({name: 'onError'}) 163 | const es = new OurEventSource(`${baseUrl}:${port}/counter?event=`, {fetch}) 164 | es.addEventListener('error', onError) 165 | es.onmessage = onMessage 166 | 167 | await onError.waitForCallCount(2) 168 | es.onmessage = null 169 | 170 | await onError.waitForCallCount(3) // 3 disconnects 171 | 172 | // If `es.onmessage = null` did not work, this should be 9, 173 | // since each connect emits 3 message then closes 174 | expect(onMessage.callCount).toBe(6) 175 | await deferClose(es) 176 | }) 177 | 178 | test('`es.onmessage` does not fire for non-`message` events', async () => { 179 | const onMessage = getCallCounter({name: 'onMessage'}) 180 | const onOpen = getCallCounter({name: 'onOpen'}) 181 | const es = new OurEventSource(`${baseUrl}:${port}/counter`, {fetch}) 182 | es.addEventListener('open', onOpen) 183 | es.onmessage = onMessage 184 | 185 | await onOpen.waitForCallCount(3) 186 | es.onmessage = null 187 | 188 | // `event` was never "message" (or blank), only ever `counter` 189 | expect(onMessage.callCount).toBe(0) 190 | await deferClose(es) 191 | }) 192 | 193 | test('`es.onmessage` does not fire for non-`message` events', async () => { 194 | const onMessage = getCallCounter({name: 'onMessage'}) 195 | const onOpen = getCallCounter({name: 'onOpen'}) 196 | const es = new OurEventSource(`${baseUrl}:${port}/counter`, {fetch}) 197 | es.addEventListener('open', onOpen) 198 | es.onmessage = onMessage 199 | 200 | await onOpen.waitForCallCount(3) 201 | es.onmessage = null 202 | 203 | // `event` was never "message" (or blank), only ever `counter` 204 | expect(onMessage.callCount).toBe(0) 205 | await deferClose(es) 206 | }) 207 | 208 | test('can redeclare `es.onopen` after initial assignment', async () => { 209 | const onError = getCallCounter({name: 'onError'}) 210 | const onOpen = getCallCounter({name: 'onOpen'}) 211 | const onOpenNew = getCallCounter({name: 'onOpen (new)'}) 212 | const es = new OurEventSource(`${baseUrl}:${port}/counter`, {fetch}) 213 | es.addEventListener('error', onError) 214 | es.onopen = onOpen 215 | 216 | await onOpen.waitForCallCount(2) 217 | es.onopen = onOpenNew 218 | 219 | await onError.waitForCallCount(4) // 4 disconnects 220 | 221 | // If `es.onopen = ` did not work, this should be 4 222 | expect(onOpen.callCount).toBe(2) 223 | expect(onOpenNew.callCount).toBe(2) 224 | await deferClose(es) 225 | }) 226 | 227 | test('can redeclare `es.onerror` after initial assignment', async () => { 228 | const onError = getCallCounter({name: 'onError'}) 229 | const onErrorNew = getCallCounter({name: 'onError (new)'}) 230 | const onOpen = getCallCounter({name: 'onOpen'}) 231 | const es = new OurEventSource(`${baseUrl}:${port}/counter`, {fetch}) 232 | es.addEventListener('open', onOpen) 233 | es.onerror = onError 234 | 235 | await onOpen.waitForCallCount(3) 236 | es.onerror = onErrorNew 237 | 238 | await onOpen.waitForCallCount(4) // 4 connects 239 | 240 | // If `es.onerror = ` did not work, this should be 4 241 | expect(onError.callCount).toBe(2) 242 | expect(onErrorNew.callCount).toBe(1) 243 | await deferClose(es) 244 | }) 245 | 246 | test('can redeclare `es.onmessage` after initial assignment', async () => { 247 | const onMessage = getCallCounter({name: 'onMessage'}) 248 | const onMessageNew = getCallCounter({name: 'onMessage (new)'}) 249 | const onError = getCallCounter({name: 'onError'}) 250 | const es = new OurEventSource(`${baseUrl}:${port}/counter?event=message`, {fetch}) 251 | es.addEventListener('error', onError) 252 | es.onmessage = onMessage 253 | 254 | await onError.waitForCallCount(2) 255 | es.onmessage = onMessageNew 256 | 257 | await onError.waitForCallCount(3) // 3 disconnects 258 | 259 | // If `es.onmessage = ` did not work, this should be 9, 260 | // since each connect emits 3 message then closes 261 | expect(onMessage.callCount).toBe(6) 262 | expect(onMessageNew.callCount).toBe(3) 263 | await deferClose(es) 264 | }) 265 | 266 | test('message event contains correct properties', async () => { 267 | const onMessage = getCallCounter({name: 'onMessage'}) 268 | const es = new OurEventSource(`${baseUrl}:${port}/counter`, {fetch}) 269 | 270 | es.addEventListener('counter', onMessage) 271 | await onMessage.waitForCallCount(1) 272 | 273 | expect(onMessage.lastCall.lastArg).toMatchObject({ 274 | data: 'Counter is at 1', 275 | type: 'counter', 276 | lastEventId: '1', 277 | origin: `${baseUrl}:${port}`, 278 | defaultPrevented: false, 279 | cancelable: false, 280 | timeStamp: expect.any('number'), 281 | }) 282 | await deferClose(es) 283 | }) 284 | 285 | test('will reconnect with last received message id if server disconnects', async () => { 286 | const onMessage = getCallCounter({name: 'onMessage'}) 287 | const onError = getCallCounter({name: 'onError'}) 288 | const url = `${baseUrl}:${port}/counter` 289 | const es = new OurEventSource(url, {fetch}) 290 | es.addEventListener('counter', onMessage) 291 | es.addEventListener('error', onError) 292 | 293 | // While still receiving messages (we receive 3 at a time before it disconnects) 294 | await onMessage.waitForCallCount(1) 295 | expect(es.readyState, 'readyState').toBe(OurEventSource.OPEN) // Open (connected) 296 | 297 | // While waiting for reconnect (after 3 messages it will disconnect and reconnect) 298 | await onError.waitForCallCount(1) 299 | expect(es.readyState, 'readyState').toBe(OurEventSource.CONNECTING) // Connecting (reconnecting) 300 | expect(onMessage.callCount).toBe(3) 301 | 302 | // Will reconnect infinitely, stop at 8 messages 303 | await onMessage.waitForCallCount(8) 304 | 305 | expect(es.url).toBe(url) 306 | expect(onMessage.lastCall.lastArg).toMatchObject({ 307 | data: 'Counter is at 8', 308 | type: 'counter', 309 | lastEventId: '8', 310 | origin: `${baseUrl}:${port}`, 311 | }) 312 | expect(onMessage.callCount).toBe(8) 313 | 314 | await deferClose(es) 315 | }) 316 | 317 | test('will not reconnect after explicit `close()`', async () => { 318 | const request = fetch || globalThis.fetch 319 | const onMessage = getCallCounter({name: 'onMessage'}) 320 | const onError = getCallCounter({name: 'onError'}) 321 | const clientId = Math.random().toString(36).slice(2) 322 | const url = `${baseUrl}:${port}/identified?client-id=${clientId}` 323 | const es = new OurEventSource(url, {fetch}) 324 | 325 | es.addEventListener('message', onMessage) 326 | es.addEventListener('error', onError) 327 | 328 | // Should receive a message containing the number of listeners on the given ID 329 | await onMessage.waitForCallCount(1) 330 | expect(onMessage.lastCall.lastArg).toMatchObject({data: '1'}) 331 | expect(es.readyState, 'readyState').toBe(OurEventSource.OPEN) // Open (connected) 332 | 333 | // Explicitly disconnect. Should normally reconnect within ~250ms (server sends retry: 250) 334 | // but we'll close it before that happens 335 | es.close() 336 | expect(es.readyState, 'readyState').toBe(OurEventSource.CLOSED) 337 | expect(onMessage.callCount).toBe(1) 338 | 339 | // After 500 ms, there should still only be a single connect with this client ID 340 | await new Promise((resolve) => setTimeout(resolve, 500)) 341 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 342 | 343 | // Wait another 500 ms, just to be sure there are no slow reconnects 344 | await new Promise((resolve) => setTimeout(resolve, 500)) 345 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 346 | }) 347 | 348 | test('will not reconnect after explicit `close()` in `onError`', async () => { 349 | const request = fetch || globalThis.fetch 350 | const onMessage = getCallCounter({name: 'onMessage'}) 351 | const onError = getCallCounter({name: 'onError', onCall: () => es.close()}) 352 | const clientId = Math.random().toString(36).slice(2) 353 | const url = `${baseUrl}:${port}/identified?client-id=${clientId}&auto-close=true` 354 | const es = new OurEventSource(url, {fetch}) 355 | es.addEventListener('open', () => expect(es.readyState).toBe(OurEventSource.OPEN)) 356 | es.addEventListener('message', onMessage) 357 | es.addEventListener('error', onError) 358 | 359 | // Should receive a message containing the number of listeners on the given ID 360 | await onMessage.waitForCallCount(1) 361 | expect(onMessage.lastCall.lastArg, 'onMessage `event` argument').toMatchObject({data: '1'}) 362 | 363 | await onError.waitForCallCount(1) 364 | expect(es.readyState, 'readyState').toBe(OurEventSource.CLOSED) // `onDisconnect` called first, closes ES. 365 | 366 | // After 50 ms, we should still be in closing state - no reconnecting 367 | expect(es.readyState, 'readyState').toBe(OurEventSource.CLOSED) 368 | 369 | // After 500 ms, there should be no clients connected to the given ID 370 | await new Promise((resolve) => setTimeout(resolve, 500)) 371 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 372 | expect(es.readyState, 'readyState').toBe(OurEventSource.CLOSED) 373 | 374 | // Wait another 500 ms, just to be sure there are no slow reconnects 375 | await new Promise((resolve) => setTimeout(resolve, 500)) 376 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 377 | expect(es.readyState, 'readyState').toBe(OurEventSource.CLOSED) 378 | }) 379 | 380 | test('will have correct ready state throughout lifecycle', async () => { 381 | const onMessage = getCallCounter({name: 'onMessage'}) 382 | const onOpen = getCallCounter({name: 'onOpen'}) 383 | const onError = getCallCounter({name: 'onError'}) 384 | const url = `${baseUrl}:${port}/slow-connect` 385 | const es = new OurEventSource(url, {fetch}) 386 | 387 | es.addEventListener('message', onMessage) 388 | es.addEventListener('open', onOpen) 389 | es.addEventListener('error', onError) 390 | 391 | // Connecting 392 | expect(es.readyState, 'readyState').toBe(OurEventSource.CONNECTING) 393 | 394 | // Connected 395 | await onOpen.waitForCallCount(1) 396 | expect(es.readyState, 'readyState').toBe(OurEventSource.OPEN) 397 | 398 | // Disconnected 399 | await onError.waitForCallCount(1) 400 | expect(es.readyState, 'readyState').toBe(OurEventSource.CONNECTING) 401 | 402 | // Closed 403 | await es.close() 404 | expect(es.readyState, 'readyState').toBe(OurEventSource.CLOSED) 405 | }) 406 | 407 | test('will close stream on HTTP 204', async () => { 408 | const onMessage = getCallCounter({name: 'onMessage'}) 409 | const onError = getCallCounter({name: 'onError'}) 410 | const es = new OurEventSource(`${baseUrl}:${port}/end-after-one`, {fetch}) 411 | 412 | es.addEventListener('progress', onMessage) 413 | es.addEventListener('error', onError) 414 | 415 | // First disconnect, then reconnect and given a 204 416 | await onError.waitForCallCount(2) 417 | 418 | // Only the first connect should have given a message 419 | await onMessage.waitForCallCount(1) 420 | 421 | expect(onMessage.callCount).toBe(1) 422 | expect(onMessage.lastCall.lastArg).toMatchObject({ 423 | data: '100%', 424 | type: 'progress', 425 | lastEventId: 'prct-100', 426 | }) 427 | expect(es.readyState, 'readyState').toBe(OurEventSource.CLOSED) // CLOSED 428 | 429 | await deferClose(es) 430 | }) 431 | 432 | /** 433 | * Note: Browser behavior varies in what they do on non-string/URL `url`: 434 | * - Chrome and Safari `toString()`s the value, which is obviously wrong according to spec: 435 | * > If urlRecord is failure, then throw a "SyntaxError" DOMException. 436 | * - Firefox throws a `DOMException` with message `An invalid or illegal string was specified` 437 | * (correct according to spec). 438 | * 439 | * We choose to go with the spec (eg mirrors Firefox behavior) if `DOMException` exists, 440 | * otherwise we throw a `SyntaxError`. 441 | */ 442 | test('throws if `url` is not a string/url', () => { 443 | const onMessage = getCallCounter({name: 'onMessage'}) 444 | try { 445 | // @ts-expect-error Should be a string or URL 446 | const es = new OurEventSource(123, {fetch}) 447 | es.addEventListener('message', onMessage) 448 | es.close() 449 | } catch (err) { 450 | expect(err instanceof DOMException).toBe(true) 451 | expect(err.message).toBe('An invalid or illegal string was specified') 452 | return 453 | } 454 | 455 | throw new Error('Expected invalid URL to throw') 456 | }) 457 | 458 | test('can request cross-origin', async () => { 459 | const hostUrl = new URL(`${baseUrl}:${port}/cors`) 460 | const url = new URL(hostUrl) 461 | url.hostname = url.hostname === 'localhost' ? '127.0.0.1' : 'localhost' 462 | 463 | const onMessage = getCallCounter({name: 'onMessage'}) 464 | const es = new OurEventSource(url, {fetch}) 465 | es.addEventListener('origin', onMessage) 466 | 467 | await onMessage.waitForCallCount(1) 468 | expect(onMessage.callCount).toBe(1) 469 | 470 | const lastMessage = onMessage.lastCall.lastArg 471 | expect(lastMessage).toMatchObject({type: 'origin'}) 472 | 473 | if (environment === 'browser') { 474 | expect(lastMessage).toMatchObject({data: hostUrl.origin}) 475 | } else { 476 | expect(lastMessage).toMatchObject({data: ''}) 477 | } 478 | 479 | await deferClose(es) 480 | }) 481 | 482 | // Same-origin redirect tests 483 | ;[301, 302, 307, 308].forEach((status) => { 484 | test(`redirects: handles ${status} to same origin`, async () => { 485 | const request = fetch || globalThis.fetch 486 | const id = Math.random().toString(36).slice(2) 487 | const onMessage = getCallCounter({name: 'onMessage'}) 488 | const onOpen = getCallCounter({name: 'onOpen'}) 489 | const origin = `${baseUrl}:${port}` 490 | const url = `${origin}/redirect?status=${status}&id=${id}` 491 | const es = new OurEventSource(url, { 492 | withCredentials: true, 493 | fetch(dstUrl, init) { 494 | return request(dstUrl, { 495 | ...init, 496 | headers: {...init?.headers, authorization: 'Bearer foo'}, 497 | }) 498 | }, 499 | }) 500 | es.addEventListener('message', onMessage) 501 | es.addEventListener('open', onOpen) 502 | 503 | await onMessage.waitForCallCount(1) 504 | 505 | // URL should be the original connected URL, even after redirect 506 | expect(es.url).toBe(url) 507 | 508 | const firstMessage = onMessage.lastCall.lastArg 509 | expect(firstMessage).toMatchObject({origin}) 510 | expect(JSON.parse(firstMessage.data)).toMatchObject({ 511 | origin, 512 | from: url, 513 | redirects: 1, 514 | auth: 'Bearer foo', 515 | }) 516 | 517 | // Reconnected and received another message 518 | await onOpen.waitForCallCount(2) 519 | await onMessage.waitForCallCount(2) 520 | 521 | const lastMessage = onMessage.lastCall.lastArg 522 | expect(lastMessage).toMatchObject({origin}) 523 | expect(JSON.parse(lastMessage.data)).toMatchObject({ 524 | origin, 525 | from: url, 526 | redirects: 2, 527 | auth: 'Bearer foo', 528 | }) 529 | 530 | // Deno/Bun seems to set timeStamp to `0` 🤷‍♂️ 531 | if (firstMessage.timeStamp > 0) { 532 | expect(firstMessage.timeStamp).notToBe(lastMessage.timeStamp) 533 | } 534 | 535 | await deferClose(es) 536 | }) 537 | }) 538 | 539 | // Cross-origin redirect tests 540 | ;[301, 302, 307, 308].forEach((status) => { 541 | test(`redirects: handles ${status} to different origin`, async () => { 542 | const request = fetch || globalThis.fetch 543 | const id = Math.random().toString(36).slice(2) 544 | const onMessage = getCallCounter({name: 'onMessage'}) 545 | const onOpen = getCallCounter({name: 'onOpen'}) 546 | const origin = `${baseUrl}:${port}` 547 | const corsOrigin = baseUrl.includes('localhost') 548 | ? `http://127.0.0.1:${port}` 549 | : `http://localhost:${port}` 550 | const url = `${origin}/redirect?status=${status}&id=${id}&cors=true` 551 | const es = new OurEventSource(url, { 552 | withCredentials: true, 553 | fetch(dstUrl, init) { 554 | return request(dstUrl, { 555 | ...init, 556 | headers: {...init?.headers, authorization: 'Bearer foo'}, 557 | }) 558 | }, 559 | }) 560 | es.addEventListener('message', onMessage) 561 | es.addEventListener('open', onOpen) 562 | 563 | await onMessage.waitForCallCount(1) 564 | 565 | // URL should be the original connected URL, even after redirect 566 | expect(es.url).toBe(url) 567 | 568 | const firstMessage = onMessage.lastCall.lastArg 569 | expect(firstMessage).toMatchObject({origin: corsOrigin}) 570 | expect(JSON.parse(firstMessage.data)).toMatchObject({ 571 | origin: corsOrigin, 572 | from: url, 573 | redirects: 1, 574 | auth: null, // Authorization header should not follow cross-origin redirects 575 | }) 576 | 577 | // Reconnected and received another message 578 | await onOpen.waitForCallCount(2) 579 | await onMessage.waitForCallCount(2) 580 | 581 | const lastMessage = onMessage.lastCall.lastArg 582 | expect(lastMessage).toMatchObject({origin: corsOrigin}) 583 | expect(JSON.parse(lastMessage.data)).toMatchObject({ 584 | origin: corsOrigin, 585 | from: url, 586 | redirects: 2, 587 | auth: null, // Authorization header should not follow cross-origin redirects 588 | }) 589 | 590 | // Deno/Bun seems to set timeStamp to `0` 🤷‍♂️ 591 | if (firstMessage.timeStamp > 0) { 592 | expect(firstMessage.timeStamp).notToBe(lastMessage.timeStamp) 593 | } 594 | 595 | await deferClose(es) 596 | }) 597 | }) 598 | 599 | browserTest( 600 | 'can use the `withCredentials` option to control cookies being sent/not sent cross-origin', 601 | async () => { 602 | const request = fetch || globalThis.fetch 603 | 604 | // `withCredentials` only applies to cross-origin requests (per spec) 605 | const corsOrigin = baseUrl.includes('localhost') 606 | ? `http://127.0.0.1:${port}` 607 | : `http://localhost:${port}` 608 | 609 | // With `withCredentials: true`, cookies should be sent 610 | let onOpen = getCallCounter({name: 'onOpen'}) 611 | let es = new OurEventSource(`${corsOrigin}/authed`, { 612 | withCredentials: true, 613 | fetch(url, init) { 614 | expect(init).toMatchObject({credentials: 'include'}) 615 | return request(url, init) 616 | }, 617 | }) 618 | 619 | es.addEventListener('open', onOpen) 620 | await onOpen.waitForCallCount(1) 621 | await deferClose(es) 622 | 623 | // With `withCredentials: false`, no cookies should be sent 624 | es = new OurEventSource(`${corsOrigin}/authed`, { 625 | withCredentials: false, 626 | fetch(url, init) { 627 | expect(init).toMatchObject({credentials: 'same-origin'}) 628 | return request(url, init) 629 | }, 630 | }) 631 | onOpen = getCallCounter({name: 'onOpen'}) 632 | es.addEventListener('open', onOpen) 633 | 634 | await onOpen.waitForCallCount(1) 635 | await deferClose(es) 636 | 637 | // With `withCredentials: undefined`, no cookies should be sent 638 | es = new OurEventSource(`${corsOrigin}/authed`, {fetch}) 639 | onOpen = getCallCounter({name: 'onOpen'}) 640 | es.addEventListener('open', onOpen) 641 | 642 | await onOpen.waitForCallCount(1) 643 | await deferClose(es) 644 | }, 645 | ) 646 | 647 | test('throws on `fetch()` that does not return web-stream', async () => { 648 | const url = `${baseUrl}:${port}/` 649 | 650 | // @ts-expect-error `body` should be a ReadableStream 651 | const faultyFetch: FetchLike = async () => ({ 652 | body: 'not a stream', 653 | redirected: false, 654 | status: 200, 655 | headers: new Headers({'content-type': 'text/event-stream'}), 656 | url, 657 | }) 658 | 659 | const onError = getCallCounter({name: 'onError'}) 660 | const es = new OurEventSource(url, {fetch: faultyFetch}) 661 | 662 | es.addEventListener('error', onError) 663 | await onError.waitForCallCount(1) 664 | 665 | expect(onError.lastCall.lastArg).toMatchObject({ 666 | type: 'error', 667 | defaultPrevented: false, 668 | cancelable: false, 669 | timeStamp: expect.any('number'), 670 | message: 'Invalid response body, expected a web ReadableStream', 671 | code: 200, 672 | }) 673 | await deferClose(es) 674 | }) 675 | 676 | test('throws on `fetch()` that does not return a body', async () => { 677 | const url = `${baseUrl}:${port}/` 678 | 679 | // @ts-expect-error `body` should be a ReadableStream 680 | const faultyFetch: FetchLike = async () => ({ 681 | redirected: false, 682 | status: 200, 683 | headers: new Headers({'content-type': 'text/event-stream'}), 684 | url, 685 | }) 686 | 687 | const onError = getCallCounter({name: 'onError'}) 688 | const es = new OurEventSource(url, {fetch: faultyFetch}) 689 | 690 | es.addEventListener('error', onError) 691 | await onError.waitForCallCount(1) 692 | 693 | expect(onError.lastCall.lastArg).toMatchObject({ 694 | type: 'error', 695 | defaultPrevented: false, 696 | cancelable: false, 697 | timeStamp: expect.any('number'), 698 | message: 'Invalid response body, expected a web ReadableStream', 699 | code: 200, 700 | }) 701 | await deferClose(es) 702 | }) 703 | 704 | test('[NON-SPEC] message event contains extended properties (failed connection)', async () => { 705 | const onError = getCallCounter({name: 'onError'}) 706 | const es = new OurEventSource(`${baseUrl}:9999/should-not-connect`, {fetch}) 707 | 708 | es.addEventListener('error', onError) 709 | await onError.waitForCallCount(1) 710 | 711 | expect(onError.lastCall.lastArg).toMatchObject({ 712 | type: 'error', 713 | defaultPrevented: false, 714 | cancelable: false, 715 | timeStamp: expect.any('number'), 716 | // Node, Deno, Bun, Chromium, Webkit, Firefox _ALL_ have different messages 😅 717 | message: expect.stringMatching( 718 | /fetch failed|failed to fetch|load failed|attempting to fetch|connection refused|unable to connect/i, 719 | ), 720 | code: undefined, 721 | }) 722 | await deferClose(es) 723 | }) 724 | 725 | test('[NON-SPEC] message event contains extended properties (invalid http response)', async () => { 726 | const onError = getCallCounter({name: 'onError'}) 727 | const es = new OurEventSource(`${baseUrl}:${port}/end-after-one`, {fetch}) 728 | 729 | es.addEventListener('error', onError) 730 | await onError.waitForCallCount(2) 731 | 732 | expect(onError.lastCall.lastArg).toMatchObject({ 733 | type: 'error', 734 | defaultPrevented: false, 735 | cancelable: false, 736 | timeStamp: expect.any('number'), 737 | message: 'Server sent HTTP 204, not reconnecting', 738 | code: 204, 739 | }) 740 | await deferClose(es) 741 | }) 742 | 743 | test('has CONNECTING constant', async () => { 744 | const es = new OurEventSource(`${baseUrl}:${port}/`) 745 | expect(es.readyState).toBe(OurEventSource.CONNECTING) 746 | expect(es.CONNECTING).toBe(0) 747 | expect(OurEventSource.CONNECTING).toBe(0) 748 | await deferClose(es) 749 | }) 750 | 751 | test('has OPEN constant', async () => { 752 | const onOpen = getCallCounter({name: 'onOpen'}) 753 | const es = new OurEventSource(`${baseUrl}:${port}/`) 754 | es.onopen = onOpen 755 | await onOpen.waitForCallCount(1) 756 | expect(es.readyState).toBe(OurEventSource.OPEN) 757 | expect(es.OPEN).toBe(1) 758 | expect(OurEventSource.OPEN).toBe(1) 759 | await deferClose(es) 760 | }) 761 | 762 | test('has CLOSED constant', async () => { 763 | const es = new OurEventSource(`${baseUrl}:${port}/`) 764 | es.close() 765 | expect(es.readyState).toBe(OurEventSource.CLOSED) 766 | expect(es.CLOSED).toBe(2) 767 | expect(OurEventSource.CLOSED).toBe(2) 768 | await deferClose(es) 769 | }) 770 | 771 | return runner 772 | } 773 | -------------------------------------------------------------------------------- /test/type-compatible.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ensures that our EventSource polyfill is as type-compatible as possible with the 3 | * WhatWG EventSource implementation/types (defined in TypeScript's `lib.dom.d.ts`). 4 | */ 5 | import {EventSource as EventSourcePolyfill} from '../src/EventSource' 6 | 7 | function testESImpl(EvtSource: typeof globalThis.EventSource | typeof EventSourcePolyfill) { 8 | const es = new EvtSource('https://foo.bar', { 9 | withCredentials: true, 10 | }) satisfies globalThis.EventSource 11 | 12 | /* eslint-disable no-console */ 13 | 14 | // Message 15 | es.onmessage = function (evt) { 16 | console.log(typeof evt.data === 'string') 17 | console.log(evt.defaultPrevented === false) 18 | console.log(evt.type === 'message') 19 | console.log(this === es) 20 | } 21 | 22 | function onMessage(evt: MessageEvent) { 23 | console.log(typeof evt.data === 'string') 24 | console.log(evt.defaultPrevented === false) 25 | console.log(evt.type === 'message') 26 | console.log(this === es) 27 | } 28 | 29 | es.addEventListener('message', onMessage) 30 | es.removeEventListener('message', onMessage) 31 | 32 | // Error 33 | es.onerror = function (event) { 34 | console.log(event.defaultPrevented === false) 35 | console.log(event.type === 'error') 36 | console.log(this === es) 37 | } 38 | 39 | function onError(event: Event) { 40 | console.log(event.defaultPrevented === false) 41 | console.log(event.type === 'error') 42 | console.log(this === es) 43 | } 44 | 45 | es.addEventListener('error', onError) 46 | es.removeEventListener('error', onError) 47 | 48 | // Open 49 | es.onopen = function (event) { 50 | console.log(event.defaultPrevented === false) 51 | console.log(event.type === 'open') 52 | console.log(this === es) 53 | } 54 | 55 | function onOpen(event: Event) { 56 | console.log(event.defaultPrevented === false) 57 | console.log(event.type === 'open') 58 | console.log(this === es) 59 | } 60 | 61 | es.addEventListener('open', onOpen) 62 | es.removeEventListener('open', onOpen) 63 | 64 | // Properties 65 | console.log(es.readyState === 0 || es.readyState === 1 || es.readyState === 2) 66 | console.log(es.url === 'https://foo.bar') 67 | console.log(es.withCredentials === true) 68 | 69 | console.log(es.CLOSED === 2) 70 | console.log(es.OPEN === 1) 71 | console.log(es.CONNECTING === 0) 72 | 73 | // Methods 74 | es.close() 75 | } 76 | 77 | testESImpl(EventSourcePolyfill) 78 | testESImpl(globalThis.EventSource) 79 | -------------------------------------------------------------------------------- /test/waffletest/index.ts: -------------------------------------------------------------------------------- 1 | export * from './runner.js' 2 | export * from './types.js' 3 | -------------------------------------------------------------------------------- /test/waffletest/reporters/defaultReporter.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env, no-console */ 2 | import type { 3 | TestEndEvent, 4 | TestFailEvent, 5 | TestPassEvent, 6 | TestReporter, 7 | TestStartEvent, 8 | } from '../types.js' 9 | import {getEndText, getFailText, getPassText, getStartText} from './helpers.js' 10 | 11 | export const defaultReporter: Required> = { 12 | onStart: reportStart, 13 | onEnd: reportEnd, 14 | onPass: reportPass, 15 | onFail: reportFail, 16 | } 17 | 18 | export function reportStart(event: TestStartEvent): void { 19 | console.log(getStartText(event)) 20 | } 21 | 22 | export function reportPass(event: TestPassEvent): void { 23 | console.log(getPassText(event)) 24 | } 25 | 26 | export function reportFail(event: TestFailEvent): void { 27 | console.log(getFailText(event)) 28 | } 29 | 30 | export function reportEnd(event: TestEndEvent): void { 31 | console.log(getEndText(event)) 32 | } 33 | -------------------------------------------------------------------------------- /test/waffletest/reporters/helpers.ts: -------------------------------------------------------------------------------- 1 | import type {TestEndEvent, TestFailEvent, TestPassEvent, TestStartEvent} from '../types.js' 2 | 3 | export function indent(str: string, spaces: number): string { 4 | return str 5 | .split('\n') 6 | .map((line) => ' '.repeat(spaces) + line) 7 | .join('\n') 8 | } 9 | 10 | export function getStartText(event: TestStartEvent): string { 11 | return `Running ${event.tests} tests…` 12 | } 13 | 14 | export function getPassText(event: TestPassEvent): string { 15 | return `✅ ${event.title} (${event.duration}ms)` 16 | } 17 | 18 | export function getFailText(event: TestFailEvent): string { 19 | return `❌ ${event.title} (${event.duration}ms)\n${indent(event.error, 3)}` 20 | } 21 | 22 | export function getEndText(event: TestEndEvent): string { 23 | const {failures, passes, tests} = event 24 | return `Ran ${tests} tests, ${passes} passed, ${failures} failed` 25 | } 26 | -------------------------------------------------------------------------------- /test/waffletest/reporters/nodeReporter.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env, no-console */ 2 | import {platform} from 'node:os' 3 | import {isatty} from 'node:tty' 4 | 5 | import type { 6 | TestEndEvent, 7 | TestFailEvent, 8 | TestPassEvent, 9 | TestReporter, 10 | TestStartEvent, 11 | } from '../types.js' 12 | import {getEndText, getFailText, getPassText, getStartText} from './helpers.js' 13 | 14 | const CAN_USE_COLORS = canUseColors() 15 | 16 | export const nodeReporter: Required> = { 17 | onStart: reportStart, 18 | onEnd: reportEnd, 19 | onPass: reportPass, 20 | onFail: reportFail, 21 | } 22 | 23 | export function reportStart(event: TestStartEvent): void { 24 | console.log(`${getStartText(event)}\n`) 25 | } 26 | 27 | export function reportPass(event: TestPassEvent): void { 28 | console.log(green(getPassText(event))) 29 | } 30 | 31 | export function reportFail(event: TestFailEvent): void { 32 | console.log(red(getFailText(event))) 33 | } 34 | 35 | export function reportEnd(event: TestEndEvent): void { 36 | console.log(`\n${getEndText(event)}`) 37 | } 38 | 39 | function red(str: string): string { 40 | return CAN_USE_COLORS ? `\x1b[31m${str}\x1b[39m` : str 41 | } 42 | 43 | function green(str: string): string { 44 | return CAN_USE_COLORS ? `\x1b[32m${str}\x1b[39m` : str 45 | } 46 | 47 | function getEnv(envVar: string): string | undefined { 48 | if (typeof process !== 'undefined' && 'env' in process && typeof process.env === 'object') { 49 | return process.env[envVar] 50 | } 51 | 52 | if (typeof globalThis.Deno !== 'undefined') { 53 | return globalThis.Deno.env.get(envVar) 54 | } 55 | 56 | throw new Error('Unable to find environment variables') 57 | } 58 | 59 | function hasEnv(envVar: string): boolean { 60 | return typeof getEnv(envVar) !== 'undefined' 61 | } 62 | 63 | function canUseColors(): boolean { 64 | const isWindows = platform() === 'win32' 65 | const isDumbTerminal = getEnv('TERM') === 'dumb' 66 | const isCompatibleTerminal = isatty(1) && getEnv('TERM') && !isDumbTerminal 67 | const isCI = 68 | hasEnv('CI') && (hasEnv('GITHUB_ACTIONS') || hasEnv('GITLAB_CI') || hasEnv('CIRCLECI')) 69 | return (isWindows && !isDumbTerminal) || isCompatibleTerminal || isCI 70 | } 71 | -------------------------------------------------------------------------------- /test/waffletest/runner.ts: -------------------------------------------------------------------------------- 1 | import {ExpectationError} from '../helpers.js' 2 | import type { 3 | TestEndEvent, 4 | TestFailEvent, 5 | TestFn, 6 | TestPassEvent, 7 | TestRunner, 8 | TestRunnerOptions, 9 | TestStartEvent, 10 | } from './types.js' 11 | 12 | interface TestDefinition { 13 | title: string 14 | timeout: number 15 | action: TestFn 16 | only?: boolean 17 | } 18 | 19 | const DEFAULT_TIMEOUT = 15000 20 | 21 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 22 | const noop = (_event: unknown) => { 23 | /* intentional noop */ 24 | } 25 | 26 | export function createRunner(options: TestRunnerOptions = {}): TestRunner { 27 | const {onEvent = noop, onStart = noop, onPass = noop, onFail = noop, onEnd = noop} = options 28 | const tests: TestDefinition[] = [] 29 | 30 | let hasOnlyTest = false 31 | let running = false 32 | let passes = 0 33 | let failures = 0 34 | let suiteStart = 0 35 | 36 | function registerTest(title: string, fn: TestFn, timeout?: number, only?: boolean): void { 37 | if (running) { 38 | throw new Error('Cannot register a test while tests are running') 39 | } 40 | 41 | if (only && !hasOnlyTest) { 42 | // Clear the current tests 43 | hasOnlyTest = true 44 | while (tests.length > 0) { 45 | tests.pop() 46 | } 47 | } 48 | 49 | if (!hasOnlyTest || only) { 50 | tests.push({ 51 | title, 52 | timeout: timeout ?? DEFAULT_TIMEOUT, 53 | action: fn, 54 | only, 55 | }) 56 | } 57 | } 58 | 59 | registerTest.skip = (...args: unknown[]): void => noop(args) 60 | 61 | registerTest.only = (title: string, fn: TestFn, timeout?: number): void => { 62 | return registerTest(title, fn, timeout, true) 63 | } 64 | 65 | async function runTests(): Promise { 66 | running = true 67 | suiteStart = Date.now() 68 | 69 | const start: TestStartEvent = { 70 | event: 'start', 71 | tests: tests.length, 72 | } 73 | 74 | onStart(start) 75 | onEvent(start) 76 | 77 | for (const test of tests) { 78 | const startTime = Date.now() 79 | try { 80 | await Promise.race([test.action(), getTimeoutPromise(test.timeout)]) 81 | passes++ 82 | const pass: TestPassEvent = { 83 | event: 'pass', 84 | duration: Date.now() - startTime, 85 | title: test.title, 86 | } 87 | onPass(pass) 88 | onEvent(pass) 89 | } catch (err: unknown) { 90 | failures++ 91 | 92 | let error: string 93 | if (err instanceof ExpectationError) { 94 | error = err.message 95 | if (typeof err.expected !== 'undefined' || typeof err.got !== 'undefined') { 96 | error += `\n\nExpected: ${inspect(err.expected)}\nGot: ${inspect(err.got)}` 97 | } 98 | } else if (err instanceof Error) { 99 | const stack = (err.stack || '').toString() 100 | error = stack.includes(err.message) ? stack : `${err.message}\n\n${stack}` 101 | } else { 102 | error = `${err}` 103 | } 104 | 105 | const fail: TestFailEvent = { 106 | event: 'fail', 107 | title: test.title, 108 | duration: Date.now() - startTime, 109 | error, 110 | } 111 | onFail(fail) 112 | onEvent(fail) 113 | } 114 | } 115 | 116 | const end: TestEndEvent = { 117 | event: 'end', 118 | success: failures === 0, 119 | failures, 120 | passes, 121 | tests: tests.length, 122 | duration: Date.now() - suiteStart, 123 | } 124 | onEnd(end) 125 | onEvent(end) 126 | 127 | running = false 128 | 129 | return end 130 | } 131 | 132 | function getTestCount() { 133 | return tests.length 134 | } 135 | 136 | function isRunning() { 137 | return running 138 | } 139 | 140 | return { 141 | isRunning, 142 | getTestCount, 143 | registerTest, 144 | runTests, 145 | } 146 | } 147 | 148 | function getTimeoutPromise(ms: number) { 149 | return new Promise((_resolve, reject) => { 150 | setTimeout(reject, ms, new Error(`Test timed out after ${ms} ms`)) 151 | }) 152 | } 153 | 154 | function inspect(thing: unknown) { 155 | return JSON.stringify(thing, null, 2) 156 | } 157 | -------------------------------------------------------------------------------- /test/waffletest/types.ts: -------------------------------------------------------------------------------- 1 | export type TestFn = () => void | Promise 2 | 3 | export interface TestReporter { 4 | onEvent?: (event: TestEvent) => void 5 | onStart?: (event: TestStartEvent) => void 6 | onPass?: (event: TestPassEvent) => void 7 | onFail?: (event: TestFailEvent) => void 8 | onEnd?: (event: TestEndEvent) => void 9 | } 10 | 11 | // Equal for now, but might extend 12 | export type TestRunnerOptions = TestReporter 13 | 14 | export type RegisterTest = (( 15 | title: string, 16 | fn: TestFn, 17 | timeout?: number, 18 | only?: boolean, 19 | ) => void) & { 20 | skip: (...args: unknown[]) => void 21 | only: (title: string, fn: TestFn, timeout?: number) => void 22 | } 23 | 24 | export interface TestRunner { 25 | isRunning(): boolean 26 | getTestCount(): number 27 | registerTest: RegisterTest 28 | runTests: () => Promise 29 | } 30 | 31 | export type TestEvent = TestStartEvent | TestPassEvent | TestFailEvent | TestEndEvent 32 | 33 | export interface TestStartEvent { 34 | event: 'start' 35 | tests: number 36 | } 37 | 38 | export interface TestPassEvent { 39 | event: 'pass' 40 | title: string 41 | duration: number 42 | } 43 | 44 | export interface TestFailEvent { 45 | event: 'fail' 46 | title: string 47 | duration: number 48 | error: string 49 | } 50 | 51 | export interface TestEndEvent { 52 | event: 'end' 53 | success: boolean 54 | tests: number 55 | passes: number 56 | failures: number 57 | duration: number 58 | } 59 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": ["**/__tests__/**"], 5 | "compilerOptions": { 6 | "outDir": "./dist/types", 7 | "rootDir": "." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": ["**/__tests__/**"], 5 | "compilerOptions": { 6 | "noEmit": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "lib": ["ES2020"], 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "erasableSyntaxOnly": true, 10 | 11 | // Module resolution 12 | "moduleResolution": "bundler", 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true 15 | } 16 | } 17 | --------------------------------------------------------------------------------