├── .editorconfig ├── .eslintignore ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.config.ts ├── package.json ├── src ├── abstractions.ts ├── client.ts ├── constants.ts ├── default.ts ├── node.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 └── 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/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 | ${{ 7 | inputs.release && 'Release ➤ Publish to NPM' || 8 | '' 9 | }} 10 | on: 11 | pull_request: 12 | push: 13 | branches: [main] 14 | workflow_dispatch: 15 | inputs: 16 | release: 17 | description: 'Publish new release' 18 | required: true 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 | release: 28 | # only run if opt-in during workflow_dispatch 29 | name: 'Release: Publish to NPM' 30 | if: always() && github.event.inputs.release == '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 | # Branches that will release new versions are defined in .releaserc.json 44 | - run: npx semantic-release 45 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 46 | # e.g. git tags were pushed but it exited before `npm publish` 47 | if: always() 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 51 | -------------------------------------------------------------------------------- /.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@v1 74 | with: 75 | deno-version: v1.x 76 | - name: Run tests 77 | run: npm run test:deno 78 | 79 | testBun: 80 | name: 'Test: Bun' 81 | timeout-minutes: 15 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | - uses: oven-sh/setup-bun@v1 86 | with: 87 | bun-version: latest 88 | - name: Install Dependencies 89 | run: bun install --frozen-lockfile 90 | - name: Run tests 91 | run: npm run test:bun 92 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | ## [1.1.3](https://github.com/rexxars/eventsource-client/compare/v1.1.2...v1.1.3) (2024-10-19) 9 | 10 | ### Bug Fixes 11 | 12 | - upgrade to eventsource-parser v3 ([#5](https://github.com/rexxars/eventsource-client/issues/5)) ([08087f7](https://github.com/rexxars/eventsource-client/commit/08087f79d0e12523a8434ff9da5533dd1d6b75bf)) 13 | 14 | ## [1.1.2](https://github.com/rexxars/eventsource-client/compare/v1.1.1...v1.1.2) (2024-08-05) 15 | 16 | ### Bug Fixes 17 | 18 | - allow `close()` in `onDisconnect` to cancel reconnect ([efed962](https://github.com/rexxars/eventsource-client/commit/efed962a561be438ec71c3a33735377d8b8372b8)), closes [#3](https://github.com/rexxars/eventsource-client/issues/3) 19 | 20 | ## [1.1.1](https://github.com/rexxars/eventsource-client/compare/v1.1.0...v1.1.1) (2024-05-06) 21 | 22 | ### Bug Fixes 23 | 24 | - stray reconnect after close ([3b13da7](https://github.com/rexxars/eventsource-client/commit/3b13da756d4a82b34b3e36651025989db3cf5ae8)), closes [#2](https://github.com/rexxars/eventsource-client/issues/2) 25 | 26 | ## [1.1.0](https://github.com/rexxars/eventsource-client/compare/v1.0.0...v1.1.0) (2024-04-29) 27 | 28 | ### Features 29 | 30 | - allow specifying only URL instead of options object ([d9b0614](https://github.com/rexxars/eventsource-client/commit/d9b061443b983fc0c38c67adce5718d095fa2a39)) 31 | - support environments without TextDecoderStream support ([e97538f](https://github.com/rexxars/eventsource-client/commit/e97538f57a78867910d7d943ced49902c8e80f62)) 32 | - warn when attempting to iterate syncronously ([c639b09](https://github.com/rexxars/eventsource-client/commit/c639b0962c9b0e71a0534f8ba8278e06c347afc7)) 33 | 34 | ### Bug Fixes 35 | 36 | - specify preferred builds for deno and bun ([b59f3f5](https://github.com/rexxars/eventsource-client/commit/b59f3f50059152c791f597cae8639d1b8f75e2be)) 37 | - upgrade dependencies, sort imports ([8e0c7a1](https://github.com/rexxars/eventsource-client/commit/8e0c7a10f70b361a8550c94024e152f1485348db)) 38 | 39 | ## 1.0.0 (2023-11-14) 40 | 41 | ### ⚠ BREAKING CHANGES 42 | 43 | - require node 18 or higher 44 | 45 | ### Features 46 | 47 | - `onScheduleReconnect` event ([c2ad6fc](https://github.com/rexxars/eventsource-client/commit/c2ad6fcfbb8975790a1717990a5561bf3e2f9032)) 48 | - close connection when receiving http 204 ([5015171](https://github.com/rexxars/eventsource-client/commit/5015171116026d83300b3a814541c4e52833af4c)) 49 | - drop unnecessary environment abstractions ([f7d4fe5](https://github.com/rexxars/eventsource-client/commit/f7d4fe5532d37d9d6893aa193eb60082d86c44c3)) 50 | - initial commit ([e85503a](https://github.com/rexxars/eventsource-client/commit/e85503a56d499ddc4a3a34f12723a88b3a4045df)) 51 | - require node 18 or higher ([0186b45](https://github.com/rexxars/eventsource-client/commit/0186b458e8dc0969cb42243c4adfc61b1851b3b8)) 52 | - support AsyncIterator pattern ([264f9c3](https://github.com/rexxars/eventsource-client/commit/264f9c335fbdc07135ec6d85923ba3a2bd2d5705)) 53 | - trigger `onConnect()` ([d2293d7](https://github.com/rexxars/eventsource-client/commit/d2293d73538de55ee3cddebbd8740837832dd3ec)) 54 | 55 | ### Bug Fixes 56 | 57 | - esm/commonjs/web build ([9782a97](https://github.com/rexxars/eventsource-client/commit/9782a978c4b22f72d656f63479552e78dbbf7c89)) 58 | - move response body check after 204 check ([c196c5c](https://github.com/rexxars/eventsource-client/commit/c196c5ce9cfc7a4ef9ddcb49078700d0e8350d54)) 59 | - reset parser on disconnect/reconnect ([1534e03](https://github.com/rexxars/eventsource-client/commit/1534e030d72f2cba642084d92dbbc2f6176da5dd)) 60 | - reset parser on start of stream ([f4c1487](https://github.com/rexxars/eventsource-client/commit/f4c148756bcf9b5de5f9a0d5f512f25b4baf1b86)) 61 | - schedule a reconnect on network failure ([c00e0ca](https://github.com/rexxars/eventsource-client/commit/c00e0cae028b7572bd4ddf96c5763bde588ba976)) 62 | - set readyState to OPEN when connected ([06d448d](https://github.com/rexxars/eventsource-client/commit/06d448d424224a573423b214222c707766d95a64)) 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Espen Hovlandsdal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eventsource-client 2 | 3 | [![npm version](https://img.shields.io/npm/v/eventsource-client.svg?style=flat-square)](http://npmjs.org/package/eventsource-client)[![npm bundle size](https://img.shields.io/bundlephobia/minzip/eventsource-client?style=flat-square)](https://bundlephobia.com/result?p=eventsource-client) 4 | 5 | A modern, streaming client for [server-sent events/eventsource](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). 6 | 7 | ## Another one? 8 | 9 | Yes! There are indeed lots of different EventSource clients and polyfills out there. In fact, I am a co-maintainer of [the most popular one](https://github.com/eventsource/eventsource). This one is different in a few ways, however: 10 | 11 | - Works in both Node.js and browsers with minimal amount of differences in code 12 | - Ships with both ESM and CommonJS versions 13 | - Uses modern APIs such as the [`fetch()` API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and [Web Streams](https://streams.spec.whatwg.org/) 14 | - Does **NOT** attempt to be API-compatible with the browser EventSource API: 15 | - Supports async iterator pattern 16 | - Supports any request method (POST, PATCH, DELETE etc) 17 | - Supports setting custom headers 18 | - Supports sending a request body 19 | - Supports configurable reconnection policies 20 | - Supports subscribing to any event (eg if event names are not known) 21 | - Supports subscribing to events named `error` 22 | - Supports setting initial last event ID 23 | 24 | ## Installation 25 | 26 | ```bash 27 | npm install --save eventsource-client 28 | ``` 29 | 30 | ## Supported engines 31 | 32 | - Node.js >= 18 33 | - Chrome >= 63 34 | - Safari >= 11.3 35 | - Firefox >= 65 36 | - Edge >= 79 37 | - Deno >= 1.30 38 | - Bun >= 1.1.23 39 | 40 | Basically, any environment that supports: 41 | 42 | - [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) 43 | - [TextDecoderStream](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoderStream) 44 | - [Symbol.asyncIterator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/asyncIterator) 45 | 46 | ## Usage (async iterator) 47 | 48 | ```ts 49 | import {createEventSource} from 'eventsource-client' 50 | 51 | const es = createEventSource({ 52 | url: 'https://my-server.com/sse', 53 | 54 | // your `fetch()` implementation of choice, or `globalThis.fetch` if not set 55 | fetch: myFetch, 56 | }) 57 | 58 | let seenMessages = 0 59 | for await (const {data, event, id} of es) { 60 | console.log('Data: %s', data) 61 | console.log('Event ID: %s', id) // Note: can be undefined 62 | console.log('Event: %s', event) // Note: can be undefined 63 | 64 | if (++seenMessages === 10) { 65 | break 66 | } 67 | } 68 | 69 | // IMPORTANT: EventSource is _not_ closed automatically when breaking out of 70 | // loop. You must manually call `close()` to close the connection. 71 | es.close() 72 | ``` 73 | 74 | ## Usage (`onMessage` callback) 75 | 76 | ```ts 77 | import {createEventSource} from 'eventsource-client' 78 | 79 | const es = createEventSource({ 80 | url: 'https://my-server.com/sse', 81 | 82 | onMessage: ({data, event, id}) => { 83 | console.log('Data: %s', data) 84 | console.log('Event ID: %s', id) // Note: can be undefined 85 | console.log('Event: %s', event) // Note: can be undefined 86 | }, 87 | 88 | // your `fetch()` implementation of choice, or `globalThis.fetch` if not set 89 | fetch: myFetch, 90 | }) 91 | 92 | console.log(es.readyState) // `open`, `closed` or `connecting` 93 | console.log(es.lastEventId) 94 | 95 | // Later, to terminate and prevent reconnections: 96 | es.close() 97 | ``` 98 | 99 | ## Minimal usage 100 | 101 | ```ts 102 | import {createEventSource} from 'eventsource-client' 103 | 104 | const es = createEventSource('https://my-server.com/sse') 105 | for await (const {data} of es) { 106 | console.log('Data: %s', data) 107 | } 108 | ``` 109 | 110 | ## Todo 111 | 112 | - [ ] Figure out what to do on broken connection on request body 113 | - [ ] Configurable stalled connection detection (eg no data) 114 | - [ ] Configurable reconnection policy 115 | - [ ] Consider legacy build 116 | 117 | ## License 118 | 119 | MIT © [Espen Hovlandsdal](https://espen.codes/) 120 | -------------------------------------------------------------------------------- /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 | extract: { 8 | rules: { 9 | 'ae-missing-release-tag': 'off', 10 | 'tsdoc-undefined-tag': 'off', 11 | }, 12 | }, 13 | 14 | legacyExports: true, 15 | 16 | bundles: [ 17 | { 18 | source: './src/default.ts', 19 | require: './dist/default.js', 20 | runtime: 'browser', 21 | }, 22 | { 23 | source: './src/node.ts', 24 | require: './dist/node.js', 25 | runtime: 'node', 26 | }, 27 | ], 28 | 29 | rollup: { 30 | plugins: [ 31 | visualizer({ 32 | emitFile: true, 33 | filename: 'stats.html', 34 | gzipSize: true, 35 | title: `${name}@${version} bundle analysis`, 36 | }), 37 | ], 38 | }, 39 | 40 | tsconfig: 'tsconfig.dist.json', 41 | }) 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eventsource-client", 3 | "version": "1.1.3", 4 | "description": "Modern EventSource client for browsers and Node.js", 5 | "sideEffects": false, 6 | "types": "./dist/default.d.ts", 7 | "source": "./src/default.ts", 8 | "module": "./dist/default.esm.js", 9 | "main": "./dist/default.js", 10 | "exports": { 11 | ".": { 12 | "types": "./dist/default.d.ts", 13 | "source": "./src/default.ts", 14 | "deno": "./dist/default.esm.js", 15 | "bun": "./dist/default.esm.js", 16 | "node": { 17 | "import": "./dist/node.cjs.mjs", 18 | "require": "./dist/node.js" 19 | }, 20 | "require": "./dist/default.js", 21 | "import": "./dist/default.esm.js", 22 | "default": "./dist/default.esm.js" 23 | }, 24 | "./package.json": "./package.json" 25 | }, 26 | "scripts": { 27 | "build": "pkg-utils build && pkg-utils --strict", 28 | "build:watch": "pkg-utils watch", 29 | "clean": "rimraf dist coverage", 30 | "lint": "eslint . && tsc --noEmit", 31 | "posttest": "npm run lint", 32 | "prebuild": "npm run clean", 33 | "prepublishOnly": "npm run build", 34 | "test": "npm run test:node && npm run test:browser", 35 | "test:browser": "tsx test/browser/client.browser.test.ts", 36 | "test:bun": "bun run test/bun/client.bun.test.ts", 37 | "test:deno": "deno run --allow-net --allow-read --allow-env --unstable-sloppy-imports test/deno/client.deno.test.ts", 38 | "test:node": "tsx test/node/client.node.test.ts" 39 | }, 40 | "files": [ 41 | "dist", 42 | "src" 43 | ], 44 | "repository": { 45 | "type": "git", 46 | "url": "git+ssh://git@github.com/rexxars/eventsource-client.git" 47 | }, 48 | "keywords": [ 49 | "sse", 50 | "eventsource", 51 | "server-sent-events" 52 | ], 53 | "author": "Espen Hovlandsdal ", 54 | "license": "MIT", 55 | "engines": { 56 | "node": ">=18.0.0" 57 | }, 58 | "browserslist": [ 59 | "node >= 18", 60 | "chrome >= 71", 61 | "safari >= 14.1", 62 | "firefox >= 105", 63 | "edge >= 79" 64 | ], 65 | "dependencies": { 66 | "eventsource-parser": "^3.0.0" 67 | }, 68 | "devDependencies": { 69 | "@sanity/pkg-utils": "^4.0.0", 70 | "@sanity/semantic-release-preset": "^4.1.7", 71 | "@types/express": "^4.17.21", 72 | "@types/node": "^18.0.0", 73 | "@types/sinon": "^17.0.3", 74 | "@typescript-eslint/eslint-plugin": "^6.11.0", 75 | "@typescript-eslint/parser": "^6.11.0", 76 | "esbuild": "^0.20.1", 77 | "eslint": "^8.57.0", 78 | "eslint-config-prettier": "^9.1.0", 79 | "eslint-config-sanity": "^7.1.1", 80 | "playwright": "^1.42.1", 81 | "prettier": "^3.2.5", 82 | "rimraf": "^5.0.5", 83 | "rollup-plugin-visualizer": "^5.12.0", 84 | "semantic-release": "^23.0.2", 85 | "sinon": "^17.0.1", 86 | "tsx": "^4.7.3", 87 | "typescript": "^5.4.2", 88 | "undici": "^6.7.1" 89 | }, 90 | "bugs": { 91 | "url": "https://github.com/rexxars/eventsource-client/issues" 92 | }, 93 | "homepage": "https://github.com/rexxars/eventsource-client#readme", 94 | "prettier": { 95 | "semi": false, 96 | "printWidth": 100, 97 | "bracketSpacing": false, 98 | "singleQuote": true 99 | }, 100 | "eslintConfig": { 101 | "parserOptions": { 102 | "ecmaVersion": 9, 103 | "sourceType": "module", 104 | "ecmaFeatures": { 105 | "modules": true 106 | } 107 | }, 108 | "extends": [ 109 | "sanity", 110 | "sanity/typescript", 111 | "prettier" 112 | ], 113 | "ignorePatterns": [ 114 | "lib/**/" 115 | ], 116 | "globals": { 117 | "globalThis": false 118 | }, 119 | "rules": { 120 | "no-undef": "off", 121 | "no-empty": "off" 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/abstractions.ts: -------------------------------------------------------------------------------- 1 | import type {EventSourceMessage} from './types.js' 2 | 3 | /** 4 | * Internal abstractions over environment-specific APIs, to keep node-specifics 5 | * out of browser bundles and vice versa. 6 | * 7 | * @internal 8 | */ 9 | export interface EnvAbstractions { 10 | getStream(body: NodeJS.ReadableStream | ReadableStream): ReadableStream 11 | } 12 | 13 | /** 14 | * Resolver function that emits an (async) event source message value. 15 | * Used internally by AsyncIterator implementation, not for external use. 16 | * 17 | * @internal 18 | */ 19 | export type EventSourceAsyncValueResolver = ( 20 | value: 21 | | IteratorResult 22 | | PromiseLike>, 23 | ) => void 24 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import {createParser} from 'eventsource-parser' 2 | 3 | import type {EnvAbstractions, EventSourceAsyncValueResolver} from './abstractions.js' 4 | import {CLOSED, CONNECTING, OPEN} from './constants.js' 5 | import type { 6 | EventSourceClient, 7 | EventSourceMessage, 8 | EventSourceOptions, 9 | FetchLike, 10 | FetchLikeInit, 11 | FetchLikeResponse, 12 | ReadyState, 13 | } from './types.js' 14 | 15 | /** 16 | * Intentional noop function for eased control flow 17 | */ 18 | const noop = () => { 19 | /* intentional noop */ 20 | } 21 | 22 | /** 23 | * Creates a new EventSource client. Used internally by the environment-specific entry points, 24 | * and should not be used directly by consumers. 25 | * 26 | * @param optionsOrUrl - Options for the client, or an URL/URL string. 27 | * @param abstractions - Abstractions for the environments. 28 | * @returns A new EventSource client instance 29 | * @internal 30 | */ 31 | export function createEventSource( 32 | optionsOrUrl: EventSourceOptions | string | URL, 33 | {getStream}: EnvAbstractions, 34 | ): EventSourceClient { 35 | const options = 36 | typeof optionsOrUrl === 'string' || optionsOrUrl instanceof URL 37 | ? {url: optionsOrUrl} 38 | : optionsOrUrl 39 | const {onMessage, onConnect = noop, onDisconnect = noop, onScheduleReconnect = noop} = options 40 | const {fetch, url, initialLastEventId} = validate(options) 41 | const requestHeaders = {...options.headers} // Prevent post-creation mutations to headers 42 | 43 | const onCloseSubscribers: (() => void)[] = [] 44 | const subscribers: ((event: EventSourceMessage) => void)[] = onMessage ? [onMessage] : [] 45 | const emit = (event: EventSourceMessage) => subscribers.forEach((fn) => fn(event)) 46 | const parser = createParser({onEvent, onRetry}) 47 | 48 | // Client state 49 | let request: Promise | null 50 | let currentUrl = url.toString() 51 | let controller = new AbortController() 52 | let lastEventId = initialLastEventId 53 | let reconnectMs = 2000 54 | let reconnectTimer: ReturnType | undefined 55 | let readyState: ReadyState = CLOSED 56 | 57 | // Let's go! 58 | connect() 59 | 60 | return { 61 | close, 62 | connect, 63 | [Symbol.iterator]: () => { 64 | throw new Error( 65 | 'EventSource does not support synchronous iteration. Use `for await` instead.', 66 | ) 67 | }, 68 | [Symbol.asyncIterator]: getEventIterator, 69 | get lastEventId() { 70 | return lastEventId 71 | }, 72 | get url() { 73 | return currentUrl 74 | }, 75 | get readyState() { 76 | return readyState 77 | }, 78 | } 79 | 80 | function connect() { 81 | if (request) { 82 | return 83 | } 84 | 85 | readyState = CONNECTING 86 | controller = new AbortController() 87 | request = fetch(url, getRequestOptions()) 88 | .then(onFetchResponse) 89 | .catch((err: Error & {type: string}) => { 90 | request = null 91 | 92 | // We expect abort errors when the user manually calls `close()` - ignore those 93 | if (err.name === 'AbortError' || err.type === 'aborted') { 94 | return 95 | } 96 | 97 | scheduleReconnect() 98 | }) 99 | } 100 | 101 | function close() { 102 | readyState = CLOSED 103 | controller.abort() 104 | parser.reset() 105 | clearTimeout(reconnectTimer) 106 | onCloseSubscribers.forEach((fn) => fn()) 107 | } 108 | 109 | function getEventIterator(): AsyncGenerator { 110 | const pullQueue: EventSourceAsyncValueResolver[] = [] 111 | const pushQueue: EventSourceMessage[] = [] 112 | 113 | function pullValue() { 114 | return new Promise>((resolve) => { 115 | const value = pushQueue.shift() 116 | if (value) { 117 | resolve({value, done: false}) 118 | } else { 119 | pullQueue.push(resolve) 120 | } 121 | }) 122 | } 123 | 124 | const pushValue = function (value: EventSourceMessage) { 125 | const resolve = pullQueue.shift() 126 | if (resolve) { 127 | resolve({value, done: false}) 128 | } else { 129 | pushQueue.push(value) 130 | } 131 | } 132 | 133 | function unsubscribe() { 134 | subscribers.splice(subscribers.indexOf(pushValue), 1) 135 | while (pullQueue.shift()) {} 136 | while (pushQueue.shift()) {} 137 | } 138 | 139 | function onClose() { 140 | const resolve = pullQueue.shift() 141 | if (!resolve) { 142 | return 143 | } 144 | 145 | resolve({done: true, value: undefined}) 146 | unsubscribe() 147 | } 148 | 149 | onCloseSubscribers.push(onClose) 150 | subscribers.push(pushValue) 151 | 152 | return { 153 | next() { 154 | return readyState === CLOSED ? this.return() : pullValue() 155 | }, 156 | return() { 157 | unsubscribe() 158 | return Promise.resolve({done: true, value: undefined}) 159 | }, 160 | throw(error) { 161 | unsubscribe() 162 | return Promise.reject(error) 163 | }, 164 | [Symbol.asyncIterator]() { 165 | return this 166 | }, 167 | } 168 | } 169 | 170 | function scheduleReconnect() { 171 | onScheduleReconnect({delay: reconnectMs}) 172 | readyState = CONNECTING 173 | reconnectTimer = setTimeout(connect, reconnectMs) 174 | } 175 | 176 | async function onFetchResponse(response: FetchLikeResponse) { 177 | onConnect() 178 | parser.reset() 179 | 180 | const {body, redirected, status} = response 181 | 182 | // HTTP 204 means "close the connection, no more data will be sent" 183 | if (status === 204) { 184 | onDisconnect() 185 | close() 186 | return 187 | } 188 | 189 | if (!body) { 190 | throw new Error('Missing response body') 191 | } 192 | 193 | if (redirected) { 194 | currentUrl = response.url 195 | } 196 | 197 | // Ensure that the response stream is a web stream 198 | // @todo Figure out a way to make this work without casting 199 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 200 | const stream = getStream(body as any) 201 | const decoder = new TextDecoder() 202 | 203 | const reader = stream.getReader() 204 | let open = true 205 | 206 | readyState = OPEN 207 | 208 | do { 209 | const {done, value} = await reader.read() 210 | if (value) { 211 | parser.feed(decoder.decode(value, {stream: !done})) 212 | } 213 | 214 | if (!done) { 215 | continue 216 | } 217 | 218 | open = false 219 | request = null 220 | parser.reset() 221 | 222 | // EventSources never close unless explicitly handled with `.close()`: 223 | // Implementors should send an `done`/`complete`/`disconnect` event and 224 | // explicitly handle it in client code, or send an HTTP 204. 225 | scheduleReconnect() 226 | 227 | // Calling scheduleReconnect() prior to onDisconnect() allows consumers to 228 | // explicitly call .close() before the reconnection is performed. 229 | onDisconnect() 230 | } while (open) 231 | } 232 | 233 | function onEvent(msg: EventSourceMessage) { 234 | if (typeof msg.id === 'string') { 235 | lastEventId = msg.id 236 | } 237 | 238 | emit(msg) 239 | } 240 | 241 | function onRetry(ms: number) { 242 | reconnectMs = ms 243 | } 244 | 245 | function getRequestOptions(): FetchLikeInit { 246 | // @todo allow interception of options, but don't allow overriding signal 247 | const {mode, credentials, body, method, redirect, referrer, referrerPolicy} = options 248 | const lastEvent = lastEventId ? {'Last-Event-ID': lastEventId} : undefined 249 | const headers = {Accept: 'text/event-stream', ...requestHeaders, ...lastEvent} 250 | return { 251 | mode, 252 | credentials, 253 | body, 254 | method, 255 | redirect, 256 | referrer, 257 | referrerPolicy, 258 | headers, 259 | cache: 'no-store', 260 | signal: controller.signal, 261 | } 262 | } 263 | } 264 | 265 | function validate(options: EventSourceOptions): { 266 | fetch: FetchLike 267 | url: string | URL 268 | initialLastEventId: string | undefined 269 | } { 270 | const fetch = options.fetch || globalThis.fetch 271 | if (!isFetchLike(fetch)) { 272 | throw new Error('No fetch implementation provided, and one was not found on the global object.') 273 | } 274 | 275 | if (typeof AbortController !== 'function') { 276 | throw new Error('Missing AbortController implementation') 277 | } 278 | 279 | const {url, initialLastEventId} = options 280 | 281 | if (typeof url !== 'string' && !(url instanceof URL)) { 282 | throw new Error('Invalid URL provided - must be string or URL instance') 283 | } 284 | 285 | if (typeof initialLastEventId !== 'string' && initialLastEventId !== undefined) { 286 | throw new Error('Invalid initialLastEventId provided - must be string or undefined') 287 | } 288 | 289 | return {fetch, url, initialLastEventId} 290 | } 291 | 292 | // This is obviously naive, but hard to probe for full compatibility 293 | function isFetchLike(fetch: FetchLike | typeof globalThis.fetch): fetch is FetchLike { 294 | return typeof fetch === 'function' 295 | } 296 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // ReadyStates, mirrors WhatWG spec, but uses strings instead of numbers. 2 | // Why make it harder to read than it needs to be? 3 | 4 | /** 5 | * ReadyState representing a connection that is connecting or has been scheduled to reconnect. 6 | * @public 7 | */ 8 | export const CONNECTING = 'connecting' 9 | 10 | /** 11 | * ReadyState representing a connection that is open, eg connected. 12 | * @public 13 | */ 14 | export const OPEN = 'open' 15 | 16 | /** 17 | * ReadyState representing a connection that has been closed (manually, or due to an error). 18 | * @public 19 | */ 20 | export const CLOSED = 'closed' 21 | -------------------------------------------------------------------------------- /src/default.ts: -------------------------------------------------------------------------------- 1 | import type {EnvAbstractions} from './abstractions.js' 2 | import {createEventSource as createSource} from './client.js' 3 | import type {EventSourceClient, EventSourceOptions} from './types.js' 4 | 5 | export * from './constants.js' 6 | export * from './types.js' 7 | 8 | /** 9 | * Default "abstractions", eg when all the APIs are globally available 10 | */ 11 | const defaultAbstractions: EnvAbstractions = { 12 | getStream, 13 | } 14 | 15 | /** 16 | * Creates a new EventSource client. 17 | * 18 | * @param optionsOrUrl - Options for the client, or an URL/URL string. 19 | * @returns A new EventSource client instance 20 | * @public 21 | */ 22 | export function createEventSource( 23 | optionsOrUrl: EventSourceOptions | URL | string, 24 | ): EventSourceClient { 25 | return createSource(optionsOrUrl, defaultAbstractions) 26 | } 27 | 28 | /** 29 | * Returns a ReadableStream (Web Stream) from either an existing ReadableStream. 30 | * Only defined because of environment abstractions - is actually a 1:1 (passthrough). 31 | * 32 | * @param body - The body to convert 33 | * @returns A ReadableStream 34 | * @private 35 | */ 36 | function getStream( 37 | body: NodeJS.ReadableStream | ReadableStream, 38 | ): ReadableStream { 39 | if (!(body instanceof ReadableStream)) { 40 | throw new Error('Invalid stream, expected a web ReadableStream') 41 | } 42 | 43 | return body 44 | } 45 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import {Readable} from 'node:stream' 2 | 3 | import type {EnvAbstractions} from './abstractions.js' 4 | import {createEventSource as createSource} from './client.js' 5 | import type {EventSourceClient, EventSourceOptions} from './types.js' 6 | 7 | export * from './constants.js' 8 | export * from './types.js' 9 | 10 | const nodeAbstractions: EnvAbstractions = { 11 | getStream, 12 | } 13 | 14 | /** 15 | * Creates a new EventSource client. 16 | * 17 | * @param options - Options for the client, or an URL/URL string. 18 | * @returns A new EventSource client instance 19 | * @public 20 | */ 21 | export function createEventSource( 22 | optionsOrUrl: EventSourceOptions | URL | string, 23 | ): EventSourceClient { 24 | return createSource(optionsOrUrl, nodeAbstractions) 25 | } 26 | 27 | /** 28 | * Returns a ReadableStream (Web Stream) from either an existing ReadableStream, 29 | * or a node.js Readable stream. Ensures that it works with more `fetch()` polyfills. 30 | * 31 | * @param body - The body to convert 32 | * @returns A ReadableStream 33 | * @private 34 | */ 35 | function getStream( 36 | body: NodeJS.ReadableStream | ReadableStream, 37 | ): ReadableStream { 38 | if ('getReader' in body) { 39 | // Already a web stream 40 | return body 41 | } 42 | 43 | if (typeof body.pipe !== 'function' || typeof body.on !== 'function') { 44 | throw new Error('Invalid response body, expected a web or node.js stream') 45 | } 46 | 47 | // Available as of Node 17, and module requires Node 18 48 | if (typeof Readable.toWeb !== 'function') { 49 | throw new Error('Node.js 18 or higher required (`Readable.toWeb()` not defined)') 50 | } 51 | 52 | // @todo Figure out if we can prevent casting 53 | return Readable.toWeb(Readable.from(body)) as ReadableStream 54 | } 55 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type {ReadableStream as NodeWebReadableStream} from 'node:stream/web' 2 | 3 | import type {EventSourceMessage} from 'eventsource-parser' 4 | 5 | /** 6 | * Ready state for a connection. 7 | * 8 | * @public 9 | */ 10 | export type ReadyState = 'open' | 'connecting' | 'closed' 11 | 12 | /** 13 | * EventSource client. 14 | * 15 | * @public 16 | */ 17 | export interface EventSourceClient { 18 | /** Close the connection and prevent the client from reconnecting automatically. */ 19 | close(): void 20 | 21 | /** Connect to the event source. Automatically called on creation - you only need to call this after manually calling `close()`, when server has sent an HTTP 204, or the server responded with a non-retryable error. */ 22 | connect(): void 23 | 24 | /** Warns when attempting to iterate synchronously */ 25 | [Symbol.iterator](): never 26 | 27 | /** Async iterator of messages received */ 28 | [Symbol.asyncIterator](): AsyncIterableIterator 29 | 30 | /** Last seen event ID, or the `initialLastEventId` if none has been received yet. */ 31 | readonly lastEventId: string | undefined 32 | 33 | /** Current URL. Usually the same as `url`, but in the case of allowed redirects, it will reflect the new URL. */ 34 | readonly url: string 35 | 36 | /** Ready state of the connection */ 37 | readonly readyState: ReadyState 38 | } 39 | 40 | /** 41 | * Options for the eventsource client. 42 | * 43 | * @public 44 | */ 45 | export interface EventSourceOptions { 46 | /** URL to connect to. */ 47 | url: string | URL 48 | 49 | /** Callback that fires each time a new event is received. */ 50 | onMessage?: (event: EventSourceMessage) => void 51 | 52 | /** Callback that fires each time the connection is established (multiple times in the case of reconnects). */ 53 | onConnect?: () => void 54 | 55 | /** Callback that fires each time we schedule a new reconnect attempt. Will include an object with information on how many milliseconds it will attempt to delay before doing the reconnect. */ 56 | onScheduleReconnect?: (info: {delay: number}) => void 57 | 58 | /** Callback that fires each time the connection is broken (will still attempt to reconnect, unless `close()` is called). */ 59 | onDisconnect?: () => void 60 | 61 | /** A string to use for the initial `Last-Event-ID` header when connecting. Only used until the first message with a new ID is received. */ 62 | initialLastEventId?: string 63 | 64 | /** Fetch implementation to use for performing requests. Defaults to `globalThis.fetch`. Throws if no implementation can be found. */ 65 | fetch?: FetchLike 66 | 67 | // --- request-related follow --- // 68 | 69 | /** An object literal to set request's headers. */ 70 | headers?: Record 71 | 72 | /** A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode. */ 73 | mode?: 'cors' | 'no-cors' | 'same-origin' 74 | 75 | /** 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. */ 76 | credentials?: 'include' | 'omit' | 'same-origin' 77 | 78 | /** A BodyInit object or null to set request's body. */ 79 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 80 | body?: any 81 | 82 | /** A string to set request's method. */ 83 | method?: string 84 | 85 | /** 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. */ 86 | redirect?: 'error' | 'follow' 87 | 88 | /** A string whose value is a same-origin URL, "about:client", or the empty string, to set request's referrer. */ 89 | referrer?: string 90 | 91 | /** A referrer policy to set request's referrerPolicy. */ 92 | referrerPolicy?: ReferrerPolicy 93 | } 94 | 95 | /** 96 | * Stripped down version of `fetch()`, only defining the parts we care about. 97 | * This ensures it should work with "most" fetch implementations. 98 | * 99 | * @public 100 | */ 101 | export type FetchLike = (url: string | URL, init?: FetchLikeInit) => Promise 102 | 103 | /** 104 | * Stripped down version of `RequestInit`, only defining the parts we care about. 105 | * 106 | * @public 107 | */ 108 | export interface FetchLikeInit { 109 | /** A string to set request's method. */ 110 | method?: string 111 | 112 | /** An AbortSignal to set request's signal. Typed as `any` because of polyfill inconsistencies. */ 113 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 114 | signal?: {aborted: boolean} | any 115 | 116 | /** A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ 117 | headers?: Record 118 | 119 | /** A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode. */ 120 | mode?: 'cors' | 'no-cors' | 'same-origin' 121 | 122 | /** 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. */ 123 | credentials?: 'include' | 'omit' | 'same-origin' 124 | 125 | /** Controls how the request is cached. */ 126 | cache?: 'no-store' 127 | 128 | /** Request body. */ 129 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 130 | body?: any 131 | 132 | /** 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. */ 133 | redirect?: 'error' | 'follow' 134 | 135 | /** A string whose value is a same-origin URL, "about:client", or the empty string, to set request's referrer. */ 136 | referrer?: string 137 | 138 | /** A referrer policy to set request's referrerPolicy. */ 139 | referrerPolicy?: ReferrerPolicy 140 | } 141 | 142 | /** 143 | * Minimal version of the `Response` type returned by `fetch()`. 144 | * 145 | * @public 146 | */ 147 | export interface FetchLikeResponse { 148 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 149 | readonly body: NodeJS.ReadableStream | NodeWebReadableStream | Response['body'] | null 150 | readonly url: string 151 | readonly status: number 152 | readonly redirected: boolean 153 | } 154 | 155 | /** 156 | * Re-export of `EventSourceMessage` from `eventsource-parser`. 157 | */ 158 | export {EventSourceMessage} 159 | -------------------------------------------------------------------------------- /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 {createEventSource} from '../../src/default.js' 5 | import {registerTests} from '../tests.js' 6 | import {createRunner, type TestEvent} from '../waffletest/index.js' 7 | 8 | if (!windowHasBeenExtended(window)) { 9 | throw new Error('window.reportTest has not been defined by playwright') 10 | } 11 | 12 | const runner = registerTests({ 13 | environment: 'browser', 14 | runner: createRunner({onEvent: window.reportTest}), 15 | createEventSource, 16 | port: 3883, 17 | }) 18 | 19 | runner.runTests().then((result) => { 20 | const el = document.getElementById('waffletest') 21 | if (!el) { 22 | console.error('Could not find element with id "waffletest"') 23 | return 24 | } 25 | 26 | el.innerText = 'Running tests…' 27 | el.innerText = `Tests completed ${result.success ? 'successfully' : 'with errors'}` 28 | el.className = result.success ? 'success' : 'fail' 29 | }) 30 | 31 | // Added by our playwright-based test runner 32 | interface ExtendedWindow extends Window { 33 | reportTest: (event: TestEvent) => void 34 | } 35 | 36 | function windowHasBeenExtended(win: Window): win is ExtendedWindow { 37 | return 'reportTest' in win && typeof win.reportTest === 'function' 38 | } 39 | -------------------------------------------------------------------------------- /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-headless 17 | * --serial 18 | */ 19 | import {type BrowserType, chromium, firefox, webkit} from 'playwright' 20 | 21 | import {getServer} from '../server.js' 22 | import {type TestEvent} from '../waffletest/index.js' 23 | import {nodeReporter} from '../waffletest/reporters/nodeReporter.js' 24 | 25 | type BrowserName = 'firefox' | 'chromium' | 'webkit' 26 | 27 | const browsers: Record = { 28 | firefox, 29 | chromium, 30 | webkit, 31 | } 32 | 33 | const {onPass: reportPass, onFail: reportFail, onEnd: reportEnd} = nodeReporter 34 | 35 | const BROWSER_TEST_PORT = 3883 36 | const RUN_IN_SERIAL = process.argv.includes('--serial') 37 | const NO_HEADLESS = process.argv.includes('--no-headless') 38 | 39 | const browserFlag = getBrowserFlag() 40 | if (browserFlag && !isDefinedBrowserType(browserFlag)) { 41 | throw new Error(`Invalid browser flag. Must be one of: ${Object.keys(browsers).join(', ')}`) 42 | } 43 | 44 | const browserFlagType = isDefinedBrowserType(browserFlag) ? browsers[browserFlag] : undefined 45 | 46 | // Run the tests in browsers 47 | ;(async function run() { 48 | const server = await getServer(BROWSER_TEST_PORT) 49 | const jobs = 50 | browserFlag && browserFlagType 51 | ? [{name: browserFlag, browserType: browserFlagType}] 52 | : Object.entries(browsers).map(([name, browserType]) => ({name, browserType})) 53 | 54 | // Run all browsers in parallel, unless --serial is defined 55 | let totalFailures = 0 56 | let totalTests = 0 57 | 58 | if (RUN_IN_SERIAL) { 59 | for (const job of jobs) { 60 | const {failures, tests} = reportBrowserResult(job.name, await runBrowserTest(job.browserType)) 61 | totalFailures += failures 62 | totalTests += tests 63 | } 64 | } else { 65 | await Promise.all( 66 | jobs.map(async (job) => { 67 | const {failures, tests} = reportBrowserResult( 68 | job.name, 69 | await runBrowserTest(job.browserType), 70 | ) 71 | totalFailures += failures 72 | totalTests += tests 73 | }), 74 | ) 75 | } 76 | 77 | function reportBrowserResult( 78 | browserName: string, 79 | events: TestEvent[], 80 | ): {failures: number; passes: number; tests: number} { 81 | console.log(`Browser: ${browserName}`) 82 | 83 | let passes = 0 84 | let failures = 0 85 | for (const event of events) { 86 | switch (event.event) { 87 | case 'start': 88 | // Ignored 89 | break 90 | case 'pass': 91 | passes++ 92 | reportPass(event) 93 | break 94 | case 'fail': 95 | failures++ 96 | reportFail(event) 97 | break 98 | case 'end': 99 | reportEnd(event) 100 | break 101 | default: 102 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 103 | throw new Error(`Unexpected event: ${(event as any).event}`) 104 | } 105 | } 106 | 107 | return {failures, passes, tests: passes + failures} 108 | } 109 | 110 | console.log(`Ran ${totalTests} tests against ${jobs.length} browsers`) 111 | 112 | await server.close() 113 | 114 | if (totalFailures > 0) { 115 | // eslint-disable-next-line no-process-exit 116 | process.exit(1) 117 | } 118 | })() 119 | 120 | async function runBrowserTest(browserType: BrowserType): Promise { 121 | let resolve: (value: TestEvent[] | PromiseLike) => void 122 | const promise = new Promise((_resolve) => { 123 | resolve = _resolve 124 | }) 125 | 126 | const domain = getBaseUrl(BROWSER_TEST_PORT) 127 | const browser = await browserType.launch({headless: !NO_HEADLESS}) 128 | const context = await browser.newContext() 129 | await context.clearCookies() 130 | 131 | const page = await context.newPage() 132 | const events: TestEvent[] = [] 133 | 134 | await page.exposeFunction('reportTest', async (event: TestEvent) => { 135 | events.push(event) 136 | 137 | if (event.event !== 'end') { 138 | return 139 | } 140 | 141 | // Teardown 142 | await context.close() 143 | await browser.close() 144 | resolve(events) 145 | }) 146 | 147 | await page.goto(`${domain}/browser-test`) 148 | 149 | return promise 150 | } 151 | 152 | function isDefinedBrowserType(browserName: string | undefined): browserName is BrowserName { 153 | return typeof browserName === 'string' && browserName in browsers 154 | } 155 | 156 | function getBrowserFlag(): BrowserName | undefined { 157 | const resolved = (function getFlag() { 158 | // Look for --browser 159 | const flagIndex = process.argv.indexOf('--browser') 160 | let flag = flagIndex === -1 ? undefined : process.argv[flagIndex + 1] 161 | if (flag) { 162 | return flag 163 | } 164 | 165 | // Look for --browser= 166 | flag = process.argv.find((arg) => arg.startsWith('--browser=')) 167 | return flag ? flag.split('=')[1] : undefined 168 | })() 169 | 170 | if (!resolved) { 171 | return undefined 172 | } 173 | 174 | if (!isDefinedBrowserType(resolved)) { 175 | throw new Error(`Invalid browser flag. Must be one of: ${Object.keys(browsers).join(', ')}`) 176 | } 177 | 178 | return resolved 179 | } 180 | 181 | function getBaseUrl(port: number): string { 182 | return typeof document === 'undefined' 183 | ? `http://127.0.0.1:${port}` 184 | : `${location.protocol}//${location.hostname}:${port}` 185 | } 186 | -------------------------------------------------------------------------------- /test/bun/client.bun.test.ts: -------------------------------------------------------------------------------- 1 | import {createEventSource} from '../../src/default.js' 2 | import {getServer} from '../server.js' 3 | import {registerTests} from '../tests.js' 4 | import {createRunner} from '../waffletest/index.js' 5 | import {nodeReporter} from '../waffletest/reporters/nodeReporter.js' 6 | 7 | const BUN_TEST_PORT = 3946 8 | 9 | // Run the tests in bun 10 | ;(async function run() { 11 | const server = await getServer(BUN_TEST_PORT) 12 | 13 | const runner = registerTests({ 14 | environment: 'bun', 15 | runner: createRunner(nodeReporter), 16 | createEventSource, 17 | fetch: globalThis.fetch, 18 | port: BUN_TEST_PORT, 19 | }) 20 | 21 | const result = await runner.runTests() 22 | 23 | // Teardown 24 | await server.close() 25 | 26 | // eslint-disable-next-line no-process-exit 27 | process.exit(result.failures) 28 | })() 29 | -------------------------------------------------------------------------------- /test/deno/client.deno.test.ts: -------------------------------------------------------------------------------- 1 | import {createEventSource} from '../../src/default.js' 2 | import {getServer} from '../server.js' 3 | import {registerTests} from '../tests.js' 4 | import {createRunner} from '../waffletest/index.js' 5 | import {nodeReporter} from '../waffletest/reporters/nodeReporter.js' 6 | 7 | const DENO_TEST_PORT = 3947 8 | 9 | // Run the tests in deno 10 | ;(async function run() { 11 | const server = await getServer(DENO_TEST_PORT) 12 | 13 | const runner = registerTests({ 14 | environment: 'deno', 15 | runner: createRunner(nodeReporter), 16 | createEventSource, 17 | fetch: globalThis.fetch, 18 | port: DENO_TEST_PORT, 19 | }) 20 | 21 | const result = await runner.runTests() 22 | 23 | // Teardown 24 | await server.close() 25 | 26 | if (typeof process !== 'undefined' && 'exit' in process && typeof process.exit === 'function') { 27 | // eslint-disable-next-line no-process-exit 28 | process.exit(result.failures) 29 | } else if (typeof globalThis.Deno !== 'undefined') { 30 | globalThis.Deno.exit(result.failures) 31 | } else if (result.failures > 0) { 32 | throw new Error(`Tests failed: ${result.failures}`) 33 | } 34 | })() 35 | -------------------------------------------------------------------------------- /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 type {EventSourceClient} from '../src/types.js' 4 | 5 | type MessageReceiver = SinonSpy & {waitForCallCount: (num: number) => Promise} 6 | 7 | export class ExpectationError extends Error { 8 | type = 'ExpectationError' 9 | } 10 | 11 | export function getCallCounter(onCall?: (info: {numCalls: number}) => void): MessageReceiver { 12 | const listeners: [number, () => void][] = [] 13 | 14 | let numCalls = 0 15 | const spy = sinon.fake(() => { 16 | numCalls++ 17 | 18 | if (onCall) { 19 | onCall({numCalls}) 20 | } 21 | 22 | listeners.forEach(([wanted, resolve]) => { 23 | if (wanted === numCalls) { 24 | resolve() 25 | } 26 | }) 27 | }) 28 | 29 | const fn = spy as unknown as MessageReceiver 30 | fn.waitForCallCount = (num: number) => { 31 | return new Promise((resolve) => { 32 | if (numCalls === num) { 33 | resolve() 34 | } else { 35 | listeners.push([num, resolve]) 36 | } 37 | }) 38 | } 39 | 40 | return fn 41 | } 42 | 43 | export function deferClose(es: EventSourceClient, timeout = 25): Promise { 44 | return new Promise((resolve) => setTimeout(() => resolve(es.close()), timeout)) 45 | } 46 | 47 | export function expect( 48 | thing: unknown, 49 | descriptor: string = '', 50 | ): { 51 | toBe(expected: unknown): void 52 | toBeLessThan(thanNum: number): void 53 | toMatchObject(expected: Record): void 54 | toThrowError(expectedMessage: RegExp): void 55 | } { 56 | return { 57 | toBe(expected: unknown) { 58 | if (thing === expected) { 59 | return 60 | } 61 | 62 | if (descriptor) { 63 | throw new ExpectationError( 64 | `Expected ${descriptor} to be ${JSON.stringify(expected)}, got ${JSON.stringify(thing)}`, 65 | ) 66 | } 67 | 68 | throw new ExpectationError( 69 | `Expected ${JSON.stringify(thing)} to be ${JSON.stringify(expected)}`, 70 | ) 71 | }, 72 | 73 | toBeLessThan(thanNum: number) { 74 | if (typeof thing !== 'number' || thing >= thanNum) { 75 | throw new ExpectationError(`Expected ${thing} to be less than ${thanNum}`) 76 | } 77 | }, 78 | 79 | toMatchObject(expected: Record) { 80 | if (!isPlainObject(thing)) { 81 | throw new ExpectationError(`Expected an object, was... not`) 82 | } 83 | 84 | Object.keys(expected).forEach((key) => { 85 | if (!(key in thing)) { 86 | throw new ExpectationError( 87 | `Expected key "${key}" to be in ${descriptor || 'object'}, was not`, 88 | ) 89 | } 90 | 91 | if (thing[key] !== expected[key]) { 92 | throw new ExpectationError( 93 | `Expected key "${key}" of ${descriptor || 'object'} to be ${JSON.stringify(expected[key])}, was ${JSON.stringify( 94 | thing[key], 95 | )}`, 96 | ) 97 | } 98 | }) 99 | }, 100 | 101 | toThrowError(expectedMessage: RegExp) { 102 | if (typeof thing !== 'function') { 103 | throw new ExpectationError( 104 | `Expected a function that was going to throw, but wasn't a function`, 105 | ) 106 | } 107 | 108 | try { 109 | thing() 110 | } catch (err: unknown) { 111 | const message = err instanceof Error ? err.message : `${err}` 112 | if (!expectedMessage.test(message)) { 113 | throw new ExpectationError( 114 | `Expected error message to match ${expectedMessage}, got ${message}`, 115 | ) 116 | } 117 | return 118 | } 119 | 120 | throw new ExpectationError('Expected function to throw error, but did not') 121 | }, 122 | } 123 | } 124 | 125 | function isPlainObject(obj: unknown): obj is Record { 126 | return typeof obj === 'object' && obj !== null && !Array.isArray(obj) 127 | } 128 | -------------------------------------------------------------------------------- /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 {createEventSource} from '../../src/node.js' 11 | import {getServer} from '../server.js' 12 | import {registerTests} from '../tests.js' 13 | import {nodeReporter} from '../waffletest/reporters/nodeReporter.js' 14 | import {createRunner} from '../waffletest/runner.js' 15 | 16 | const NODE_TEST_PORT = 3945 17 | 18 | // Run the tests in node.js 19 | ;(async function run() { 20 | const server = await getServer(NODE_TEST_PORT) 21 | 22 | const runner = registerTests({ 23 | environment: 'node', 24 | runner: createRunner(nodeReporter), 25 | createEventSource, 26 | fetch: globalThis.fetch, 27 | port: NODE_TEST_PORT, 28 | }) 29 | 30 | const result = await runner.runTests() 31 | 32 | // Teardown 33 | await server.close() 34 | 35 | // eslint-disable-next-line no-process-exit 36 | process.exit(result.failures) 37 | })() 38 | -------------------------------------------------------------------------------- /test/server.ts: -------------------------------------------------------------------------------- 1 | import {createHash} from 'node:crypto' 2 | import {createReadStream} from 'node:fs' 3 | import {createServer, type IncomingMessage, type Server, type ServerResponse} from 'node:http' 4 | import {resolve as resolvePath} from 'node:path' 5 | 6 | import esbuild from 'esbuild' 7 | 8 | import {unicodeLines} from './fixtures.js' 9 | 10 | const isDeno = typeof globalThis.Deno !== 'undefined' 11 | /* {[client id]: number of connects} */ 12 | const connectCounts = new Map() 13 | 14 | export function getServer(port: number): Promise { 15 | return new Promise((resolve, reject) => { 16 | const server = createServer(onRequest) 17 | .on('error', reject) 18 | .listen(port, isDeno ? '127.0.0.1' : '::', () => resolve(server)) 19 | }) 20 | } 21 | 22 | function onRequest(req: IncomingMessage, res: ServerResponse) { 23 | // Disable Nagle's algorithm for testing 24 | if (res.socket && 'setNoDelay' in res.socket) { 25 | res.socket.setNoDelay(true) 26 | } 27 | 28 | const path = new URL(req.url || '/', 'http://localhost').pathname 29 | switch (path) { 30 | // Server-Sent Event endpoints 31 | case '/': 32 | return writeDefault(req, res) 33 | case '/counter': 34 | return writeCounter(req, res) 35 | case '/identified': 36 | return writeIdentifiedListeners(req, res) 37 | case '/end-after-one': 38 | return writeOne(req, res) 39 | case '/slow-connect': 40 | return writeSlowConnect(req, res) 41 | case '/debug': 42 | return writeDebug(req, res) 43 | case '/set-cookie': 44 | return writeCookies(req, res) 45 | case '/authed': 46 | return writeAuthed(req, res) 47 | case '/cors': 48 | return writeCors(req, res) 49 | case '/stalled': 50 | return writeStalledConnection(req, res) 51 | case '/trickle': 52 | return writeTricklingConnection(req, res) 53 | case '/unicode': 54 | return writeUnicode(req, res) 55 | 56 | // Browser test endpoints (HTML/JS) 57 | case '/browser-test': 58 | return writeBrowserTestPage(req, res) 59 | case '/browser-test.js': 60 | return writeBrowserTestScript(req, res) 61 | 62 | // Fallback, eg 404 63 | default: 64 | return writeFallback(req, res) 65 | } 66 | } 67 | 68 | function writeDefault(_req: IncomingMessage, res: ServerResponse) { 69 | res.writeHead(200, { 70 | 'Content-Type': 'text/event-stream', 71 | 'Cache-Control': 'no-cache', 72 | Connection: 'keep-alive', 73 | }) 74 | 75 | tryWrite( 76 | res, 77 | formatEvent({ 78 | event: 'welcome', 79 | data: 'Hello, world!', 80 | }), 81 | ) 82 | 83 | // For some reason, Bun seems to need this to flush 84 | tryWrite(res, ':\n') 85 | } 86 | 87 | /** 88 | * Writes 3 messages, then closes connection. 89 | * Picks up event ID and continues from there. 90 | */ 91 | async function writeCounter(req: IncomingMessage, res: ServerResponse) { 92 | res.writeHead(200, { 93 | 'Content-Type': 'text/event-stream', 94 | 'Cache-Control': 'no-cache', 95 | Connection: 'keep-alive', 96 | }) 97 | 98 | tryWrite(res, formatEvent({retry: 50, data: ''})) 99 | 100 | let counter = parseInt(getLastEventId(req) || '0', 10) 101 | for (let i = 0; i < 3; i++) { 102 | counter++ 103 | tryWrite( 104 | res, 105 | formatEvent({ 106 | event: 'counter', 107 | data: `Counter is at ${counter}`, 108 | id: `${counter}`, 109 | }), 110 | ) 111 | await delay(25) 112 | } 113 | 114 | res.end() 115 | } 116 | 117 | async function writeIdentifiedListeners(req: IncomingMessage, res: ServerResponse) { 118 | const url = new URL(req.url || '/', 'http://localhost') 119 | const clientId = url.searchParams.get('client-id') 120 | if (!clientId) { 121 | res.writeHead(400, { 122 | 'Content-Type': 'application/json', 123 | 'Cache-Control': 'no-cache', 124 | Connection: 'keep-alive', 125 | }) 126 | tryWrite(res, JSON.stringify({error: 'Missing "id" or "client-id" query parameter'})) 127 | res.end() 128 | return 129 | } 130 | 131 | // SSE endpoint, tracks how many listeners have connected with a given client ID 132 | if ((req.headers.accept || '').includes('text/event-stream')) { 133 | connectCounts.set(clientId, (connectCounts.get(clientId) || 0) + 1) 134 | 135 | res.writeHead(200, { 136 | 'Content-Type': 'text/event-stream', 137 | 'Cache-Control': 'no-cache', 138 | Connection: 'keep-alive', 139 | }) 140 | tryWrite(res, formatEvent({data: '', retry: 250})) 141 | tryWrite(res, formatEvent({data: `${connectCounts.get(clientId)}`})) 142 | 143 | if (url.searchParams.get('auto-close')) { 144 | res.end() 145 | } 146 | 147 | return 148 | } 149 | 150 | // JSON endpoint, returns the number of connects for a given client ID 151 | res.writeHead(200, { 152 | 'Content-Type': 'application/json', 153 | 'Cache-Control': 'no-cache', 154 | }) 155 | tryWrite(res, JSON.stringify({clientIdConnects: connectCounts.get(clientId) ?? 0})) 156 | res.end() 157 | } 158 | 159 | function writeOne(req: IncomingMessage, res: ServerResponse) { 160 | const last = getLastEventId(req) 161 | res.writeHead(last ? 204 : 200, { 162 | 'Content-Type': 'text/event-stream', 163 | 'Cache-Control': 'no-cache', 164 | Connection: 'keep-alive', 165 | }) 166 | 167 | if (!last) { 168 | tryWrite(res, formatEvent({retry: 50, data: ''})) 169 | tryWrite( 170 | res, 171 | formatEvent({ 172 | event: 'progress', 173 | data: '100%', 174 | id: 'prct-100', 175 | }), 176 | ) 177 | } 178 | 179 | res.end() 180 | } 181 | 182 | async function writeSlowConnect(_req: IncomingMessage, res: ServerResponse) { 183 | await delay(200) 184 | 185 | res.writeHead(200, { 186 | 'Content-Type': 'text/event-stream', 187 | 'Cache-Control': 'no-cache', 188 | Connection: 'keep-alive', 189 | }) 190 | 191 | tryWrite( 192 | res, 193 | formatEvent({ 194 | event: 'welcome', 195 | data: 'That was a slow connect, was it not?', 196 | }), 197 | ) 198 | 199 | res.end() 200 | } 201 | 202 | async function writeStalledConnection(req: IncomingMessage, res: ServerResponse) { 203 | res.writeHead(200, { 204 | 'Content-Type': 'text/event-stream', 205 | 'Cache-Control': 'no-cache', 206 | Connection: 'keep-alive', 207 | }) 208 | 209 | const lastId = getLastEventId(req) 210 | const reconnected = lastId === '1' 211 | 212 | tryWrite( 213 | res, 214 | formatEvent({ 215 | id: reconnected ? '2' : '1', 216 | event: 'welcome', 217 | data: reconnected 218 | ? 'Welcome back' 219 | : 'Connected - now I will sleep for "too long" without sending data', 220 | }), 221 | ) 222 | 223 | if (reconnected) { 224 | await delay(250) 225 | tryWrite( 226 | res, 227 | formatEvent({ 228 | id: '3', 229 | event: 'success', 230 | data: 'You waited long enough!', 231 | }), 232 | ) 233 | 234 | res.end() 235 | } 236 | 237 | // Intentionally not closing on first-connect that never sends data after welcome 238 | } 239 | 240 | async function writeUnicode(_req: IncomingMessage, res: ServerResponse) { 241 | res.writeHead(200, { 242 | 'Content-Type': 'text/event-stream', 243 | 'Cache-Control': 'no-cache', 244 | Connection: 'keep-alive', 245 | }) 246 | 247 | tryWrite( 248 | res, 249 | formatEvent({ 250 | event: 'welcome', 251 | data: 'Connected - I will now send some chonks (cuter chunks) with unicode', 252 | }), 253 | ) 254 | 255 | tryWrite( 256 | res, 257 | formatEvent({ 258 | event: 'unicode', 259 | data: unicodeLines[0], 260 | }), 261 | ) 262 | 263 | await delay(100) 264 | 265 | // Start of a valid SSE chunk 266 | tryWrite(res, 'event: unicode\ndata: ') 267 | 268 | // Write "Espen ❤️ Kokos" in two halves: 269 | // 1st: Espen � [..., 226, 153] 270 | // 2st: � Kokos [165, 32, ...] 271 | tryWrite(res, new Uint8Array([69, 115, 112, 101, 110, 32, 226, 153])) 272 | 273 | // Give time to the client to process the first half 274 | await delay(1000) 275 | 276 | tryWrite(res, new Uint8Array([165, 32, 75, 111, 107, 111, 115])) 277 | 278 | // Closing end of packet 279 | tryWrite(res, '\n\n\n\n') 280 | 281 | tryWrite(res, formatEvent({event: 'disconnect', data: 'Thanks for listening'})) 282 | res.end() 283 | } 284 | 285 | async function writeTricklingConnection(_req: IncomingMessage, res: ServerResponse) { 286 | res.writeHead(200, { 287 | 'Content-Type': 'text/event-stream', 288 | 'Cache-Control': 'no-cache', 289 | Connection: 'keep-alive', 290 | }) 291 | 292 | tryWrite( 293 | res, 294 | formatEvent({ 295 | event: 'welcome', 296 | data: 'Connected - now I will keep sending "comments" for a while', 297 | }), 298 | ) 299 | 300 | for (let i = 0; i < 60; i++) { 301 | await delay(500) 302 | tryWrite(res, ':\n') 303 | } 304 | 305 | tryWrite(res, formatEvent({event: 'disconnect', data: 'Thanks for listening'})) 306 | res.end() 307 | } 308 | 309 | function writeCors(req: IncomingMessage, res: ServerResponse) { 310 | const origin = req.headers.origin 311 | const cors = origin ? {'Access-Control-Allow-Origin': origin} : {} 312 | 313 | res.writeHead(200, { 314 | 'Content-Type': 'text/event-stream', 315 | 'Cache-Control': 'no-cache', 316 | Connection: 'keep-alive', 317 | ...cors, 318 | }) 319 | 320 | tryWrite( 321 | res, 322 | formatEvent({ 323 | event: 'origin', 324 | data: origin || '', 325 | }), 326 | ) 327 | 328 | res.end() 329 | } 330 | 331 | async function writeDebug(req: IncomingMessage, res: ServerResponse) { 332 | const hash = new Promise((resolve, reject) => { 333 | const bodyHash = createHash('sha256') 334 | req.on('error', reject) 335 | req.on('data', (chunk) => bodyHash.update(chunk)) 336 | req.on('end', () => resolve(bodyHash.digest('hex'))) 337 | }) 338 | 339 | let bodyHash: string 340 | try { 341 | bodyHash = await hash 342 | } catch (err: unknown) { 343 | res.writeHead(500, 'Internal Server Error') 344 | tryWrite(res, err instanceof Error ? err.message : `${err}`) 345 | res.end() 346 | return 347 | } 348 | 349 | res.writeHead(200, { 350 | 'Content-Type': 'text/event-stream', 351 | 'Cache-Control': 'no-cache', 352 | Connection: 'keep-alive', 353 | }) 354 | 355 | tryWrite( 356 | res, 357 | formatEvent({ 358 | event: 'debug', 359 | data: JSON.stringify({ 360 | method: req.method, 361 | headers: req.headers, 362 | bodyHash, 363 | }), 364 | }), 365 | ) 366 | 367 | res.end() 368 | } 369 | 370 | /** 371 | * Ideally we'd just set these in the storage state, but Playwright does not seem to 372 | * be able to for some obscure reason - is not set if passed in page context or through 373 | * `addCookies()`. 374 | */ 375 | function writeCookies(_req: IncomingMessage, res: ServerResponse) { 376 | res.writeHead(200, { 377 | 'Content-Type': 'application/json', 378 | 'Cache-Control': 'no-cache', 379 | 'Set-Cookie': 'someSession=someValue; Path=/authed; HttpOnly; SameSite=Lax;', 380 | Connection: 'keep-alive', 381 | }) 382 | tryWrite(res, JSON.stringify({cookiesWritten: true})) 383 | res.end() 384 | } 385 | 386 | function writeAuthed(req: IncomingMessage, res: ServerResponse) { 387 | res.writeHead(200, { 388 | 'Content-Type': 'text/event-stream', 389 | 'Cache-Control': 'no-cache', 390 | Connection: 'keep-alive', 391 | }) 392 | 393 | tryWrite( 394 | res, 395 | formatEvent({ 396 | event: 'authInfo', 397 | data: JSON.stringify({cookies: req.headers.cookie || ''}), 398 | }), 399 | ) 400 | 401 | res.end() 402 | } 403 | 404 | function writeFallback(_req: IncomingMessage, res: ServerResponse) { 405 | res.writeHead(404, { 406 | 'Content-Type': 'text/plain', 407 | 'Cache-Control': 'no-cache', 408 | Connection: 'close', 409 | }) 410 | 411 | tryWrite(res, 'File not found') 412 | res.end() 413 | } 414 | 415 | function writeBrowserTestPage(_req: IncomingMessage, res: ServerResponse) { 416 | res.writeHead(200, { 417 | 'Content-Type': 'text/html; charset=utf-8', 418 | 'Cache-Control': 'no-cache', 419 | Connection: 'close', 420 | }) 421 | 422 | createReadStream(resolvePath(__dirname, './browser/browser-test.html')).pipe(res) 423 | } 424 | 425 | async function writeBrowserTestScript(_req: IncomingMessage, res: ServerResponse) { 426 | res.writeHead(200, { 427 | 'Content-Type': 'text/javascript; charset=utf-8', 428 | 'Cache-Control': 'no-cache', 429 | Connection: 'close', 430 | }) 431 | 432 | const build = await esbuild.build({ 433 | bundle: true, 434 | target: ['chrome71', 'edge79', 'firefox105', 'safari14.1'], 435 | entryPoints: [resolvePath(__dirname, './browser/browser-test.ts')], 436 | sourcemap: 'inline', 437 | write: false, 438 | outdir: 'out', 439 | }) 440 | 441 | tryWrite(res, build.outputFiles.map((file) => file.text).join('\n\n')) 442 | res.end() 443 | } 444 | 445 | function delay(ms: number): Promise { 446 | return new Promise((resolve) => setTimeout(resolve, ms)) 447 | } 448 | 449 | function getLastEventId(req: IncomingMessage): string | undefined { 450 | const lastId = req.headers['last-event-id'] 451 | return typeof lastId === 'string' ? lastId : undefined 452 | } 453 | 454 | export interface SseMessage { 455 | event?: string 456 | retry?: number 457 | id?: string 458 | data: string 459 | } 460 | 461 | export function formatEvent(message: SseMessage | string): string { 462 | const msg = typeof message === 'string' ? {data: message} : message 463 | 464 | let output = '' 465 | if (msg.event) { 466 | output += `event: ${msg.event}\n` 467 | } 468 | 469 | if (msg.retry) { 470 | output += `retry: ${msg.retry}\n` 471 | } 472 | 473 | if (typeof msg.id === 'string' || typeof msg.id === 'number') { 474 | output += `id: ${msg.id}\n` 475 | } 476 | 477 | output += encodeData(msg.data || '') 478 | output += '\n\n' 479 | 480 | return output 481 | } 482 | 483 | export function formatComment(comment: string): string { 484 | return `:${comment}\n\n` 485 | } 486 | 487 | export function encodeData(text: string): string { 488 | if (!text) { 489 | return '' 490 | } 491 | 492 | const data = String(text).replace(/(\r\n|\r|\n)/g, '\n') 493 | const lines = data.split(/\n/) 494 | 495 | let line = '' 496 | let output = '' 497 | 498 | for (let i = 0, l = lines.length; i < l; ++i) { 499 | line = lines[i] 500 | 501 | output += `data: ${line}` 502 | output += i + 1 === l ? '\n\n' : '\n' 503 | } 504 | 505 | return output 506 | } 507 | 508 | function tryWrite(res: ServerResponse, chunk: string | Uint8Array) { 509 | try { 510 | res.write(chunk) 511 | } catch (err: unknown) { 512 | // Deno randomly throws on write after close, it seems 513 | if (err instanceof TypeError && err.message.includes('cannot close or enqueue')) { 514 | return 515 | } 516 | 517 | throw err 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /test/tests.ts: -------------------------------------------------------------------------------- 1 | import {CLOSED, CONNECTING, OPEN} from '../src/constants.js' 2 | import type {createEventSource as CreateEventSourceFn, EventSourceMessage} from '../src/default.js' 3 | import {unicodeLines} from './fixtures.js' 4 | import {deferClose, expect, getCallCounter} from './helpers.js' 5 | import type {TestRunner} from './waffletest/index.js' 6 | 7 | export function registerTests(options: { 8 | environment: string 9 | runner: TestRunner 10 | port: number 11 | createEventSource: typeof CreateEventSourceFn 12 | fetch?: typeof fetch 13 | }): TestRunner { 14 | const {createEventSource, port, fetch, runner, environment} = options 15 | 16 | // eslint-disable-next-line no-empty-function 17 | const browserTest = environment === 'browser' ? runner.registerTest : function noop() {} 18 | const test = runner.registerTest 19 | 20 | const baseUrl = 21 | typeof document === 'undefined' 22 | ? 'http://127.0.0.1' 23 | : `${location.protocol}//${location.hostname}` 24 | 25 | test('can connect, receive message, manually disconnect', async () => { 26 | const onMessage = getCallCounter() 27 | const es = createEventSource({ 28 | url: new URL(`${baseUrl}:${port}/`), 29 | fetch, 30 | onMessage, 31 | }) 32 | 33 | await onMessage.waitForCallCount(1) 34 | 35 | expect(onMessage.callCount).toBe(1) 36 | expect(onMessage.lastCall.lastArg).toMatchObject({ 37 | data: 'Hello, world!', 38 | event: 'welcome', 39 | id: undefined, 40 | }) 41 | 42 | await deferClose(es) 43 | }) 44 | 45 | test('can connect using URL only', async () => { 46 | const es = createEventSource(new URL(`${baseUrl}:${port}/`)) 47 | for await (const event of es) { 48 | expect(event).toMatchObject({event: 'welcome'}) 49 | await deferClose(es) 50 | } 51 | }) 52 | 53 | test('can connect using URL string only', async () => { 54 | const es = createEventSource(`${baseUrl}:${port}/`) 55 | for await (const event of es) { 56 | expect(event).toMatchObject({event: 'welcome'}) 57 | await deferClose(es) 58 | } 59 | }) 60 | 61 | test('can handle unicode data correctly', async () => { 62 | const onMessage = getCallCounter() 63 | const es = createEventSource({ 64 | url: new URL(`${baseUrl}:${port}/unicode`), 65 | fetch, 66 | onMessage, 67 | }) 68 | 69 | const messages: EventSourceMessage[] = [] 70 | for await (const event of es) { 71 | if (event.event === 'unicode') { 72 | messages.push(event) 73 | } 74 | 75 | if (messages.length === 2) { 76 | break 77 | } 78 | } 79 | 80 | expect(messages[0].data).toBe(unicodeLines[0]) 81 | expect(messages[1].data).toBe(unicodeLines[1]) 82 | 83 | await deferClose(es) 84 | }) 85 | 86 | test('will reconnect with last received message id if server disconnects', async () => { 87 | const onMessage = getCallCounter() 88 | const onDisconnect = getCallCounter() 89 | const url = `${baseUrl}:${port}/counter` 90 | const es = createEventSource({ 91 | url, 92 | fetch, 93 | onMessage, 94 | onDisconnect, 95 | }) 96 | 97 | // While still receiving messages (we receive 3 at a time before it disconnects) 98 | await onMessage.waitForCallCount(1) 99 | expect(es.readyState, 'readyState').toBe(OPEN) // Open (connected) 100 | 101 | // While waiting for reconnect (after 3 messages it will disconnect and reconnect) 102 | await onDisconnect.waitForCallCount(1) 103 | expect(es.readyState, 'readyState').toBe(CONNECTING) // Connecting (reconnecting) 104 | expect(onMessage.callCount).toBe(3) 105 | 106 | // Will reconnect infinitely, stop at 8 messages 107 | await onMessage.waitForCallCount(8) 108 | 109 | expect(es.url).toBe(url) 110 | expect(onMessage.lastCall.lastArg).toMatchObject({ 111 | data: 'Counter is at 8', 112 | event: 'counter', 113 | id: '8', 114 | }) 115 | expect(es.lastEventId).toBe('8') 116 | expect(onMessage.callCount).toBe(8) 117 | 118 | await deferClose(es) 119 | }) 120 | 121 | test('will not reconnect after explicit `close()`', async () => { 122 | const request = fetch || globalThis.fetch 123 | const onMessage = getCallCounter() 124 | const onDisconnect = getCallCounter() 125 | const onScheduleReconnect = getCallCounter() 126 | const clientId = Math.random().toString(36).slice(2) 127 | const url = `${baseUrl}:${port}/identified?client-id=${clientId}` 128 | const es = createEventSource({ 129 | url, 130 | fetch, 131 | onMessage, 132 | onDisconnect, 133 | onScheduleReconnect, 134 | }) 135 | 136 | // Should receive a message containing the number of listeners on the given ID 137 | await onMessage.waitForCallCount(1) 138 | expect(onMessage.lastCall.lastArg).toMatchObject({data: '1'}) 139 | expect(es.readyState, 'readyState').toBe(OPEN) // Open (connected) 140 | 141 | // Explicitly disconnect. Should normally reconnect within ~250ms (server sends retry: 250) 142 | // but we'll close it before that happens 143 | es.close() 144 | expect(es.readyState, 'readyState').toBe(CLOSED) 145 | expect(onMessage.callCount).toBe(1) 146 | expect(onScheduleReconnect.callCount, 'onScheduleReconnect call count').toBe(0) 147 | 148 | // After 500 ms, there should still only be a single connect with this client ID 149 | await new Promise((resolve) => setTimeout(resolve, 500)) 150 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 151 | 152 | // Wait another 500 ms, just to be sure there are no slow reconnects 153 | await new Promise((resolve) => setTimeout(resolve, 500)) 154 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 155 | 156 | expect(onScheduleReconnect.callCount, 'onScheduleReconnect call count').toBe(0) 157 | }) 158 | 159 | test('will not reconnect after explicit `close()` in `onDisconnect`', async () => { 160 | const request = fetch || globalThis.fetch 161 | const onMessage = getCallCounter() 162 | const onDisconnect = getCallCounter(() => es.close()) 163 | const onScheduleReconnect = getCallCounter() 164 | const clientId = Math.random().toString(36).slice(2) 165 | const url = `${baseUrl}:${port}/identified?client-id=${clientId}&auto-close=true` 166 | const es = createEventSource({ 167 | url, 168 | fetch, 169 | onMessage, 170 | onDisconnect, 171 | onScheduleReconnect, 172 | }) 173 | 174 | // Should receive a message containing the number of listeners on the given ID 175 | await onMessage.waitForCallCount(1) 176 | expect(onMessage.lastCall.lastArg, 'onMessage `event` argument').toMatchObject({data: '1'}) 177 | expect(es.readyState, 'readyState').toBe(OPEN) // Open (connected) 178 | 179 | await onDisconnect.waitForCallCount(1) 180 | expect(es.readyState, 'readyState').toBe(CLOSED) // `onDisconnect` called first, closes ES. 181 | 182 | // After 50 ms, we should still be in closing state - no reconnecting 183 | expect(es.readyState, 'readyState').toBe(CLOSED) 184 | 185 | // After 500 ms, there should be no clients connected to the given ID 186 | await new Promise((resolve) => setTimeout(resolve, 500)) 187 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 188 | expect(es.readyState, 'readyState').toBe(CLOSED) 189 | 190 | // Wait another 500 ms, just to be sure there are no slow reconnects 191 | await new Promise((resolve) => setTimeout(resolve, 500)) 192 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 193 | expect(es.readyState, 'readyState').toBe(CLOSED) 194 | }) 195 | 196 | test('can use async iterator, reconnects transparently', async () => { 197 | const onDisconnect = getCallCounter() 198 | const url = `${baseUrl}:${port}/counter` 199 | const es = createEventSource({ 200 | url, 201 | fetch, 202 | onDisconnect, 203 | }) 204 | 205 | let numMessages = 1 206 | for await (const event of es) { 207 | expect(event.event).toBe('counter') 208 | expect(event.data).toBe(`Counter is at ${numMessages}`) 209 | expect(event.id).toBe(`${numMessages}`) 210 | 211 | // Will reconnect infinitely, stop at 11 messages 212 | if (++numMessages === 11) { 213 | break 214 | } 215 | } 216 | 217 | expect(onDisconnect.callCount).toBe(3) 218 | await deferClose(es) 219 | }) 220 | 221 | test('async iterator breaks out of loop without error when calling `close()`', async () => { 222 | const url = `${baseUrl}:${port}/counter` 223 | const es = createEventSource({ 224 | url, 225 | fetch, 226 | }) 227 | 228 | let hasSeenMessage = false 229 | for await (const {event} of es) { 230 | hasSeenMessage = true 231 | expect(event).toBe('counter') 232 | es.close() 233 | } 234 | 235 | expect(hasSeenMessage).toBe(true) 236 | }) 237 | 238 | test('will have correct ready state throughout lifecycle', async () => { 239 | const onMessage = getCallCounter() 240 | const onConnect = getCallCounter() 241 | const onDisconnect = getCallCounter() 242 | const url = `${baseUrl}:${port}/slow-connect` 243 | const es = createEventSource({ 244 | url, 245 | fetch, 246 | onMessage, 247 | onConnect, 248 | onDisconnect, 249 | }) 250 | 251 | // Connecting 252 | expect(es.readyState, 'readyState').toBe(CONNECTING) 253 | 254 | // Connected 255 | await onConnect.waitForCallCount(1) 256 | expect(es.readyState, 'readyState').toBe(OPEN) 257 | 258 | // Disconnected 259 | await onDisconnect.waitForCallCount(1) 260 | expect(es.readyState, 'readyState').toBe(CONNECTING) 261 | 262 | // Closed 263 | await es.close() 264 | expect(es.readyState, 'readyState').toBe(CLOSED) 265 | }) 266 | 267 | test('calling connect while already connected does nothing', async () => { 268 | const onMessage = getCallCounter() 269 | const es = createEventSource({ 270 | url: `${baseUrl}:${port}/counter`, 271 | fetch, 272 | onMessage, 273 | }) 274 | 275 | es.connect() 276 | await onMessage.waitForCallCount(1) 277 | es.connect() 278 | await onMessage.waitForCallCount(2) 279 | es.connect() 280 | 281 | await deferClose(es) 282 | }) 283 | 284 | test('can pass an initial last received event id', async () => { 285 | const onMessage = getCallCounter() 286 | const es = createEventSource({ 287 | url: `${baseUrl}:${port}/counter`, 288 | fetch, 289 | onMessage, 290 | initialLastEventId: '50000', 291 | }) 292 | 293 | await onMessage.waitForCallCount(4) 294 | 295 | expect(es.lastEventId).toBe('50004') 296 | expect(onMessage.callCount).toBe(4) 297 | expect(onMessage.firstCall.lastArg).toMatchObject({ 298 | data: 'Counter is at 50001', 299 | event: 'counter', 300 | id: '50001', 301 | }) 302 | expect(onMessage.lastCall.lastArg).toMatchObject({ 303 | data: 'Counter is at 50004', 304 | event: 'counter', 305 | id: '50004', 306 | }) 307 | 308 | await deferClose(es) 309 | }) 310 | 311 | test('will close stream on HTTP 204', async () => { 312 | const onMessage = getCallCounter() 313 | const onDisconnect = getCallCounter() 314 | const es = createEventSource({ 315 | url: `${baseUrl}:${port}/end-after-one`, 316 | fetch, 317 | onMessage, 318 | onDisconnect, 319 | }) 320 | 321 | // First disconnect, then reconnect and given a 204 322 | await onDisconnect.waitForCallCount(2) 323 | 324 | // Only the first connect should have given a message 325 | await onMessage.waitForCallCount(1) 326 | 327 | expect(es.lastEventId).toBe('prct-100') 328 | expect(es.readyState, 'readyState').toBe(CLOSED) // CLOSED 329 | expect(onMessage.callCount).toBe(1) 330 | expect(onMessage.lastCall.lastArg).toMatchObject({ 331 | data: '100%', 332 | event: 'progress', 333 | id: 'prct-100', 334 | }) 335 | 336 | await deferClose(es) 337 | }) 338 | 339 | test('can send plain-text string data as POST request with headers', async () => { 340 | const onMessage = getCallCounter() 341 | const es = createEventSource({ 342 | url: new URL(`${baseUrl}:${port}/debug`), 343 | method: 'POST', 344 | body: 'Blåbærsyltetøy, rømme og brunost på vaffel', 345 | headers: {'Content-Type': 'text/norwegian-plain; charset=utf-8'}, 346 | fetch, 347 | onMessage, 348 | }) 349 | 350 | await onMessage.waitForCallCount(1) 351 | expect(onMessage.callCount).toBe(1) 352 | 353 | const lastMessage = onMessage.lastCall.lastArg 354 | expect(lastMessage.event).toBe('debug') 355 | 356 | const data = JSON.parse(lastMessage.data) 357 | expect(data.method).toBe('POST') 358 | expect(data.bodyHash).toBe('5f4e50479bfc5ccdb6f865cc3341245dde9e81aa2f36b0c80e3fcbcfbeccaeda') 359 | expect(data.headers).toMatchObject({'content-type': 'text/norwegian-plain; charset=utf-8'}) 360 | 361 | await deferClose(es) 362 | }) 363 | 364 | test('throws if `url` is not a string/url', () => { 365 | const onMessage = getCallCounter() 366 | expect(() => { 367 | const es = createEventSource({ 368 | // @ts-expect-error Should be a string 369 | url: 123, 370 | fetch, 371 | onMessage, 372 | }) 373 | 374 | es.close() 375 | }).toThrowError(/Invalid URL provided/) 376 | 377 | expect(onMessage.callCount).toBe(0) 378 | }) 379 | 380 | test('throws if `initialLastEventId` is not a string', () => { 381 | const onMessage = getCallCounter() 382 | expect(() => { 383 | const es = createEventSource({ 384 | url: `${baseUrl}:${port}/`, 385 | fetch, 386 | onMessage, 387 | // @ts-expect-error Should be a string 388 | initialLastEventId: 123, 389 | }) 390 | 391 | es.close() 392 | }).toThrowError(/Invalid initialLastEventId provided - must be string or undefined/) 393 | 394 | expect(onMessage.callCount).toBe(0) 395 | }) 396 | 397 | test('can request cross-origin', async () => { 398 | const hostUrl = new URL(`${baseUrl}:${port}/cors`) 399 | const url = new URL(hostUrl) 400 | url.hostname = url.hostname === 'localhost' ? '127.0.0.1' : 'localhost' 401 | 402 | const onMessage = getCallCounter() 403 | const es = createEventSource({ 404 | url, 405 | fetch, 406 | onMessage, 407 | }) 408 | 409 | await onMessage.waitForCallCount(1) 410 | expect(onMessage.callCount).toBe(1) 411 | 412 | const lastMessage = onMessage.lastCall.lastArg 413 | expect(lastMessage.event).toBe('origin') 414 | 415 | if (environment === 'browser') { 416 | expect(lastMessage.data).toBe(hostUrl.origin) 417 | } else { 418 | expect(lastMessage.data).toBe('') 419 | } 420 | 421 | await deferClose(es) 422 | }) 423 | 424 | browserTest( 425 | 'can use the `credentials` option to control cookies being sent/not sent', 426 | async () => { 427 | // Ideally this would be done through playwright, but can't get it working, 428 | // so let's just fire off a request that sets the cookies for now 429 | const {cookiesWritten} = await globalThis.fetch('/set-cookie').then((res) => res.json()) 430 | expect(cookiesWritten).toBe(true) 431 | 432 | let es = createEventSource({url: '/authed', fetch, credentials: 'include'}) 433 | for await (const event of es) { 434 | expect(event.event).toBe('authInfo') 435 | expect(JSON.parse(event.data)).toMatchObject({cookies: 'someSession=someValue'}) 436 | break 437 | } 438 | 439 | await deferClose(es) 440 | 441 | es = createEventSource({url: '/authed', fetch, credentials: 'omit'}) 442 | for await (const event of es) { 443 | expect(event.event).toBe('authInfo') 444 | expect(JSON.parse(event.data)).toMatchObject({cookies: ''}) 445 | break 446 | } 447 | }, 448 | ) 449 | 450 | return runner 451 | } 452 | -------------------------------------------------------------------------------- /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 | TestEvent, 5 | TestFailEvent, 6 | TestFn, 7 | TestPassEvent, 8 | TestRunner, 9 | TestRunnerOptions, 10 | TestStartEvent, 11 | } from './types.js' 12 | 13 | interface TestDefinition { 14 | title: string 15 | timeout: number 16 | action: TestFn 17 | only?: boolean 18 | } 19 | 20 | const DEFAULT_TIMEOUT = 15000 21 | 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | const noop = (_event: TestEvent) => { 24 | /* intentional noop */ 25 | } 26 | 27 | export function createRunner(options: TestRunnerOptions = {}): TestRunner { 28 | const {onEvent = noop, onStart = noop, onPass = noop, onFail = noop, onEnd = noop} = options 29 | const tests: TestDefinition[] = [] 30 | 31 | let hasOnlyTest = false 32 | let running = false 33 | let passes = 0 34 | let failures = 0 35 | let suiteStart = 0 36 | 37 | function registerTest(title: string, fn: TestFn, timeout?: number, only?: boolean): void { 38 | if (running) { 39 | throw new Error('Cannot register a test while tests are running') 40 | } 41 | 42 | if (only && !hasOnlyTest) { 43 | // Clear the current tests 44 | hasOnlyTest = true 45 | while (tests.length > 0) { 46 | tests.pop() 47 | } 48 | } 49 | 50 | if (!hasOnlyTest || only) { 51 | tests.push({ 52 | title, 53 | timeout: timeout ?? DEFAULT_TIMEOUT, 54 | action: fn, 55 | only, 56 | }) 57 | } 58 | } 59 | 60 | registerTest.only = (title: string, fn: TestFn, timeout?: number): void => { 61 | return registerTest(title, fn, timeout, true) 62 | } 63 | 64 | async function runTests(): Promise { 65 | running = true 66 | suiteStart = Date.now() 67 | 68 | const start: TestStartEvent = { 69 | event: 'start', 70 | tests: tests.length, 71 | } 72 | 73 | onStart(start) 74 | onEvent(start) 75 | 76 | for (const test of tests) { 77 | const startTime = Date.now() 78 | try { 79 | await Promise.race([test.action(), getTimeoutPromise(test.timeout)]) 80 | passes++ 81 | const pass: TestPassEvent = { 82 | event: 'pass', 83 | duration: Date.now() - startTime, 84 | title: test.title, 85 | } 86 | onPass(pass) 87 | onEvent(pass) 88 | } catch (err: unknown) { 89 | failures++ 90 | 91 | let error: string 92 | if (err instanceof ExpectationError) { 93 | error = err.message 94 | } else if (err instanceof Error) { 95 | const stack = (err.stack || '').toString() 96 | error = stack.includes(err.message) ? stack : `${err.message}\n\n${stack}` 97 | } else { 98 | error = `${err}` 99 | } 100 | 101 | const fail: TestFailEvent = { 102 | event: 'fail', 103 | title: test.title, 104 | duration: Date.now() - startTime, 105 | error, 106 | } 107 | onFail(fail) 108 | onEvent(fail) 109 | } 110 | } 111 | 112 | const end: TestEndEvent = { 113 | event: 'end', 114 | success: failures === 0, 115 | failures, 116 | passes, 117 | tests: tests.length, 118 | duration: Date.now() - suiteStart, 119 | } 120 | onEnd(end) 121 | onEvent(end) 122 | 123 | running = false 124 | 125 | return end 126 | } 127 | 128 | function getTestCount() { 129 | return tests.length 130 | } 131 | 132 | function isRunning() { 133 | return running 134 | } 135 | 136 | return { 137 | isRunning, 138 | getTestCount, 139 | registerTest, 140 | runTests, 141 | } 142 | } 143 | 144 | function getTimeoutPromise(ms: number) { 145 | return new Promise((_resolve, reject) => { 146 | setTimeout(reject, ms, new Error(`Test timed out after ${ms} ms`)) 147 | }) 148 | } 149 | -------------------------------------------------------------------------------- /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 | only: (title: string, fn: TestFn, timeout?: number) => void 21 | } 22 | 23 | export interface TestRunner { 24 | isRunning(): boolean 25 | getTestCount(): number 26 | registerTest: RegisterTest 27 | runTests: () => Promise 28 | } 29 | 30 | export type TestEvent = TestStartEvent | TestPassEvent | TestFailEvent | TestEndEvent 31 | 32 | export interface TestStartEvent { 33 | event: 'start' 34 | tests: number 35 | } 36 | 37 | export interface TestPassEvent { 38 | event: 'pass' 39 | title: string 40 | duration: number 41 | } 42 | 43 | export interface TestFailEvent { 44 | event: 'fail' 45 | title: string 46 | duration: number 47 | error: string 48 | } 49 | 50 | export interface TestEndEvent { 51 | event: 'end' 52 | success: boolean 53 | tests: number 54 | passes: number 55 | failures: number 56 | duration: number 57 | } 58 | -------------------------------------------------------------------------------- /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 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["ES2020", "DOM"], 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | 9 | // Strict type-checking 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictPropertyInitialization": true, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | 18 | // Additional checks 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "skipLibCheck": true, 24 | 25 | // Module resolution 26 | "moduleResolution": "bundler", 27 | "allowSyntheticDefaultImports": true, 28 | "esModuleInterop": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------