Skip to content

Commit

Permalink
fix: ensure message is set on ErrorEvent on network errors
Browse files Browse the repository at this point in the history
  • Loading branch information
rexxars committed Jan 27, 2025
1 parent 568f209 commit b4e63e8
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 9 deletions.
16 changes: 8 additions & 8 deletions src/EventSource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {createParser, type EventSourceMessage, type EventSourceParser} from 'eventsource-parser'

import {ErrorEvent, syntaxError} from './errors.js'
import {ErrorEvent, flattenError, syntaxError} from './errors.js'
import type {
AddEventListenerOptions,
EventListenerOptions,
Expand Down Expand Up @@ -438,7 +438,7 @@ export class EventSource extends EventTarget {
return
}

this.#scheduleReconnect()
this.#scheduleReconnect(flattenError(err))
}

/**
Expand Down Expand Up @@ -514,6 +514,7 @@ export class EventSource extends EventTarget {
* Handles the process referred to in the EventSource specification as "failing a connection".
*
* @param error - The error causing the connection to fail
* @param code - The HTTP status code, if available
* @internal
*/
#failConnection(error?: string, code?: number) {
Expand All @@ -525,14 +526,11 @@ export class EventSource extends EventTarget {

// [spec] …and fires an event named `error` at the `EventSource` object.
// [spec] Once the user agent has failed the connection, it does not attempt to reconnect.
const errorEvent = new ErrorEvent('error')

// [spec] > Implementations are especially encouraged to report detailed information
// [spec] > to their development consoles whenever an error event is fired, since little
// [spec] > to no information can be made available in the events themselves.
// Printing to console is not very programatically helpful, though, so we emit a custom event.
errorEvent.code = code
errorEvent.message = error
const errorEvent = new ErrorEvent('error', code, error)

this.#onError?.(errorEvent)
this.dispatchEvent(errorEvent)
Expand All @@ -541,9 +539,11 @@ export class EventSource extends EventTarget {
/**
* Schedules a reconnection attempt against the EventSource endpoint.
*
* @param error - The error causing the connection to fail
* @param code - The HTTP status code, if available
* @internal
*/
#scheduleReconnect() {
#scheduleReconnect(error?: string, code?: number) {
// [spec] If the readyState attribute is set to CLOSED, abort the task.
if (this.#readyState === this.CLOSED) {
return
Expand All @@ -553,7 +553,7 @@ export class EventSource extends EventTarget {
this.#readyState = this.CONNECTING

// [spec] Fire an event named `error` at the EventSource object.
const errorEvent = new ErrorEvent('error')
const errorEvent = new ErrorEvent('error', code, error)
this.#onError?.(errorEvent)
this.dispatchEvent(errorEvent)

Expand Down
30 changes: 30 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export class ErrorEvent extends Event {
* @public
*/
public message?: string | undefined

constructor(type: string, code?: number, message?: string) {
super(type)
this.code = code ?? undefined
this.message = message ?? undefined
}
}

/**
Expand All @@ -43,3 +49,27 @@ export function syntaxError(message: string): SyntaxError {

return new SyntaxError(message)
}

/**
* Flatten an error into a single error message string.
* Unwraps nested errors and joins them with a comma.
*
* @param err - The error to flatten
* @returns A string representation of the error
* @internal
*/
export function flattenError(err: unknown): string {
if (!(err instanceof Error)) {
return `${err}`
}

if ('errors' in err && Array.isArray(err.errors)) {
return err.errors.map(flattenError).join(', ')
}

if ('cause' in err && err.cause instanceof Error) {
return `${err}: ${flattenError(err.cause)}`
}

return err.message
}
39 changes: 39 additions & 0 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type MessageReceiver = SinonSpy & {
}

const TYPE_ASSERTER = Symbol.for('waffle.type-asserter')
const PATTERN_ASSERTER = Symbol.for('waffle.pattern-asserter')

export class ExpectationError extends Error {
type = 'ExpectationError'
Expand Down Expand Up @@ -146,6 +147,38 @@ export function expect(
return
}

if (
typeof expected[key] === 'object' &&
expected[key] !== null &&
PATTERN_ASSERTER in expected[key]
) {
if (typeof thing[key] !== 'string') {
throw new ExpectationError(
`Expected key "${key}" of ${descriptor || 'object'} to be a string, got ${typeof thing[key]}`,
)
}

if (typeof expected[key][PATTERN_ASSERTER] === 'string') {
if (!thing[key].includes(expected[key][PATTERN_ASSERTER])) {
throw new ExpectationError(
`Expected key "${key}" of ${descriptor || 'object'} to include "${expected[key][PATTERN_ASSERTER]}", got "${thing[key]}"`,
)
}
return
}

if (expected[key][PATTERN_ASSERTER] instanceof RegExp) {
if (!expected[key][PATTERN_ASSERTER].test(thing[key])) {
throw new ExpectationError(
`Expected key "${key}" of ${descriptor || 'object'} to match pattern ${expected[key][PATTERN_ASSERTER]}, got "${thing[key]}"`,
)
}
return
}

throw new Error('Invalid pattern asserter')
}

if (thing[key] !== expected[key]) {
throw new ExpectationError(
`Expected key "${key}" of ${descriptor || 'object'} to be ${JSON.stringify(expected[key])}, was ${JSON.stringify(
Expand Down Expand Up @@ -188,6 +221,12 @@ expect.any = (
}
}

expect.stringMatching = (expected: string | RegExp) => {
return {
[PATTERN_ASSERTER]: expected,
}
}

function isPlainObject(obj: unknown): obj is Record<string, unknown> {
return typeof obj === 'object' && obj !== null && !Array.isArray(obj)
}
22 changes: 21 additions & 1 deletion test/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,27 @@ export function registerTests(options: {
await deferClose(es)
})

test('[NON-SPEC] message event contains extended properties', async () => {
test('[NON-SPEC] message event contains extended properties (failed connection)', async () => {
const onError = getCallCounter({name: 'onError'})
const es = new OurEventSource(`${baseUrl}:9999/should-not-connect`, {fetch})

es.addEventListener('error', onError)
await onError.waitForCallCount(1)

expect(onError.lastCall.lastArg).toMatchObject({
type: 'error',
defaultPrevented: false,
cancelable: false,
timeStamp: expect.any('number'),
message: expect.stringMatching(
/fetch failed|failed to fetch|load failed|attempting to fetch/i,
),
code: undefined,
})
await deferClose(es)
})

test('[NON-SPEC] message event contains extended properties (invalid http response)', async () => {
const onError = getCallCounter({name: 'onError'})
const es = new OurEventSource(`${baseUrl}:${port}/end-after-one`, {fetch})

Expand Down

0 comments on commit b4e63e8

Please sign in to comment.