@durable-streams/client 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sse.ts CHANGED
@@ -22,6 +22,7 @@ export interface SSEControlEvent {
22
22
  streamNextOffset: Offset
23
23
  streamCursor?: string
24
24
  upToDate?: boolean
25
+ streamClosed?: boolean
25
26
  }
26
27
 
27
28
  export type SSEEvent = SSEDataEvent | SSEControlEvent
@@ -73,12 +74,14 @@ export async function* parseSSEStream(
73
74
  streamNextOffset: Offset
74
75
  streamCursor?: string
75
76
  upToDate?: boolean
77
+ streamClosed?: boolean
76
78
  }
77
79
  yield {
78
80
  type: `control`,
79
81
  streamNextOffset: control.streamNextOffset,
80
82
  streamCursor: control.streamCursor,
81
83
  upToDate: control.upToDate,
84
+ streamClosed: control.streamClosed,
82
85
  }
83
86
  } catch (err) {
84
87
  // Control events contain critical offset data - don't silently ignore
@@ -94,7 +97,11 @@ export async function* parseSSEStream(
94
97
  }
95
98
  currentEvent = { data: [] }
96
99
  } else if (line.startsWith(`event:`)) {
97
- currentEvent.type = line.slice(6).trim()
100
+ // Per SSE spec, strip only one optional space after "event:"
101
+ const eventType = line.slice(6)
102
+ currentEvent.type = eventType.startsWith(` `)
103
+ ? eventType.slice(1)
104
+ : eventType
98
105
  } else if (line.startsWith(`data:`)) {
99
106
  // Per SSE spec, strip the optional space after "data:"
100
107
  const content = line.slice(5)
@@ -123,12 +130,14 @@ export async function* parseSSEStream(
123
130
  streamNextOffset: Offset
124
131
  streamCursor?: string
125
132
  upToDate?: boolean
133
+ streamClosed?: boolean
126
134
  }
127
135
  yield {
128
136
  type: `control`,
129
137
  streamNextOffset: control.streamNextOffset,
130
138
  streamCursor: control.streamCursor,
131
139
  upToDate: control.upToDate,
140
+ streamClosed: control.streamClosed,
132
141
  }
133
142
  } catch (err) {
134
143
  const preview =
package/src/stream-api.ts CHANGED
@@ -7,8 +7,10 @@
7
7
  import {
8
8
  LIVE_QUERY_PARAM,
9
9
  OFFSET_QUERY_PARAM,
10
+ STREAM_CLOSED_HEADER,
10
11
  STREAM_CURSOR_HEADER,
11
12
  STREAM_OFFSET_HEADER,
13
+ STREAM_SSE_DATA_ENCODING_HEADER,
12
14
  STREAM_UP_TO_DATE_HEADER,
13
15
  } from "./constants"
14
16
  import { DurableStreamError, FetchBackoffAbortError } from "./error"
@@ -190,12 +192,21 @@ async function streamInternal<TJson = unknown>(
190
192
  const initialCursor =
191
193
  firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? undefined
192
194
  const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER)
195
+ const initialStreamClosed =
196
+ firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
193
197
 
194
198
  // Determine if JSON mode
195
199
  const isJsonMode =
196
200
  options.json === true ||
197
201
  (contentType?.includes(`application/json`) ?? false)
198
202
 
203
+ // Detect SSE data encoding from response header (server auto-sets for binary streams)
204
+ const sseDataEncoding = firstResponse.headers.get(
205
+ STREAM_SSE_DATA_ENCODING_HEADER
206
+ )
207
+ const encoding =
208
+ sseDataEncoding === `base64` ? (`base64` as const) : undefined
209
+
199
210
  // Create the fetch function for subsequent requests
200
211
  const fetchNext = async (
201
212
  offset: Offset,
@@ -288,10 +299,12 @@ async function streamInternal<TJson = unknown>(
288
299
  initialOffset,
289
300
  initialCursor,
290
301
  initialUpToDate,
302
+ initialStreamClosed,
291
303
  firstResponse,
292
304
  abortController,
293
305
  fetchNext,
294
306
  startSSE,
295
307
  sseResilience: options.sseResilience,
308
+ encoding,
296
309
  })
297
310
  }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Explicit state machine for StreamResponseImpl.
