From 35be66df5bdbcf8962a02575179472f3bc0a50d4 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 29 Sep 2026 15:10:17 +0700 Subject: [PATCH] feat(fetch)!: always send close event, remove `emptyCloseEventEnabled` option `toEventStream` now always emits a `close` event when the iterator completes, including when it returns `undefined`, so clients can reliably tell a finished stream from a dropped connection. --- packages/fetch/src/event-stream.test.ts | 81 ------------------------- packages/fetch/src/event-stream.ts | 22 ++----- 2 files changed, 5 insertions(+), 98 deletions(-) diff --git a/packages/fetch/src/event-stream.test.ts b/packages/fetch/src/event-stream.test.ts index a1e57855..dab0e27e 100644 --- a/packages/fetch/src/event-stream.test.ts +++ b/packages/fetch/src/event-stream.test.ts @@ -583,87 +583,6 @@ describe('toEventStream', () => { await expect(reader.read()).resolves.toEqual({ done: true }) }) }) - - describe('empty close event', () => { - it('enabled by default', async () => { - async function* gen() { - yield 'hello' - } - - const stream = toEventStream(gen(), { - initialComment: { enabled: false }, - keepAlive: { enabled: false }, - }) - - const reader = stream - .pipeThrough(new TextDecoderStream()) - .getReader() - - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }) - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: close\n\n' }) - await expect(reader.read()).resolves.toEqual({ done: true }) - }) - - it('disabled', async () => { - async function* gen() { - yield 'hello' - } - - const stream = toEventStream(gen(), { - initialComment: { enabled: false }, - keepAlive: { enabled: false }, - emptyCloseEventEnabled: false, - }) - - const reader = stream - .pipeThrough(new TextDecoderStream()) - .getReader() - - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }) - await expect(reader.read()).resolves.toEqual({ done: true }) - }) - - it('enabled explicitly', async () => { - async function* gen() { - yield 'hello' - } - - const stream = toEventStream(gen(), { - initialComment: { enabled: false }, - keepAlive: { enabled: false }, - emptyCloseEventEnabled: true, - }) - - const reader = stream - .pipeThrough(new TextDecoderStream()) - .getReader() - - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }) - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: close\n\n' }) - await expect(reader.read()).resolves.toEqual({ done: true }) - }) - - it.each([true, false])('still sends close event when iterator returns a value: %s', async (emptyCloseEventEnabled) => { - async function* gen() { - yield 'hello' - return 'bye' - } - - const stream = toEventStream(gen(), { - initialComment: { enabled: false }, - keepAlive: { enabled: false }, - emptyCloseEventEnabled, - }) - - const reader = stream - .pipeThrough(new TextDecoderStream()) - .getReader() - - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: message\ndata: "hello"\n\n' }) - await expect(reader.read()).resolves.toEqual({ done: false, value: 'event: close\ndata: "bye"\n\n' }) - await expect(reader.read()).resolves.toEqual({ done: true }) - }) - }) }) it.each([ diff --git a/packages/fetch/src/event-stream.ts b/packages/fetch/src/event-stream.ts index 0b34cc22..acbef216 100644 --- a/packages/fetch/src/event-stream.ts +++ b/packages/fetch/src/event-stream.ts @@ -113,14 +113,6 @@ export interface ToEventStreamOptions { */ comment?: string } - - /** - * If true, a `close` event is sent even when the iterator completes with `undefined`. - * When the iterator returns a value, a `close` event is always emitted regardless of this setting. - * - * @default true - */ - emptyCloseEventEnabled?: boolean } export function toEventStream( @@ -132,7 +124,6 @@ export function toEventStream( const keepAliveComment = options.keepAlive?.comment ?? '' const initialCommentEnabled = options.initialComment?.enabled ?? true const initialComment = options.initialComment?.comment ?? '' - const emptyCloseEventEnabled = options.emptyCloseEventEnabled ?? true let cancelled = false let timeout: ReturnType | undefined @@ -193,14 +184,11 @@ export function toEventStream( try { const [data, meta] = unwrapEvent(result.value) - if (!result.done || data !== undefined || meta !== undefined || emptyCloseEventEnabled) { - const event = result.done ? 'close' : 'message' - controller.enqueue(encodeEventStreamMessage({ - ...meta, - event, - data: stringifyJSON(data), - })) - } + controller.enqueue(encodeEventStreamMessage({ + ...meta, + event: result.done ? 'close' : 'message', + data: stringifyJSON(data), + })) } catch (err) { /**