Skip to content

Commit b4e63e8

Browse files
committed
fix: ensure message is set on ErrorEvent on network errors
1 parent 568f209 commit b4e63e8

File tree

4 files changed

+98
-9
lines changed

4 files changed

+98
-9
lines changed

src/EventSource.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {createParser, type EventSourceMessage, type EventSourceParser} from 'eventsource-parser'
22

3-
import {ErrorEvent, syntaxError} from './errors.js'
3+
import {ErrorEvent, flattenError, syntaxError} from './errors.js'
44
import type {
55
AddEventListenerOptions,
66
EventListenerOptions,
@@ -438,7 +438,7 @@ export class EventSource extends EventTarget {
438438
return
439439
}
440440

441-
this.#scheduleReconnect()
441+
this.#scheduleReconnect(flattenError(err))
442442
}
443443

444444
/**
@@ -514,6 +514,7 @@ export class EventSource extends EventTarget {
514514
* Handles the process referred to in the EventSource specification as "failing a connection".
515515
*
516516
* @param error - The error causing the connection to fail
517+
* @param code - The HTTP status code, if available
517518
* @internal
518519
*/
519520
#failConnection(error?: string, code?: number) {
@@ -525,14 +526,11 @@ export class EventSource extends EventTarget {
525526

526527
// [spec] …and fires an event named `error` at the `EventSource` object.
527528
// [spec] Once the user agent has failed the connection, it does not attempt to reconnect.
528-
const errorEvent = new ErrorEvent('error')
529-
530529
// [spec] > Implementations are especially encouraged to report detailed information
531530
// [spec] > to their development consoles whenever an error event is fired, since little
532531
// [spec] > to no information can be made available in the events themselves.
533532
// Printing to console is not very programatically helpful, though, so we emit a custom event.
534-
errorEvent.code = code
535-
errorEvent.message = error
533+
const errorEvent = new ErrorEvent('error', code, error)
536534

537535
this.#onError?.(errorEvent)
538536
this.dispatchEvent(errorEvent)
@@ -541,9 +539,11 @@ export class EventSource extends EventTarget {
541539
/**
542540
* Schedules a reconnection attempt against the EventSource endpoint.
543541
*
542+
* @param error - The error causing the connection to fail
543+
* @param code - The HTTP status code, if available
544544
* @internal
545545
*/
546-
#scheduleReconnect() {
546+
#scheduleReconnect(error?: string, code?: number) {
547547
// [spec] If the readyState attribute is set to CLOSED, abort the task.
548548
if (this.#readyState === this.CLOSED) {
549549
return
@@ -553,7 +553,7 @@ export class EventSource extends EventTarget {
553553
this.#readyState = this.CONNECTING
554554

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

src/errors.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export class ErrorEvent extends Event {
2121
* @public
2222
*/
2323
public message?: string | undefined
24+
25+
constructor(type: string, code?: number, message?: string) {
26+
super(type)
27+
this.code = code ?? undefined
28+
this.message = message ?? undefined
29+
}
2430
}
2531

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

4450
return new SyntaxError(message)
4551
}
52+
53+
/**
54+
* Flatten an error into a single error message string.
55+
* Unwraps nested errors and joins them with a comma.
56+
*
57+
* @param err - The error to flatten
58+
* @returns A string representation of the error
59+
* @internal
60+
*/
61+
export function flattenError(err: unknown): string {
62+
if (!(err instanceof Error)) {
63+
return `${err}`
64+
}
65+
66+
if ('errors' in err && Array.isArray(err.errors)) {
67+
return err.errors.map(flattenError).join(', ')
68+
}
69+
70+
if ('cause' in err && err.cause instanceof Error) {
71+
return `${err}: ${flattenError(err.cause)}`
72+
}
73+
74+
return err.message
75+
}

test/helpers.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ type MessageReceiver = SinonSpy & {
77
}
88

99
const TYPE_ASSERTER = Symbol.for('waffle.type-asserter')
10+
const PATTERN_ASSERTER = Symbol.for('waffle.pattern-asserter')
1011

1112
export class ExpectationError extends Error {
1213
type = 'ExpectationError'
@@ -146,6 +147,38 @@ export function expect(
146147
return
147148
}
148149

150+
if (
151+
typeof expected[key] === 'object' &&
152+
expected[key] !== null &&
153+
PATTERN_ASSERTER in expected[key]
154+
) {
155+
if (typeof thing[key] !== 'string') {
156+
throw new ExpectationError(
157+
`Expected key "${key}" of ${descriptor || 'object'} to be a string, got ${typeof thing[key]}`,
158+
)
159+
}
160+
161+
if (typeof expected[key][PATTERN_ASSERTER] === 'string') {
162+
if (!thing[key].includes(expected[key][PATTERN_ASSERTER])) {
163+
throw new ExpectationError(
164+
`Expected key "${key}" of ${descriptor || 'object'} to include "${expected[key][PATTERN_ASSERTER]}", got "${thing[key]}"`,
165+
)
166+
}
167+
return
168+
}
169+
170+
if (expected[key][PATTERN_ASSERTER] instanceof RegExp) {
171+
if (!expected[key][PATTERN_ASSERTER].test(thing[key])) {
172+
throw new ExpectationError(
173+
`Expected key "${key}" of ${descriptor || 'object'} to match pattern ${expected[key][PATTERN_ASSERTER]}, got "${thing[key]}"`,
174+
)
175+
}
176+
return
177+
}
178+
179+
throw new Error('Invalid pattern asserter')
180+
}
181+
149182
if (thing[key] !== expected[key]) {
150183
throw new ExpectationError(
151184
`Expected key "${key}" of ${descriptor || 'object'} to be ${JSON.stringify(expected[key])}, was ${JSON.stringify(
@@ -188,6 +221,12 @@ expect.any = (
188221
}
189222
}
190223

224+
expect.stringMatching = (expected: string | RegExp) => {
225+
return {
226+
[PATTERN_ASSERTER]: expected,
227+
}
228+
}
229+
191230
function isPlainObject(obj: unknown): obj is Record<string, unknown> {
192231
return typeof obj === 'object' && obj !== null && !Array.isArray(obj)
193232
}

test/tests.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,27 @@ export function registerTests(options: {
697697
await deferClose(es)
698698
})
699699

700-
test('[NON-SPEC] message event contains extended properties', async () => {
700+
test('[NON-SPEC] message event contains extended properties (failed connection)', async () => {
701+
const onError = getCallCounter({name: 'onError'})
702+
const es = new OurEventSource(`${baseUrl}:9999/should-not-connect`, {fetch})
703+
704+
es.addEventListener('error', onError)
705+
await onError.waitForCallCount(1)
706+
707+
expect(onError.lastCall.lastArg).toMatchObject({
708+
type: 'error',
709+
defaultPrevented: false,
710+
cancelable: false,
711+
timeStamp: expect.any('number'),
712+
message: expect.stringMatching(
713+
/fetch failed|failed to fetch|load failed|attempting to fetch/i,
714+
),
715+
code: undefined,
716+
})
717+
await deferClose(es)
718+
})
719+
720+
test('[NON-SPEC] message event contains extended properties (invalid http response)', async () => {
701721
const onError = getCallCounter({name: 'onError'})
702722
const es = new OurEventSource(`${baseUrl}:${port}/end-after-one`, {fetch})
703723

0 commit comments

Comments
 (0)