├── .eslintignore ├── test ├── waffletest │ ├── index.ts │ ├── reporters │ │ ├── helpers.ts │ │ ├── defaultReporter.ts │ │ └── nodeReporter.ts │ ├── types.ts │ └── runner.ts ├── fixtures.ts ├── bun │ └── client.bun.test.ts ├── browser │ ├── browser-test.html │ ├── browser-test.ts │ └── client.browser.test.ts ├── node │ └── client.node.test.ts ├── deno │ └── client.deno.test.ts ├── helpers.ts ├── server.ts └── tests.ts ├── .releaserc.json ├── tsconfig.json ├── tsconfig.dist.json ├── .editorconfig ├── src ├── constants.ts ├── abstractions.ts ├── default.ts ├── node.ts ├── types.ts └── client.ts ├── tsconfig.settings.json ├── .gitignore ├── package.config.ts ├── LICENSE ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── package.json ├── README.md └── CHANGELOG.md /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /coverage 3 | /demo/dist 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /test/waffletest/index.ts: -------------------------------------------------------------------------------- 1 | export * from './runner.js' 2 | export * from './types.js' 3 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | export const unicodeLines = [ 2 | '🦄 are cool. 🐾 in the snow. Allyson Felix, 🏃🏽‍♀️ 🥇 2012 London!', 3 | 'Espen ♥ Kokos', 4 | ] 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": ["**/__tests__/**"], 5 | "compilerOptions": { 6 | "noEmit": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eventsource-client", 3 | "version": "1.2.0", 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.54.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | ## Comments 111 | 112 | Should you need to read/respond to comments, pass an `onComment` callback: 113 | 114 | ```ts 115 | import {createEventSource} from 'eventsource-client' 116 | 117 | const es = createEventSource({ 118 | url: 'https://my-server.com/sse', 119 | onComment: (comment: string) => { 120 | /* a single, leading space will be trimmed if present */ 121 | /* eg `: hello` and `:hello` will both yield `hello` */ 122 | }, 123 | }) 124 | ``` 125 | 126 | ## Todo 127 | 128 | - [ ] Figure out what to do on broken connection on request body 129 | - [ ] Configurable stalled connection detection (eg no data) 130 | - [ ] Configurable reconnection policy 131 | - [ ] Consider legacy build 132 | 133 | ## License 134 | 135 | MIT © [Espen Hovlandsdal](https://espen.codes/) 136 | -------------------------------------------------------------------------------- /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.2.0](https://github.com/rexxars/eventsource-client/compare/v1.1.5...v1.2.0) (2025-09-19) 9 | 10 | ### Features 11 | 12 | - support `onComment` callback ([8fdfe0b](https://github.com/rexxars/eventsource-client/commit/8fdfe0b2839253da775a8892589dfe5af0623f5a)) 13 | 14 | ## [1.1.5](https://github.com/rexxars/eventsource-client/compare/v1.1.4...v1.1.5) (2025-09-18) 15 | 16 | ### Bug Fixes 17 | 18 | - prevent reconnect if `close()` is called in `onScheduleReconnect` handler ([#19](https://github.com/rexxars/eventsource-client/issues/19)) ([8db946b](https://github.com/rexxars/eventsource-client/commit/8db946bdb8dac12d3eb2cf2c0a5fbc1b94324cc1)) 19 | 20 | ## [1.1.4](https://github.com/rexxars/eventsource-client/compare/v1.1.3...v1.1.4) (2025-07-15) 21 | 22 | ### Bug Fixes 23 | 24 | - check if AbortController is aborted before scheduling reconnect ([#15](https://github.com/rexxars/eventsource-client/issues/15)) ([a8b098a](https://github.com/rexxars/eventsource-client/commit/a8b098aeae612683ec28e5597174b54c8b606e32)) 25 | 26 | ## [1.1.3](https://github.com/rexxars/eventsource-client/compare/v1.1.2...v1.1.3) (2024-10-19) 27 | 28 | ### Bug Fixes 29 | 30 | - upgrade to eventsource-parser v3 ([#5](https://github.com/rexxars/eventsource-client/issues/5)) ([08087f7](https://github.com/rexxars/eventsource-client/commit/08087f79d0e12523a8434ff9da5533dd1d6b75bf)) 31 | 32 | ## [1.1.2](https://github.com/rexxars/eventsource-client/compare/v1.1.1...v1.1.2) (2024-08-05) 33 | 34 | ### Bug Fixes 35 | 36 | - 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) 37 | 38 | ## [1.1.1](https://github.com/rexxars/eventsource-client/compare/v1.1.0...v1.1.1) (2024-05-06) 39 | 40 | ### Bug Fixes 41 | 42 | - stray reconnect after close ([3b13da7](https://github.com/rexxars/eventsource-client/commit/3b13da756d4a82b34b3e36651025989db3cf5ae8)), closes [#2](https://github.com/rexxars/eventsource-client/issues/2) 43 | 44 | ## [1.1.0](https://github.com/rexxars/eventsource-client/compare/v1.0.0...v1.1.0) (2024-04-29) 45 | 46 | ### Features 47 | 48 | - allow specifying only URL instead of options object ([d9b0614](https://github.com/rexxars/eventsource-client/commit/d9b061443b983fc0c38c67adce5718d095fa2a39)) 49 | - support environments without TextDecoderStream support ([e97538f](https://github.com/rexxars/eventsource-client/commit/e97538f57a78867910d7d943ced49902c8e80f62)) 50 | - warn when attempting to iterate syncronously ([c639b09](https://github.com/rexxars/eventsource-client/commit/c639b0962c9b0e71a0534f8ba8278e06c347afc7)) 51 | 52 | ### Bug Fixes 53 | 54 | - specify preferred builds for deno and bun ([b59f3f5](https://github.com/rexxars/eventsource-client/commit/b59f3f50059152c791f597cae8639d1b8f75e2be)) 55 | - upgrade dependencies, sort imports ([8e0c7a1](https://github.com/rexxars/eventsource-client/commit/8e0c7a10f70b361a8550c94024e152f1485348db)) 56 | 57 | ## 1.0.0 (2023-11-14) 58 | 59 | ### ⚠ BREAKING CHANGES 60 | 61 | - require node 18 or higher 62 | 63 | ### Features 64 | 65 | - `onScheduleReconnect` event ([c2ad6fc](https://github.com/rexxars/eventsource-client/commit/c2ad6fcfbb8975790a1717990a5561bf3e2f9032)) 66 | - close connection when receiving http 204 ([5015171](https://github.com/rexxars/eventsource-client/commit/5015171116026d83300b3a814541c4e52833af4c)) 67 | - drop unnecessary environment abstractions ([f7d4fe5](https://github.com/rexxars/eventsource-client/commit/f7d4fe5532d37d9d6893aa193eb60082d86c44c3)) 68 | - initial commit ([e85503a](https://github.com/rexxars/eventsource-client/commit/e85503a56d499ddc4a3a34f12723a88b3a4045df)) 69 | - require node 18 or higher ([0186b45](https://github.com/rexxars/eventsource-client/commit/0186b458e8dc0969cb42243c4adfc61b1851b3b8)) 70 | - support AsyncIterator pattern ([264f9c3](https://github.com/rexxars/eventsource-client/commit/264f9c335fbdc07135ec6d85923ba3a2bd2d5705)) 71 | - trigger `onConnect()` ([d2293d7](https://github.com/rexxars/eventsource-client/commit/d2293d73538de55ee3cddebbd8740837832dd3ec)) 72 | 73 | ### Bug Fixes 74 | 75 | - esm/commonjs/web build ([9782a97](https://github.com/rexxars/eventsource-client/commit/9782a978c4b22f72d656f63479552e78dbbf7c89)) 76 | - move response body check after 204 check ([c196c5c](https://github.com/rexxars/eventsource-client/commit/c196c5ce9cfc7a4ef9ddcb49078700d0e8350d54)) 77 | - reset parser on disconnect/reconnect ([1534e03](https://github.com/rexxars/eventsource-client/commit/1534e030d72f2cba642084d92dbbc2f6176da5dd)) 78 | - reset parser on start of stream ([f4c1487](https://github.com/rexxars/eventsource-client/commit/f4c148756bcf9b5de5f9a0d5f512f25b4baf1b86)) 79 | - schedule a reconnect on network failure ([c00e0ca](https://github.com/rexxars/eventsource-client/commit/c00e0cae028b7572bd4ddf96c5763bde588ba976)) 80 | - set readyState to OPEN when connected ([06d448d](https://github.com/rexxars/eventsource-client/commit/06d448d424224a573423b214222c707766d95a64)) 81 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | /** Callback that fires each time a comment is received. One leading space is trimmed 62 | * if present. */ 63 | onComment?: (comment: string) => void 64 | 65 | /** 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. */ 66 | initialLastEventId?: string 67 | 68 | /** Fetch implementation to use for performing requests. Defaults to `globalThis.fetch`. Throws if no implementation can be found. */ 69 | fetch?: FetchLike 70 | 71 | // --- request-related follow --- // 72 | 73 | /** An object literal to set request's headers. */ 74 | headers?: Record 75 | 76 | /** A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode. */ 77 | mode?: 'cors' | 'no-cors' | 'same-origin' 78 | 79 | /** 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. */ 80 | credentials?: 'include' | 'omit' | 'same-origin' 81 | 82 | /** A BodyInit object or null to set request's body. */ 83 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 84 | body?: any 85 | 86 | /** A string to set request's method. */ 87 | method?: string 88 | 89 | /** 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. */ 90 | redirect?: 'error' | 'follow' 91 | 92 | /** A string whose value is a same-origin URL, "about:client", or the empty string, to set request's referrer. */ 93 | referrer?: string 94 | 95 | /** A referrer policy to set request's referrerPolicy. */ 96 | referrerPolicy?: ReferrerPolicy 97 | } 98 | 99 | /** 100 | * Stripped down version of `fetch()`, only defining the parts we care about. 101 | * This ensures it should work with "most" fetch implementations. 102 | * 103 | * @public 104 | */ 105 | export type FetchLike = (url: string | URL, init?: FetchLikeInit) => Promise 106 | 107 | /** 108 | * Stripped down version of `RequestInit`, only defining the parts we care about. 109 | * 110 | * @public 111 | */ 112 | export interface FetchLikeInit { 113 | /** A string to set request's method. */ 114 | method?: string 115 | 116 | /** An AbortSignal to set request's signal. Typed as `any` because of polyfill inconsistencies. */ 117 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 118 | signal?: {aborted: boolean} | any 119 | 120 | /** A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ 121 | headers?: Record 122 | 123 | /** A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode. */ 124 | mode?: 'cors' | 'no-cors' | 'same-origin' 125 | 126 | /** 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. */ 127 | credentials?: 'include' | 'omit' | 'same-origin' 128 | 129 | /** Controls how the request is cached. */ 130 | cache?: 'no-store' 131 | 132 | /** Request body. */ 133 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 134 | body?: any 135 | 136 | /** 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. */ 137 | redirect?: 'error' | 'follow' 138 | 139 | /** A string whose value is a same-origin URL, "about:client", or the empty string, to set request's referrer. */ 140 | referrer?: string 141 | 142 | /** A referrer policy to set request's referrerPolicy. */ 143 | referrerPolicy?: ReferrerPolicy 144 | } 145 | 146 | /** 147 | * Minimal version of the `Response` type returned by `fetch()`. 148 | * 149 | * @public 150 | */ 151 | export interface FetchLikeResponse { 152 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 153 | readonly body: NodeJS.ReadableStream | NodeWebReadableStream | Response['body'] | null 154 | readonly url: string 155 | readonly status: number 156 | readonly redirected: boolean 157 | } 158 | 159 | /** 160 | * Re-export of `EventSourceMessage` from `eventsource-parser`. 161 | */ 162 | export {EventSourceMessage} 163 | -------------------------------------------------------------------------------- /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 { 40 | onMessage, 41 | onComment = noop, 42 | onConnect = noop, 43 | onDisconnect = noop, 44 | onScheduleReconnect = noop, 45 | } = options 46 | const {fetch, url, initialLastEventId} = validate(options) 47 | const requestHeaders = {...options.headers} // Prevent post-creation mutations to headers 48 | 49 | const onCloseSubscribers: (() => void)[] = [] 50 | const subscribers: ((event: EventSourceMessage) => void)[] = onMessage ? [onMessage] : [] 51 | const emit = (event: EventSourceMessage) => subscribers.forEach((fn) => fn(event)) 52 | const parser = createParser({onEvent, onRetry, onComment}) 53 | 54 | // Client state 55 | let request: Promise | null 56 | let currentUrl = url.toString() 57 | let controller = new AbortController() 58 | let lastEventId = initialLastEventId 59 | let reconnectMs = 2000 60 | let reconnectTimer: ReturnType | undefined 61 | let readyState: ReadyState = CLOSED 62 | 63 | // Let's go! 64 | connect() 65 | 66 | return { 67 | close, 68 | connect, 69 | [Symbol.iterator]: () => { 70 | throw new Error( 71 | 'EventSource does not support synchronous iteration. Use `for await` instead.', 72 | ) 73 | }, 74 | [Symbol.asyncIterator]: getEventIterator, 75 | get lastEventId() { 76 | return lastEventId 77 | }, 78 | get url() { 79 | return currentUrl 80 | }, 81 | get readyState() { 82 | return readyState 83 | }, 84 | } 85 | 86 | function connect() { 87 | if (request) { 88 | return 89 | } 90 | 91 | readyState = CONNECTING 92 | controller = new AbortController() 93 | request = fetch(url, getRequestOptions()) 94 | .then(onFetchResponse) 95 | .catch((err: Error & {type: string}) => { 96 | request = null 97 | 98 | // We expect abort errors when the user manually calls `close()` - ignore those 99 | if (err.name === 'AbortError' || err.type === 'aborted' || controller.signal.aborted) { 100 | return 101 | } 102 | 103 | scheduleReconnect() 104 | }) 105 | } 106 | 107 | function close() { 108 | readyState = CLOSED 109 | controller.abort() 110 | parser.reset() 111 | clearTimeout(reconnectTimer) 112 | onCloseSubscribers.forEach((fn) => fn()) 113 | } 114 | 115 | function getEventIterator(): AsyncGenerator { 116 | const pullQueue: EventSourceAsyncValueResolver[] = [] 117 | const pushQueue: EventSourceMessage[] = [] 118 | 119 | function pullValue() { 120 | return new Promise>((resolve) => { 121 | const value = pushQueue.shift() 122 | if (value) { 123 | resolve({value, done: false}) 124 | } else { 125 | pullQueue.push(resolve) 126 | } 127 | }) 128 | } 129 | 130 | const pushValue = function (value: EventSourceMessage) { 131 | const resolve = pullQueue.shift() 132 | if (resolve) { 133 | resolve({value, done: false}) 134 | } else { 135 | pushQueue.push(value) 136 | } 137 | } 138 | 139 | function unsubscribe() { 140 | subscribers.splice(subscribers.indexOf(pushValue), 1) 141 | while (pullQueue.shift()) {} 142 | while (pushQueue.shift()) {} 143 | } 144 | 145 | function onClose() { 146 | const resolve = pullQueue.shift() 147 | if (!resolve) { 148 | return 149 | } 150 | 151 | resolve({done: true, value: undefined}) 152 | unsubscribe() 153 | } 154 | 155 | onCloseSubscribers.push(onClose) 156 | subscribers.push(pushValue) 157 | 158 | return { 159 | next() { 160 | return readyState === CLOSED ? this.return() : pullValue() 161 | }, 162 | return() { 163 | unsubscribe() 164 | return Promise.resolve({done: true, value: undefined}) 165 | }, 166 | throw(error) { 167 | unsubscribe() 168 | return Promise.reject(error) 169 | }, 170 | [Symbol.asyncIterator]() { 171 | return this 172 | }, 173 | } 174 | } 175 | 176 | function scheduleReconnect() { 177 | onScheduleReconnect({delay: reconnectMs}) 178 | if (controller.signal.aborted) { 179 | return 180 | } 181 | readyState = CONNECTING 182 | reconnectTimer = setTimeout(connect, reconnectMs) 183 | } 184 | 185 | async function onFetchResponse(response: FetchLikeResponse) { 186 | onConnect() 187 | parser.reset() 188 | 189 | const {body, redirected, status} = response 190 | 191 | // HTTP 204 means "close the connection, no more data will be sent" 192 | if (status === 204) { 193 | onDisconnect() 194 | close() 195 | return 196 | } 197 | 198 | if (!body) { 199 | throw new Error('Missing response body') 200 | } 201 | 202 | if (redirected) { 203 | currentUrl = response.url 204 | } 205 | 206 | // Ensure that the response stream is a web stream 207 | // @todo Figure out a way to make this work without casting 208 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 209 | const stream = getStream(body as any) 210 | const decoder = new TextDecoder() 211 | 212 | const reader = stream.getReader() 213 | let open = true 214 | 215 | readyState = OPEN 216 | 217 | do { 218 | const {done, value} = await reader.read() 219 | if (value) { 220 | parser.feed(decoder.decode(value, {stream: !done})) 221 | } 222 | 223 | if (!done) { 224 | continue 225 | } 226 | 227 | open = false 228 | request = null 229 | parser.reset() 230 | 231 | // EventSources never close unless explicitly handled with `.close()`: 232 | // Implementors should send an `done`/`complete`/`disconnect` event and 233 | // explicitly handle it in client code, or send an HTTP 204. 234 | scheduleReconnect() 235 | 236 | // Calling scheduleReconnect() prior to onDisconnect() allows consumers to 237 | // explicitly call .close() before the reconnection is performed. 238 | onDisconnect() 239 | } while (open) 240 | } 241 | 242 | function onEvent(msg: EventSourceMessage) { 243 | if (typeof msg.id === 'string') { 244 | lastEventId = msg.id 245 | } 246 | 247 | emit(msg) 248 | } 249 | 250 | function onRetry(ms: number) { 251 | reconnectMs = ms 252 | } 253 | 254 | function getRequestOptions(): FetchLikeInit { 255 | // @todo allow interception of options, but don't allow overriding signal 256 | const {mode, credentials, body, method, redirect, referrer, referrerPolicy} = options 257 | const lastEvent = lastEventId ? {'Last-Event-ID': lastEventId} : undefined 258 | const headers = {Accept: 'text/event-stream', ...requestHeaders, ...lastEvent} 259 | return { 260 | mode, 261 | credentials, 262 | body, 263 | method, 264 | redirect, 265 | referrer, 266 | referrerPolicy, 267 | headers, 268 | cache: 'no-store', 269 | signal: controller.signal, 270 | } 271 | } 272 | } 273 | 274 | function validate(options: EventSourceOptions): { 275 | fetch: FetchLike 276 | url: string | URL 277 | initialLastEventId: string | undefined 278 | } { 279 | const fetch = options.fetch || globalThis.fetch 280 | if (!isFetchLike(fetch)) { 281 | throw new Error('No fetch implementation provided, and one was not found on the global object.') 282 | } 283 | 284 | if (typeof AbortController !== 'function') { 285 | throw new Error('Missing AbortController implementation') 286 | } 287 | 288 | const {url, initialLastEventId} = options 289 | 290 | if (typeof url !== 'string' && !(url instanceof URL)) { 291 | throw new Error('Invalid URL provided - must be string or URL instance') 292 | } 293 | 294 | if (typeof initialLastEventId !== 'string' && initialLastEventId !== undefined) { 295 | throw new Error('Invalid initialLastEventId provided - must be string or undefined') 296 | } 297 | 298 | return {fetch, url, initialLastEventId} 299 | } 300 | 301 | // This is obviously naive, but hard to probe for full compatibility 302 | function isFetchLike(fetch: FetchLike | typeof globalThis.fetch): fetch is FetchLike { 303 | return typeof fetch === 'function' 304 | } 305 | -------------------------------------------------------------------------------- /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 '/heartbeats': 38 | return writeHeartbeatSeparated(req, res) 39 | case '/end-after-one': 40 | return writeOne(req, res) 41 | case '/slow-connect': 42 | return writeSlowConnect(req, res) 43 | case '/debug': 44 | return writeDebug(req, res) 45 | case '/set-cookie': 46 | return writeCookies(req, res) 47 | case '/authed': 48 | return writeAuthed(req, res) 49 | case '/cors': 50 | return writeCors(req, res) 51 | case '/stalled': 52 | return writeStalledConnection(req, res) 53 | case '/trickle': 54 | return writeTricklingConnection(req, res) 55 | case '/unicode': 56 | return writeUnicode(req, res) 57 | 58 | // Browser test endpoints (HTML/JS) 59 | case '/browser-test': 60 | return writeBrowserTestPage(req, res) 61 | case '/browser-test.js': 62 | return writeBrowserTestScript(req, res) 63 | 64 | // Fallback, eg 404 65 | default: 66 | return writeFallback(req, res) 67 | } 68 | } 69 | 70 | function writeDefault(_req: IncomingMessage, res: ServerResponse) { 71 | res.writeHead(200, { 72 | 'Content-Type': 'text/event-stream', 73 | 'Cache-Control': 'no-cache', 74 | Connection: 'keep-alive', 75 | }) 76 | 77 | tryWrite( 78 | res, 79 | formatEvent({ 80 | event: 'welcome', 81 | data: 'Hello, world!', 82 | }), 83 | ) 84 | 85 | // For some reason, Bun seems to need this to flush 86 | tryWrite(res, ':\n') 87 | } 88 | 89 | /** 90 | * Writes 3 messages, then closes connection. 91 | * Picks up event ID and continues from there. 92 | */ 93 | async function writeCounter(req: IncomingMessage, res: ServerResponse) { 94 | res.writeHead(200, { 95 | 'Content-Type': 'text/event-stream', 96 | 'Cache-Control': 'no-cache', 97 | Connection: 'keep-alive', 98 | }) 99 | 100 | tryWrite(res, formatEvent({retry: 50, data: ''})) 101 | 102 | let counter = parseInt(getLastEventId(req) || '0', 10) 103 | for (let i = 0; i < 3; i++) { 104 | counter++ 105 | tryWrite( 106 | res, 107 | formatEvent({ 108 | event: 'counter', 109 | data: `Counter is at ${counter}`, 110 | id: `${counter}`, 111 | }), 112 | ) 113 | await delay(25) 114 | } 115 | 116 | res.end() 117 | } 118 | 119 | async function writeHeartbeatSeparated(_req: IncomingMessage, res: ServerResponse) { 120 | res.writeHead(200, { 121 | 'Content-Type': 'text/event-stream', 122 | 'Cache-Control': 'no-cache', 123 | Connection: 'keep-alive', 124 | }) 125 | 126 | for (let i = 0; i < 10; i++) { 127 | tryWrite(res, formatEvent({event: 'ping', data: `Ping ${i + 1} of 10`})) 128 | tryWrite(res, formatComment(i % 2 === 0 ? '❤️' : ' 💚')) 129 | await delay(5) 130 | } 131 | 132 | res.end() 133 | } 134 | 135 | async function writeIdentifiedListeners(req: IncomingMessage, res: ServerResponse) { 136 | const url = new URL(req.url || '/', 'http://localhost') 137 | const clientId = url.searchParams.get('client-id') 138 | if (!clientId) { 139 | res.writeHead(400, { 140 | 'Content-Type': 'application/json', 141 | 'Cache-Control': 'no-cache', 142 | Connection: 'keep-alive', 143 | }) 144 | tryWrite(res, JSON.stringify({error: 'Missing "id" or "client-id" query parameter'})) 145 | res.end() 146 | return 147 | } 148 | 149 | // SSE endpoint, tracks how many listeners have connected with a given client ID 150 | if ((req.headers.accept || '').includes('text/event-stream')) { 151 | connectCounts.set(clientId, (connectCounts.get(clientId) || 0) + 1) 152 | 153 | res.writeHead(200, { 154 | 'Content-Type': 'text/event-stream', 155 | 'Cache-Control': 'no-cache', 156 | Connection: 'keep-alive', 157 | }) 158 | tryWrite(res, formatEvent({data: '', retry: 250})) 159 | tryWrite(res, formatEvent({data: `${connectCounts.get(clientId)}`})) 160 | 161 | if (url.searchParams.get('auto-close')) { 162 | res.end() 163 | } 164 | 165 | return 166 | } 167 | 168 | // JSON endpoint, returns the number of connects for a given client ID 169 | res.writeHead(200, { 170 | 'Content-Type': 'application/json', 171 | 'Cache-Control': 'no-cache', 172 | }) 173 | tryWrite(res, JSON.stringify({clientIdConnects: connectCounts.get(clientId) ?? 0})) 174 | res.end() 175 | } 176 | 177 | function writeOne(req: IncomingMessage, res: ServerResponse) { 178 | const last = getLastEventId(req) 179 | res.writeHead(last ? 204 : 200, { 180 | 'Content-Type': 'text/event-stream', 181 | 'Cache-Control': 'no-cache', 182 | Connection: 'keep-alive', 183 | }) 184 | 185 | if (!last) { 186 | tryWrite(res, formatEvent({retry: 50, data: ''})) 187 | tryWrite( 188 | res, 189 | formatEvent({ 190 | event: 'progress', 191 | data: '100%', 192 | id: 'prct-100', 193 | }), 194 | ) 195 | } 196 | 197 | res.end() 198 | } 199 | 200 | async function writeSlowConnect(_req: IncomingMessage, res: ServerResponse) { 201 | await delay(200) 202 | 203 | res.writeHead(200, { 204 | 'Content-Type': 'text/event-stream', 205 | 'Cache-Control': 'no-cache', 206 | Connection: 'keep-alive', 207 | }) 208 | 209 | tryWrite( 210 | res, 211 | formatEvent({ 212 | event: 'welcome', 213 | data: 'That was a slow connect, was it not?', 214 | }), 215 | ) 216 | 217 | res.end() 218 | } 219 | 220 | async function writeStalledConnection(req: IncomingMessage, res: ServerResponse) { 221 | res.writeHead(200, { 222 | 'Content-Type': 'text/event-stream', 223 | 'Cache-Control': 'no-cache', 224 | Connection: 'keep-alive', 225 | }) 226 | 227 | const lastId = getLastEventId(req) 228 | const reconnected = lastId === '1' 229 | 230 | tryWrite( 231 | res, 232 | formatEvent({ 233 | id: reconnected ? '2' : '1', 234 | event: 'welcome', 235 | data: reconnected 236 | ? 'Welcome back' 237 | : 'Connected - now I will sleep for "too long" without sending data', 238 | }), 239 | ) 240 | 241 | if (reconnected) { 242 | await delay(250) 243 | tryWrite( 244 | res, 245 | formatEvent({ 246 | id: '3', 247 | event: 'success', 248 | data: 'You waited long enough!', 249 | }), 250 | ) 251 | 252 | res.end() 253 | } 254 | 255 | // Intentionally not closing on first-connect that never sends data after welcome 256 | } 257 | 258 | async function writeUnicode(_req: IncomingMessage, res: ServerResponse) { 259 | res.writeHead(200, { 260 | 'Content-Type': 'text/event-stream', 261 | 'Cache-Control': 'no-cache', 262 | Connection: 'keep-alive', 263 | }) 264 | 265 | tryWrite( 266 | res, 267 | formatEvent({ 268 | event: 'welcome', 269 | data: 'Connected - I will now send some chonks (cuter chunks) with unicode', 270 | }), 271 | ) 272 | 273 | tryWrite( 274 | res, 275 | formatEvent({ 276 | event: 'unicode', 277 | data: unicodeLines[0], 278 | }), 279 | ) 280 | 281 | await delay(100) 282 | 283 | // Start of a valid SSE chunk 284 | tryWrite(res, 'event: unicode\ndata: ') 285 | 286 | // Write "Espen ❤️ Kokos" in two halves: 287 | // 1st: Espen � [..., 226, 153] 288 | // 2st: � Kokos [165, 32, ...] 289 | tryWrite(res, new Uint8Array([69, 115, 112, 101, 110, 32, 226, 153])) 290 | 291 | // Give time to the client to process the first half 292 | await delay(1000) 293 | 294 | tryWrite(res, new Uint8Array([165, 32, 75, 111, 107, 111, 115])) 295 | 296 | // Closing end of packet 297 | tryWrite(res, '\n\n\n\n') 298 | 299 | tryWrite(res, formatEvent({event: 'disconnect', data: 'Thanks for listening'})) 300 | res.end() 301 | } 302 | 303 | async function writeTricklingConnection(_req: IncomingMessage, res: ServerResponse) { 304 | res.writeHead(200, { 305 | 'Content-Type': 'text/event-stream', 306 | 'Cache-Control': 'no-cache', 307 | Connection: 'keep-alive', 308 | }) 309 | 310 | tryWrite( 311 | res, 312 | formatEvent({ 313 | event: 'welcome', 314 | data: 'Connected - now I will keep sending "comments" for a while', 315 | }), 316 | ) 317 | 318 | for (let i = 0; i < 60; i++) { 319 | await delay(500) 320 | tryWrite(res, ':\n') 321 | } 322 | 323 | tryWrite(res, formatEvent({event: 'disconnect', data: 'Thanks for listening'})) 324 | res.end() 325 | } 326 | 327 | function writeCors(req: IncomingMessage, res: ServerResponse) { 328 | const origin = req.headers.origin 329 | const cors = origin ? {'Access-Control-Allow-Origin': origin} : {} 330 | 331 | res.writeHead(200, { 332 | 'Content-Type': 'text/event-stream', 333 | 'Cache-Control': 'no-cache', 334 | Connection: 'keep-alive', 335 | ...cors, 336 | }) 337 | 338 | tryWrite( 339 | res, 340 | formatEvent({ 341 | event: 'origin', 342 | data: origin || '', 343 | }), 344 | ) 345 | 346 | res.end() 347 | } 348 | 349 | async function writeDebug(req: IncomingMessage, res: ServerResponse) { 350 | const hash = new Promise((resolve, reject) => { 351 | const bodyHash = createHash('sha256') 352 | req.on('error', reject) 353 | req.on('data', (chunk) => bodyHash.update(chunk)) 354 | req.on('end', () => resolve(bodyHash.digest('hex'))) 355 | }) 356 | 357 | let bodyHash: string 358 | try { 359 | bodyHash = await hash 360 | } catch (err: unknown) { 361 | res.writeHead(500, 'Internal Server Error') 362 | tryWrite(res, err instanceof Error ? err.message : `${err}`) 363 | res.end() 364 | return 365 | } 366 | 367 | res.writeHead(200, { 368 | 'Content-Type': 'text/event-stream', 369 | 'Cache-Control': 'no-cache', 370 | Connection: 'keep-alive', 371 | }) 372 | 373 | tryWrite( 374 | res, 375 | formatEvent({ 376 | event: 'debug', 377 | data: JSON.stringify({ 378 | method: req.method, 379 | headers: req.headers, 380 | bodyHash, 381 | }), 382 | }), 383 | ) 384 | 385 | res.end() 386 | } 387 | 388 | /** 389 | * Ideally we'd just set these in the storage state, but Playwright does not seem to 390 | * be able to for some obscure reason - is not set if passed in page context or through 391 | * `addCookies()`. 392 | */ 393 | function writeCookies(_req: IncomingMessage, res: ServerResponse) { 394 | res.writeHead(200, { 395 | 'Content-Type': 'application/json', 396 | 'Cache-Control': 'no-cache', 397 | 'Set-Cookie': 'someSession=someValue; Path=/authed; HttpOnly; SameSite=Lax;', 398 | Connection: 'keep-alive', 399 | }) 400 | tryWrite(res, JSON.stringify({cookiesWritten: true})) 401 | res.end() 402 | } 403 | 404 | function writeAuthed(req: IncomingMessage, res: ServerResponse) { 405 | res.writeHead(200, { 406 | 'Content-Type': 'text/event-stream', 407 | 'Cache-Control': 'no-cache', 408 | Connection: 'keep-alive', 409 | }) 410 | 411 | tryWrite( 412 | res, 413 | formatEvent({ 414 | event: 'authInfo', 415 | data: JSON.stringify({cookies: req.headers.cookie || ''}), 416 | }), 417 | ) 418 | 419 | res.end() 420 | } 421 | 422 | function writeFallback(_req: IncomingMessage, res: ServerResponse) { 423 | res.writeHead(404, { 424 | 'Content-Type': 'text/plain', 425 | 'Cache-Control': 'no-cache', 426 | Connection: 'close', 427 | }) 428 | 429 | tryWrite(res, 'File not found') 430 | res.end() 431 | } 432 | 433 | function writeBrowserTestPage(_req: IncomingMessage, res: ServerResponse) { 434 | res.writeHead(200, { 435 | 'Content-Type': 'text/html; charset=utf-8', 436 | 'Cache-Control': 'no-cache', 437 | Connection: 'close', 438 | }) 439 | 440 | createReadStream(resolvePath(__dirname, './browser/browser-test.html')).pipe(res) 441 | } 442 | 443 | async function writeBrowserTestScript(_req: IncomingMessage, res: ServerResponse) { 444 | res.writeHead(200, { 445 | 'Content-Type': 'text/javascript; charset=utf-8', 446 | 'Cache-Control': 'no-cache', 447 | Connection: 'close', 448 | }) 449 | 450 | const build = await esbuild.build({ 451 | bundle: true, 452 | target: ['chrome71', 'edge79', 'firefox105', 'safari14.1'], 453 | entryPoints: [resolvePath(__dirname, './browser/browser-test.ts')], 454 | sourcemap: 'inline', 455 | write: false, 456 | outdir: 'out', 457 | }) 458 | 459 | tryWrite(res, build.outputFiles.map((file) => file.text).join('\n\n')) 460 | res.end() 461 | } 462 | 463 | function delay(ms: number): Promise { 464 | return new Promise((resolve) => setTimeout(resolve, ms)) 465 | } 466 | 467 | function getLastEventId(req: IncomingMessage): string | undefined { 468 | const lastId = req.headers['last-event-id'] 469 | return typeof lastId === 'string' ? lastId : undefined 470 | } 471 | 472 | export interface SseMessage { 473 | event?: string 474 | retry?: number 475 | id?: string 476 | data: string 477 | } 478 | 479 | export function formatEvent(message: SseMessage | string): string { 480 | const msg = typeof message === 'string' ? {data: message} : message 481 | 482 | let output = '' 483 | if (msg.event) { 484 | output += `event: ${msg.event}\n` 485 | } 486 | 487 | if (msg.retry) { 488 | output += `retry: ${msg.retry}\n` 489 | } 490 | 491 | if (typeof msg.id === 'string' || typeof msg.id === 'number') { 492 | output += `id: ${msg.id}\n` 493 | } 494 | 495 | output += encodeData(msg.data || '') 496 | output += '\n\n' 497 | 498 | return output 499 | } 500 | 501 | export function formatComment(comment: string): string { 502 | return `:${comment}\n\n` 503 | } 504 | 505 | export function encodeData(text: string): string { 506 | if (!text) { 507 | return '' 508 | } 509 | 510 | const data = String(text).replace(/(\r\n|\r|\n)/g, '\n') 511 | const lines = data.split(/\n/) 512 | 513 | let line = '' 514 | let output = '' 515 | 516 | for (let i = 0, l = lines.length; i < l; ++i) { 517 | line = lines[i] 518 | 519 | output += `data: ${line}` 520 | output += i + 1 === l ? '\n\n' : '\n' 521 | } 522 | 523 | return output 524 | } 525 | 526 | function tryWrite(res: ServerResponse, chunk: string | Uint8Array) { 527 | try { 528 | res.write(chunk) 529 | } catch (err: unknown) { 530 | // Deno randomly throws on write after close, it seems 531 | if (err instanceof TypeError && err.message.includes('cannot close or enqueue')) { 532 | return 533 | } 534 | 535 | throw err 536 | } 537 | } 538 | -------------------------------------------------------------------------------- /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 skip over comments (iterator)', async () => { 87 | const es = createEventSource({ 88 | url: new URL(`${baseUrl}:${port}/heartbeats`), 89 | fetch, 90 | }) 91 | 92 | let counter = 0 93 | for await (const event of es) { 94 | expect(event.event).toBe('ping') 95 | expect(event.data).toBe(`Ping ${++counter} of 10`) 96 | 97 | if (counter === 10) { 98 | break 99 | } 100 | } 101 | 102 | await deferClose(es) 103 | }) 104 | 105 | test('will skip over comments (onMessage)', async () => { 106 | const messageCounter = getCallCounter() 107 | const onDisconnect = getCallCounter() 108 | let counter = 0 109 | const es = createEventSource({ 110 | url: new URL(`${baseUrl}:${port}/heartbeats`), 111 | fetch, 112 | onMessage: (event) => { 113 | messageCounter() 114 | expect(event.event).toBe('ping') 115 | expect(event.data).toBe(`Ping ${++counter} of 10`) 116 | if (counter === 10) es.close() 117 | }, 118 | onDisconnect, 119 | }) 120 | 121 | await messageCounter.waitForCallCount(10) 122 | }) 123 | 124 | test('will call `onComment` with comments, if provided', async () => { 125 | const onComment = getCallCounter() 126 | const es = createEventSource({ 127 | url: new URL(`${baseUrl}:${port}/heartbeats`), 128 | fetch, 129 | onComment, 130 | }) 131 | 132 | await onComment.waitForCallCount(5) 133 | expect(onComment.firstCall.lastArg).toBe('❤️') 134 | expect(onComment.secondCall.lastArg).toBe('💚') 135 | 136 | await deferClose(es) 137 | }) 138 | 139 | test('will reconnect with last received message id if server disconnects', async () => { 140 | const onMessage = getCallCounter() 141 | const onDisconnect = getCallCounter() 142 | const url = `${baseUrl}:${port}/counter` 143 | const es = createEventSource({ 144 | url, 145 | fetch, 146 | onMessage, 147 | onDisconnect, 148 | }) 149 | 150 | // While still receiving messages (we receive 3 at a time before it disconnects) 151 | await onMessage.waitForCallCount(1) 152 | expect(es.readyState, 'readyState').toBe(OPEN) // Open (connected) 153 | 154 | // While waiting for reconnect (after 3 messages it will disconnect and reconnect) 155 | await onDisconnect.waitForCallCount(1) 156 | expect(es.readyState, 'readyState').toBe(CONNECTING) // Connecting (reconnecting) 157 | expect(onMessage.callCount).toBe(3) 158 | 159 | // Will reconnect infinitely, stop at 8 messages 160 | await onMessage.waitForCallCount(8) 161 | 162 | expect(es.url).toBe(url) 163 | expect(onMessage.lastCall.lastArg).toMatchObject({ 164 | data: 'Counter is at 8', 165 | event: 'counter', 166 | id: '8', 167 | }) 168 | expect(es.lastEventId).toBe('8') 169 | expect(onMessage.callCount).toBe(8) 170 | 171 | await deferClose(es) 172 | }) 173 | 174 | test('will not reconnect after explicit `close()`', async () => { 175 | const request = fetch || globalThis.fetch 176 | const onMessage = getCallCounter() 177 | const onDisconnect = getCallCounter() 178 | const onScheduleReconnect = getCallCounter() 179 | const clientId = Math.random().toString(36).slice(2) 180 | const url = `${baseUrl}:${port}/identified?client-id=${clientId}` 181 | const es = createEventSource({ 182 | url, 183 | fetch, 184 | onMessage, 185 | onDisconnect, 186 | onScheduleReconnect, 187 | }) 188 | 189 | // Should receive a message containing the number of listeners on the given ID 190 | await onMessage.waitForCallCount(1) 191 | expect(onMessage.lastCall.lastArg).toMatchObject({data: '1'}) 192 | expect(es.readyState, 'readyState').toBe(OPEN) // Open (connected) 193 | 194 | // Explicitly disconnect. Should normally reconnect within ~250ms (server sends retry: 250) 195 | // but we'll close it before that happens 196 | es.close() 197 | expect(es.readyState, 'readyState').toBe(CLOSED) 198 | expect(onMessage.callCount).toBe(1) 199 | expect(onScheduleReconnect.callCount, 'onScheduleReconnect call count').toBe(0) 200 | 201 | // After 500 ms, there should still only be a single connect with this client ID 202 | await new Promise((resolve) => setTimeout(resolve, 500)) 203 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 204 | 205 | // Wait another 500 ms, just to be sure there are no slow reconnects 206 | await new Promise((resolve) => setTimeout(resolve, 500)) 207 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 208 | 209 | expect(onScheduleReconnect.callCount, 'onScheduleReconnect call count').toBe(0) 210 | }) 211 | 212 | test('will not reconnect after explicit `close()` in `onScheduleReconnect`', async () => { 213 | const onMessage = getCallCounter() 214 | const onDisconnect = getCallCounter() 215 | const onScheduleReconnect = getCallCounter(() => es.close()) 216 | 217 | const url = `${baseUrl}:${port}/counter` 218 | const es = createEventSource({ 219 | url, 220 | fetch, 221 | onMessage, 222 | onDisconnect, 223 | onScheduleReconnect, 224 | }) 225 | 226 | // Wait until first batch of messages is received (3 messages before server disconnects) 227 | await onMessage.waitForCallCount(3) 228 | 229 | // Wait until onDisconnect has been called (server closed connection) 230 | await onDisconnect.waitForCallCount(1) 231 | 232 | // Wait until onScheduleReconnect has been called where we call es.close() 233 | await onScheduleReconnect.waitForCallCount(1) 234 | 235 | expect(es.readyState, 'readyState').toBe(CLOSED) 236 | 237 | // Give some time to ensure no reconnects happen 238 | await new Promise((resolve) => setTimeout(resolve, 500)) 239 | 240 | // connect will set readyState to CONNECTING, so if it's CLOSED here, 241 | // it means no reconnect was attempted 242 | expect(es.readyState, 'readyState').toBe(CLOSED) 243 | }) 244 | 245 | test('will not reconnect after explicit `close()` in `onDisconnect`', async () => { 246 | const request = fetch || globalThis.fetch 247 | const onMessage = getCallCounter() 248 | const onDisconnect = getCallCounter(() => es.close()) 249 | const onScheduleReconnect = getCallCounter() 250 | const clientId = Math.random().toString(36).slice(2) 251 | const url = `${baseUrl}:${port}/identified?client-id=${clientId}&auto-close=true` 252 | const es = createEventSource({ 253 | url, 254 | fetch, 255 | onMessage, 256 | onDisconnect, 257 | onScheduleReconnect, 258 | }) 259 | 260 | // Should receive a message containing the number of listeners on the given ID 261 | await onMessage.waitForCallCount(1) 262 | expect(onMessage.lastCall.lastArg, 'onMessage `event` argument').toMatchObject({data: '1'}) 263 | expect(es.readyState, 'readyState').toBe(OPEN) // Open (connected) 264 | 265 | await onDisconnect.waitForCallCount(1) 266 | expect(es.readyState, 'readyState').toBe(CLOSED) // `onDisconnect` called first, closes ES. 267 | 268 | // After 50 ms, we should still be in closing state - no reconnecting 269 | expect(es.readyState, 'readyState').toBe(CLOSED) 270 | 271 | // After 500 ms, there should be no clients connected to the given ID 272 | await new Promise((resolve) => setTimeout(resolve, 500)) 273 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 274 | expect(es.readyState, 'readyState').toBe(CLOSED) 275 | 276 | // Wait another 500 ms, just to be sure there are no slow reconnects 277 | await new Promise((resolve) => setTimeout(resolve, 500)) 278 | expect(await request(url).then((res) => res.json())).toMatchObject({clientIdConnects: 1}) 279 | expect(es.readyState, 'readyState').toBe(CLOSED) 280 | }) 281 | 282 | test('can use async iterator, reconnects transparently', async () => { 283 | const onDisconnect = getCallCounter() 284 | const url = `${baseUrl}:${port}/counter` 285 | const es = createEventSource({ 286 | url, 287 | fetch, 288 | onDisconnect, 289 | }) 290 | 291 | let numMessages = 1 292 | for await (const event of es) { 293 | expect(event.event).toBe('counter') 294 | expect(event.data).toBe(`Counter is at ${numMessages}`) 295 | expect(event.id).toBe(`${numMessages}`) 296 | 297 | // Will reconnect infinitely, stop at 11 messages 298 | if (++numMessages === 11) { 299 | break 300 | } 301 | } 302 | 303 | expect(onDisconnect.callCount).toBe(3) 304 | await deferClose(es) 305 | }) 306 | 307 | test('async iterator breaks out of loop without error when calling `close()`', async () => { 308 | const url = `${baseUrl}:${port}/counter` 309 | const es = createEventSource({ 310 | url, 311 | fetch, 312 | }) 313 | 314 | let hasSeenMessage = false 315 | for await (const {event} of es) { 316 | hasSeenMessage = true 317 | expect(event).toBe('counter') 318 | es.close() 319 | } 320 | 321 | expect(hasSeenMessage).toBe(true) 322 | }) 323 | 324 | test('will have correct ready state throughout lifecycle', async () => { 325 | const onMessage = getCallCounter() 326 | const onConnect = getCallCounter() 327 | const onDisconnect = getCallCounter() 328 | const url = `${baseUrl}:${port}/slow-connect` 329 | const es = createEventSource({ 330 | url, 331 | fetch, 332 | onMessage, 333 | onConnect, 334 | onDisconnect, 335 | }) 336 | 337 | // Connecting 338 | expect(es.readyState, 'readyState').toBe(CONNECTING) 339 | 340 | // Connected 341 | await onConnect.waitForCallCount(1) 342 | expect(es.readyState, 'readyState').toBe(OPEN) 343 | 344 | // Disconnected 345 | await onDisconnect.waitForCallCount(1) 346 | expect(es.readyState, 'readyState').toBe(CONNECTING) 347 | 348 | // Closed 349 | await es.close() 350 | expect(es.readyState, 'readyState').toBe(CLOSED) 351 | }) 352 | 353 | test('calling connect while already connected does nothing', async () => { 354 | const onMessage = getCallCounter() 355 | const es = createEventSource({ 356 | url: `${baseUrl}:${port}/counter`, 357 | fetch, 358 | onMessage, 359 | }) 360 | 361 | es.connect() 362 | await onMessage.waitForCallCount(1) 363 | es.connect() 364 | await onMessage.waitForCallCount(2) 365 | es.connect() 366 | 367 | await deferClose(es) 368 | }) 369 | 370 | test('can pass an initial last received event id', async () => { 371 | const onMessage = getCallCounter() 372 | const es = createEventSource({ 373 | url: `${baseUrl}:${port}/counter`, 374 | fetch, 375 | onMessage, 376 | initialLastEventId: '50000', 377 | }) 378 | 379 | await onMessage.waitForCallCount(4) 380 | 381 | expect(es.lastEventId).toBe('50004') 382 | expect(onMessage.callCount).toBe(4) 383 | expect(onMessage.firstCall.lastArg).toMatchObject({ 384 | data: 'Counter is at 50001', 385 | event: 'counter', 386 | id: '50001', 387 | }) 388 | expect(onMessage.lastCall.lastArg).toMatchObject({ 389 | data: 'Counter is at 50004', 390 | event: 'counter', 391 | id: '50004', 392 | }) 393 | 394 | await deferClose(es) 395 | }) 396 | 397 | test('will close stream on HTTP 204', async () => { 398 | const onMessage = getCallCounter() 399 | const onDisconnect = getCallCounter() 400 | const es = createEventSource({ 401 | url: `${baseUrl}:${port}/end-after-one`, 402 | fetch, 403 | onMessage, 404 | onDisconnect, 405 | }) 406 | 407 | // First disconnect, then reconnect and given a 204 408 | await onDisconnect.waitForCallCount(2) 409 | 410 | // Only the first connect should have given a message 411 | await onMessage.waitForCallCount(1) 412 | 413 | expect(es.lastEventId).toBe('prct-100') 414 | expect(es.readyState, 'readyState').toBe(CLOSED) // CLOSED 415 | expect(onMessage.callCount).toBe(1) 416 | expect(onMessage.lastCall.lastArg).toMatchObject({ 417 | data: '100%', 418 | event: 'progress', 419 | id: 'prct-100', 420 | }) 421 | 422 | await deferClose(es) 423 | }) 424 | 425 | test('can send plain-text string data as POST request with headers', async () => { 426 | const onMessage = getCallCounter() 427 | const es = createEventSource({ 428 | url: new URL(`${baseUrl}:${port}/debug`), 429 | method: 'POST', 430 | body: 'Blåbærsyltetøy, rømme og brunost på vaffel', 431 | headers: {'Content-Type': 'text/norwegian-plain; charset=utf-8'}, 432 | fetch, 433 | onMessage, 434 | }) 435 | 436 | await onMessage.waitForCallCount(1) 437 | expect(onMessage.callCount).toBe(1) 438 | 439 | const lastMessage = onMessage.lastCall.lastArg 440 | expect(lastMessage.event).toBe('debug') 441 | 442 | const data = JSON.parse(lastMessage.data) 443 | expect(data.method).toBe('POST') 444 | expect(data.bodyHash).toBe('5f4e50479bfc5ccdb6f865cc3341245dde9e81aa2f36b0c80e3fcbcfbeccaeda') 445 | expect(data.headers).toMatchObject({'content-type': 'text/norwegian-plain; charset=utf-8'}) 446 | 447 | await deferClose(es) 448 | }) 449 | 450 | test('throws if `url` is not a string/url', () => { 451 | const onMessage = getCallCounter() 452 | expect(() => { 453 | const es = createEventSource({ 454 | // @ts-expect-error Should be a string 455 | url: 123, 456 | fetch, 457 | onMessage, 458 | }) 459 | 460 | es.close() 461 | }).toThrowError(/Invalid URL provided/) 462 | 463 | expect(onMessage.callCount).toBe(0) 464 | }) 465 | 466 | test('throws if `initialLastEventId` is not a string', () => { 467 | const onMessage = getCallCounter() 468 | expect(() => { 469 | const es = createEventSource({ 470 | url: `${baseUrl}:${port}/`, 471 | fetch, 472 | onMessage, 473 | // @ts-expect-error Should be a string 474 | initialLastEventId: 123, 475 | }) 476 | 477 | es.close() 478 | }).toThrowError(/Invalid initialLastEventId provided - must be string or undefined/) 479 | 480 | expect(onMessage.callCount).toBe(0) 481 | }) 482 | 483 | test('can request cross-origin', async () => { 484 | const hostUrl = new URL(`${baseUrl}:${port}/cors`) 485 | const url = new URL(hostUrl) 486 | url.hostname = url.hostname === 'localhost' ? '127.0.0.1' : 'localhost' 487 | 488 | const onMessage = getCallCounter() 489 | const es = createEventSource({ 490 | url, 491 | fetch, 492 | onMessage, 493 | }) 494 | 495 | await onMessage.waitForCallCount(1) 496 | expect(onMessage.callCount).toBe(1) 497 | 498 | const lastMessage = onMessage.lastCall.lastArg 499 | expect(lastMessage.event).toBe('origin') 500 | 501 | if (environment === 'browser') { 502 | expect(lastMessage.data).toBe(hostUrl.origin) 503 | } else { 504 | expect(lastMessage.data).toBe('') 505 | } 506 | 507 | await deferClose(es) 508 | }) 509 | 510 | browserTest( 511 | 'can use the `credentials` option to control cookies being sent/not sent', 512 | async () => { 513 | // Ideally this would be done through playwright, but can't get it working, 514 | // so let's just fire off a request that sets the cookies for now 515 | const {cookiesWritten} = await globalThis.fetch('/set-cookie').then((res) => res.json()) 516 | expect(cookiesWritten).toBe(true) 517 | 518 | let es = createEventSource({url: '/authed', fetch, credentials: 'include'}) 519 | for await (const event of es) { 520 | expect(event.event).toBe('authInfo') 521 | expect(JSON.parse(event.data)).toMatchObject({cookies: 'someSession=someValue'}) 522 | break 523 | } 524 | 525 | await deferClose(es) 526 | 527 | es = createEventSource({url: '/authed', fetch, credentials: 'omit'}) 528 | for await (const event of es) { 529 | expect(event.event).toBe('authInfo') 530 | expect(JSON.parse(event.data)).toMatchObject({cookies: ''}) 531 | break 532 | } 533 | }, 534 | ) 535 | 536 | return runner 537 | } 538 | --------------------------------------------------------------------------------