3
+ *
4
+ * Every transition returns a new state — no mutation.
5
+ *
6
+ * Hierarchy:
7
+ * StreamResponseState (abstract)
8
+ * ├── LongPollState shouldUseSse() → false
9
+ * ├── SSEState shouldUseSse() → true
10
+ * └── PausedState delegates to wrapped inner state
11
+ */
12
+
13
+ import type { SSEControlEvent } from "./sse"
14
+ import type { LiveMode, Offset, SSEResilienceOptions } from "./types"
15
+
16
+ /**
17
+ * Shared sync fields across all state types.
18
+ */
19
+ export interface SyncFields {
20
+ readonly offset: Offset
21
+ readonly cursor: string | undefined
22
+ readonly upToDate: boolean
23
+ readonly streamClosed: boolean
24
+ }
25
+
26
+ /**
27
+ * Extracted metadata from an HTTP response for state transitions.
28
+ * undefined values mean "not present in response, preserve current value".
29
+ */
30
+ export interface ResponseMetadataUpdate {
31
+ readonly offset?: string
32
+ readonly cursor?: string
33
+ readonly upToDate: boolean
34
+ readonly streamClosed: boolean
35
+ }
36
+
37
+ /**
38
+ * Result of SSEState.handleConnectionEnd().
39
+ */
40
+ export type SSEConnectionEndResult =
41
+ | {
42
+ readonly action: `reconnect`
43
+ readonly state: SSEState
44
+ readonly backoffAttempt: number
45
+ }
46
+ | { readonly action: `fallback`; readonly state: LongPollState }
47
+ | { readonly action: `healthy`; readonly state: SSEState }
48
+
49
+ /**
50
+ * Abstract base class for stream response state.
51
+ * All state transitions return new immutable state objects.
52
+ */
53
+ export abstract class StreamResponseState implements SyncFields {
54
+ abstract readonly offset: Offset
55
+ abstract readonly cursor: string | undefined
56
+ abstract readonly upToDate: boolean
57
+ abstract readonly streamClosed: boolean
58
+
59
+ abstract shouldUseSse(): boolean
60
+ abstract withResponseMetadata(
61
+ update: ResponseMetadataUpdate
62
+ ): StreamResponseState
63
+ abstract withSSEControl(event: SSEControlEvent): StreamResponseState
64
+ abstract pause(): StreamResponseState
65
+
66
+ shouldContinueLive(stopAfterUpToDate: boolean, liveMode: LiveMode): boolean {
67
+ if (stopAfterUpToDate && this.upToDate) return false
68
+ if (liveMode === false) return false
69
+ if (this.streamClosed) return false
70
+ return true
71
+ }
72
+ }
73
+
74
+ /**
75
+ * State for long-poll mode. shouldUseSse() returns false.
76
+ */
77
+ export class LongPollState extends StreamResponseState {
78
+ readonly offset: Offset
79
+ readonly cursor: string | undefined
80
+ readonly upToDate: boolean
81
+ readonly streamClosed: boolean
82
+
83
+ constructor(fields: SyncFields) {
84
+ super()
85
+ this.offset = fields.offset
86
+ this.cursor = fields.cursor
87
+ this.upToDate = fields.upToDate
88
+ this.streamClosed = fields.streamClosed
89
+ }
90
+
91
+ shouldUseSse(): boolean {
92
+ return false
93
+ }
94
+
95
+ withResponseMetadata(update: ResponseMetadataUpdate): LongPollState {
96
+ return new LongPollState({
97
+ offset: update.offset ?? this.offset,
98
+ cursor: update.cursor ?? this.cursor,
99
+ upToDate: update.upToDate,
100
+ streamClosed: this.streamClosed || update.streamClosed,
101
+ })
102
+ }
103
+
104
+ withSSEControl(event: SSEControlEvent): LongPollState {
105
+ const streamClosed = this.streamClosed || (event.streamClosed ?? false)
106
+ return new LongPollState({
107
+ offset: event.streamNextOffset,
108
+ cursor: event.streamCursor || this.cursor,
109
+ upToDate:
110
+ (event.streamClosed ?? false)
111
+ ? true
112
+ : (event.upToDate ?? this.upToDate),
113
+ streamClosed,
114
+ })
115
+ }
116
+
117
+ pause(): PausedState {
118
+ return new PausedState(this)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * State for SSE mode. shouldUseSse() returns true.
124
+ * Tracks SSE connection resilience (short connection detection).
125
+ */
126
+ export class SSEState extends StreamResponseState {
127
+ readonly offset: Offset
128
+ readonly cursor: string | undefined
129
+ readonly upToDate: boolean
130
+ readonly streamClosed: boolean
131
+ readonly consecutiveShortConnections: number
132
+ readonly connectionStartTime: number | undefined
133
+
134
+ constructor(
135
+ fields: SyncFields & {
136
+ consecutiveShortConnections?: number
137
+ connectionStartTime?: number
138
+ }
139
+ ) {
140
+ super()
141
+ this.offset = fields.offset
142
+ this.cursor = fields.cursor
143
+ this.upToDate = fields.upToDate
144
+ this.streamClosed = fields.streamClosed
145
+ this.consecutiveShortConnections = fields.consecutiveShortConnections ?? 0
146
+ this.connectionStartTime = fields.connectionStartTime
147
+ }
148
+
149
+ shouldUseSse(): boolean {
150
+ return true
151
+ }
152
+
153
+ withResponseMetadata(update: ResponseMetadataUpdate): SSEState {
154
+ return new SSEState({
155
+ offset: update.offset ?? this.offset,
156
+ cursor: update.cursor ?? this.cursor,
157
+ upToDate: update.upToDate,
158
+ streamClosed: this.streamClosed || update.streamClosed,
159
+ consecutiveShortConnections: this.consecutiveShortConnections,
160
+ connectionStartTime: this.connectionStartTime,
161
+ })
162
+ }
163
+
164
+ withSSEControl(event: SSEControlEvent): SSEState {
165
+ const streamClosed = this.streamClosed || (event.streamClosed ?? false)
166
+ return new SSEState({
167
+ offset: event.streamNextOffset,
168
+ cursor: event.streamCursor || this.cursor,
169
+ upToDate:
170
+ (event.streamClosed ?? false)
171
+ ? true
172
+ : (event.upToDate ?? this.upToDate),
173
+ streamClosed,
174
+ consecutiveShortConnections: this.consecutiveShortConnections,
175
+ connectionStartTime: this.connectionStartTime,
176
+ })
177
+ }
178
+
179
+ startConnection(now: number): SSEState {
180
+ return new SSEState({
181
+ offset: this.offset,
182
+ cursor: this.cursor,
183
+ upToDate: this.upToDate,
184
+ streamClosed: this.streamClosed,
185
+ consecutiveShortConnections: this.consecutiveShortConnections,
186
+ connectionStartTime: now,
187
+ })
188
+ }
189
+
190
+ handleConnectionEnd(
191
+ now: number,
192
+ wasAborted: boolean,
193
+ config: Required<SSEResilienceOptions>
194
+ ): SSEConnectionEndResult {
195
+ if (this.connectionStartTime === undefined) {
196
+ return { action: `healthy`, state: this }
197
+ }
198
+
199
+ const duration = now - this.connectionStartTime
200
+
201
+ if (duration < config.minConnectionDuration && !wasAborted) {
202
+ // Connection was too short — likely proxy buffering or misconfiguration
203
+ const newCount = this.consecutiveShortConnections + 1
204
+
205
+ if (newCount >= config.maxShortConnections) {
206
+ // Threshold reached → permanent fallback to long-poll
207
+ return {
208
+ action: `fallback`,
209
+ state: new LongPollState({
210
+ offset: this.offset,
211
+ cursor: this.cursor,
212
+ upToDate: this.upToDate,
213
+ streamClosed: this.streamClosed,
214
+ }),
215
+ }
216
+ }
217
+
218
+ // Reconnect with backoff
219
+ return {
220
+ action: `reconnect`,
221
+ state: new SSEState({
222
+ offset: this.offset,
223
+ cursor: this.cursor,
224
+ upToDate: this.upToDate,
225
+ streamClosed: this.streamClosed,
226
+ consecutiveShortConnections: newCount,
227
+ connectionStartTime: this.connectionStartTime,
228
+ }),
229
+ backoffAttempt: newCount,
230
+ }
231
+ }
232
+
233
+ if (duration >= config.minConnectionDuration) {
234
+ // Healthy connection — reset counter
235
+ return {
236
+ action: `healthy`,
237
+ state: new SSEState({
238
+ offset: this.offset,
239
+ cursor: this.cursor,
240
+ upToDate: this.upToDate,
241
+ streamClosed: this.streamClosed,
242
+ consecutiveShortConnections: 0,
243
+ connectionStartTime: this.connectionStartTime,
244
+ }),
245
+ }
246
+ }
247
+
248
+ // Aborted connection — don't change counter
249
+ return { action: `healthy`, state: this }
250
+ }
251
+
252
+ pause(): PausedState {
253
+ return new PausedState(this)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Paused state wrapper. Delegates all sync field access to the inner state.
259
+ * resume() returns the wrapped state unchanged (identity preserved).
260
+ */
261
+ export class PausedState extends StreamResponseState {
262
+ readonly #inner: LongPollState | SSEState
263
+
264
+ constructor(inner: LongPollState | SSEState) {
265
+ super()
266
+ this.#inner = inner
267
+ }
268
+
269
+ get offset(): Offset {
270
+ return this.#inner.offset
271
+ }
272
+
273
+ get cursor(): string | undefined {
274
+ return this.#inner.cursor
275
+ }
276
+
277
+ get upToDate(): boolean {
278
+ return this.#inner.upToDate
279
+ }
280
+
281
+ get streamClosed(): boolean {
282
+ return this.#inner.streamClosed
283
+ }
284
+
285
+ shouldUseSse(): boolean {
286
+ return this.#inner.shouldUseSse()
287
+ }
288
+
289
+ withResponseMetadata(update: ResponseMetadataUpdate): PausedState {
290
+ const newInner = this.#inner.withResponseMetadata(update)
291
+ return new PausedState(newInner)
292
+ }
293
+
294
+ withSSEControl(event: SSEControlEvent): PausedState {
295
+ const newInner = this.#inner.withSSEControl(event)
296
+ return new PausedState(newInner)
297
+ }
298
+
299
+ pause(): PausedState {
300
+ return this
301
+ }
302
+
303
+ resume(): { state: LongPollState | SSEState; justResumed: true } {
304
+ return { state: this.#inner, justResumed: true }
305
+ }
306
+ }
package/src/stream.ts CHANGED
@@ -7,13 +7,13 @@
7
7
  import fastq from "fastq"
8
8
 
9
9
  import {
10
- DurableStreamError,
11
10
  InvalidSignalError,
12
11
  MissingStreamUrlError,
12
+ StreamClosedError,
13
13
  } from "./error"
14
14
  import { IdempotentProducer } from "./idempotent-producer"
15
15
  import {
16
- SSE_COMPATIBLE_CONTENT_TYPES,
16
+ STREAM_CLOSED_HEADER,
17
17
  STREAM_EXPIRES_AT_HEADER,
18
18
  STREAM_OFFSET_HEADER,
19
19
  STREAM_SEQ_HEADER,
@@ -35,6 +35,8 @@ import type { BackoffOptions } from "./fetch"
35
35
  import type { queueAsPromised } from "fastq"
36
36
  import type {
37
37
  AppendOptions,
38
+ CloseOptions,
39
+ CloseResult,
38
40
  CreateOptions,
39
41
  HeadResult,
40
42
  HeadersRecord,
@@ -204,6 +206,7 @@ export class DurableStream {
204
206
  ttlSeconds: opts.ttlSeconds,
205
207
  expiresAt: opts.expiresAt,
206
208
  body: opts.body,
209
+ closed: opts.closed,
207
210
  })
208
211
  return stream
209
212
  }
@@ -269,6 +272,8 @@ export class DurableStream {
269
272
  const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? undefined
270
273
  const etag = response.headers.get(`etag`) ?? undefined
271
274
  const cacheControl = response.headers.get(`cache-control`) ?? undefined
275
+ const streamClosed =
276
+ response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
272
277
 
273
278
  // Update instance contentType
274
279
  if (contentType) {
@@ -281,6 +286,7 @@ export class DurableStream {
281
286
  offset,
282
287
  etag,
283
288
  cacheControl,
289
+ streamClosed,
284
290
  }
285
291
  }
286
292
 
@@ -300,6 +306,9 @@ export class DurableStream {
300
306
  if (opts?.expiresAt) {
301
307
  requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt
302
308
  }
309
+ if (opts?.closed) {
310
+ requestHeaders[STREAM_CLOSED_HEADER] = `true`
311
+ }
303
312
 
304
313
  const body = encodeBody(opts?.body)
305
314
 
@@ -342,6 +351,84 @@ export class DurableStream {
342
351
  }
343
352
  }
344
353
 
354
+ /**
355
+ * Close the stream, optionally with a final message.
356
+ *
357
+ * After closing:
358
+ * - No further appends are permitted (server returns 409)
359
+ * - Readers can observe the closed state and treat it as EOF
360
+ * - The stream's data remains fully readable
361
+ *
362
+ * Closing is:
363
+ * - **Durable**: The closed state is persisted
364
+ * - **Monotonic**: Once closed, a stream cannot be reopened
365
+ *
366
+ * **Idempotency:**
367
+ * - `close()` without body: Idempotent — safe to call multiple times
368
+ * - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
369
+ * if stream is already closed (use `IdempotentProducer.close()` for
370
+ * idempotent close-with-body semantics)
371
+ *
372
+ * @returns CloseResult with the final offset
373
+ * @throws StreamClosedError if called with body on an already-closed stream
374
+ */
375
+ async close(opts?: CloseOptions): Promise<CloseResult> {
376
+ const { requestHeaders, fetchUrl } = await this.#buildRequest()
377
+
378
+ const contentType =
379
+ opts?.contentType ?? this.#options.contentType ?? this.contentType
380
+ if (contentType) {
381
+ requestHeaders[`content-type`] = contentType
382
+ }
383
+
384
+ // Always send Stream-Closed: true header for close operation
385
+ requestHeaders[STREAM_CLOSED_HEADER] = `true`
386
+
387
+ // For JSON mode with body, wrap in array
388
+ let body: BodyInit | undefined
389
+ if (opts?.body !== undefined) {
390
+ const isJson = normalizeContentType(contentType) === `application/json`
391
+ if (isJson) {
392
+ const bodyStr =
393
+ typeof opts.body === `string`
394
+ ? opts.body
395
+ : new TextDecoder().decode(opts.body)
396
+ body = `[${bodyStr}]`
397
+ } else {
398
+ body =
399
+ typeof opts.body === `string`
400
+ ? opts.body
401
+ : (opts.body as unknown as BodyInit)
402
+ }
403
+ }
404
+
405
+ const response = await this.#fetchClient(fetchUrl.toString(), {
406
+ method: `POST`,
407
+ headers: requestHeaders,
408
+ body,
409
+ signal: opts?.signal ?? this.#options.signal,
410
+ })
411
+
412
+ // Check for 409 Conflict with Stream-Closed header
413
+ if (response.status === 409) {
414
+ const isClosed =
415
+ response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
416
+ if (isClosed) {
417
+ const finalOffset =
418
+ response.headers.get(STREAM_OFFSET_HEADER) ?? undefined
419
+ throw new StreamClosedError(this.url, finalOffset)
420
+ }
421
+ }
422
+
423
+ if (!response.ok) {
424
+ await handleErrorResponse(response, this.url)
425
+ }
426
+
427
+ const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
428
+
429
+ return { finalOffset }
430
+ }
431
+
345
432
  /**
346
433
  * Append a single payload to the stream.
347
434
  *
@@ -403,9 +490,24 @@ export class DurableStream {
403
490
  // For JSON mode, wrap body in array to match protocol (server flattens one level)
404
491
  // Input is pre-serialized JSON string
405
492
  const isJson = normalizeContentType(contentType) === `application/json`
406
- const bodyStr =
407
- typeof body === `string` ? body : new TextDecoder().decode(body)
408
- const encodedBody: BodyInit = isJson ? `[${bodyStr}]` : bodyStr
493
+ let encodedBody: BodyInit
494
+ if (isJson) {
495
+ // JSON mode: decode as UTF-8 string and wrap in array
496
+ const bodyStr =
497
+ typeof body === `string` ? body : new TextDecoder().decode(body)
498
+ encodedBody = `[${bodyStr}]`
499
+ } else {
500
+ // Binary mode: preserve raw bytes
501
+ // Use ArrayBuffer for cross-platform BodyInit compatibility
502
+ if (typeof body === `string`) {
503
+ encodedBody = body
504
+ } else {
505
+ encodedBody = body.buffer.slice(
506
+ body.byteOffset,
507
+ body.byteOffset + body.byteLength
508
+ ) as ArrayBuffer
509
+ }
510
+ }
409
511
 
410
512
  const response = await this.#fetchClient(fetchUrl.toString(), {
411
513
  method: `POST`,
@@ -522,11 +624,43 @@ export class DurableStream {
522
624
  )
523
625
  batchedBody = `[${jsonStrings.join(`,`)}]`
524
626
  } else {
525
- // For byte mode: concatenate all chunks as a string
526
- const strings = batch.map((m) =>
527
- typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data)
528
- )
529
- batchedBody = strings.join(``)
627
+ // For byte mode: preserve original data types
628
+ // - Strings are concatenated as strings (for text/* content types)
629
+ // - Uint8Arrays are concatenated as binary (for application/octet-stream)
630
+ // - Mixed types: convert all to binary to avoid data corruption
631
+ const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array)
632
+ const hasString = batch.some((m) => typeof m.data === `string`)
633
+
634
+ if (hasUint8Array && !hasString) {
635
+ // All binary: concatenate Uint8Arrays
636
+ const chunks = batch.map((m) => m.data as Uint8Array)
637
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
638
+ const combined = new Uint8Array(totalLength)
639
+ let offset = 0
640
+ for (const chunk of chunks) {
641
+ combined.set(chunk, offset)
642
+ offset += chunk.length
643
+ }
644
+ batchedBody = combined
645
+ } else if (hasString && !hasUint8Array) {
646
+ // All strings: concatenate as string
647
+ batchedBody = batch.map((m) => m.data as string).join(``)
648
+ } else {
649
+ // Mixed types: convert strings to binary and concatenate
650
+ // This preserves binary data integrity
651
+ const encoder = new TextEncoder()
652
+ const chunks = batch.map((m) =>
653
+ typeof m.data === `string` ? encoder.encode(m.data) : m.data
654
+ )
655
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
656
+ const combined = new Uint8Array(totalLength)
657
+ let offset = 0
658
+ for (const chunk of chunks) {
659
+ combined.set(chunk, offset)
660
+ offset += chunk.length
661
+ }
662
+ batchedBody = combined
663
+ }
530
664
  }
531
665
 
532
666
  // Combine signals: stream-level signal + any per-message signals
@@ -682,12 +816,13 @@ export class DurableStream {
682
816
  producer.append(chunk)
683
817
  },
684
818
  async close() {
685
- await producer.flush()
819
+ // close() flushes pending and closes the stream (EOF)
686
820
  await producer.close()
687
821
  if (writeError) throw writeError // Causes pipeTo() to reject
688
822
  },
689
823
  abort(_reason) {
690
- producer.close().catch((err) => {
824
+ // detach() stops the producer without closing the stream
825
+ producer.detach().catch((err) => {
691
826
  opts?.onError?.(err) // Report instead of swallowing
692
827
  })
693
828
  },
@@ -736,20 +871,6 @@ export class DurableStream {
736
871
  async stream<TJson = unknown>(
737
872
  options?: Omit<StreamOptions, `url`>
738
873
  ): Promise<StreamResponse<TJson>> {
739
- // Check SSE compatibility if SSE mode is requested
740
- if (options?.live === `sse` && this.contentType) {
741
- const isSSECompatible = SSE_COMPATIBLE_CONTENT_TYPES.some((prefix) =>
742
- this.contentType!.startsWith(prefix)
743
- )
744
- if (!isSSECompatible) {
745
- throw new DurableStreamError(
746
- `SSE is not supported for content-type: ${this.contentType}`,
747
- `SSE_NOT_SUPPORTED`,
748
- 400
749
- )
750
- }
751
- }
752
-
753
874
  // Merge handle-level and call-specific headers
754
875
  const mergedHeaders: HeadersRecord = {
755
876
  ...this.#options.headers,