@durable-streams/client 0.1.5 → 0.2.1

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/response.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { asAsyncIterableReadableStream } from "./asyncIterableReadableStream"
9
9
  import {
10
+ STREAM_CLOSED_HEADER,
10
11
  STREAM_CURSOR_HEADER,
11
12
  STREAM_OFFSET_HEADER,
12
13
  STREAM_UP_TO_DATE_HEADER,
@@ -55,6 +56,8 @@ export interface StreamResponseConfig {
55
56
  initialCursor?: string
56
57
  /** Initial upToDate from first response headers */
57
58
  initialUpToDate: boolean
59
+ /** Initial streamClosed from first response headers */
60
+ initialStreamClosed: boolean
58
61
  /** The held first Response object */
59
62
  firstResponse: Response
60
63
  /** Abort controller for the session */
@@ -74,6 +77,8 @@ export interface StreamResponseConfig {
74
77
  ) => Promise<Response>
75
78
  /** SSE resilience options */
76
79
  sseResilience?: SSEResilienceOptions
80
+ /** Encoding for SSE data events */
81
+ encoding?: `base64`
77
82
  }
78
83
 
79
84
  /**
@@ -96,9 +101,10 @@ export class StreamResponseImpl<
96
101
  #isLoading: boolean
97
102
 
98
103
  // --- Evolving state ---
99
- offset: Offset
100
- cursor?: string
101
- upToDate: boolean
104
+ #offset: Offset
105
+ #cursor?: string
106
+ #upToDate: boolean
107
+ #streamClosed: boolean
102
108
 
103
109
  // --- Internal state ---
104
110
  #isJsonMode: boolean
@@ -125,6 +131,9 @@ export class StreamResponseImpl<
125
131
  #consecutiveShortSSEConnections = 0
126
132
  #sseFallbackToLongPoll = false
127
133
 
134
+ // --- SSE Encoding State ---
135
+ #encoding?: `base64`
136
+
128
137
  // Core primitive: a ReadableStream of Response objects
129
138
  #responseStream: ReadableStream<Response>
130
139
 
@@ -133,9 +142,10 @@ export class StreamResponseImpl<
133
142
  this.contentType = config.contentType
134
143
  this.live = config.live
135
144
  this.startOffset = config.startOffset
136
- this.offset = config.initialOffset
137
- this.cursor = config.initialCursor
138
- this.upToDate = config.initialUpToDate
145
+ this.#offset = config.initialOffset
146
+ this.#cursor = config.initialCursor
147
+ this.#upToDate = config.initialUpToDate
148
+ this.#streamClosed = config.initialStreamClosed
139
149
 
140
150
  // Initialize response metadata from first response
141
151
  this.#headers = config.firstResponse.headers
@@ -162,6 +172,9 @@ export class StreamResponseImpl<
162
172
  logWarnings: config.sseResilience?.logWarnings ?? true,
163
173
  }
164
174
 
175
+ // Initialize SSE encoding
176
+ this.#encoding = config.encoding
177
+
165
178
  this.#closed = new Promise((resolve, reject) => {
166
179
  this.#closedResolve = resolve
167
180
  this.#closedReject = reject
@@ -284,6 +297,24 @@ export class StreamResponseImpl<
284
297
  return this.#isLoading
285
298
  }
286
299
 
300
+ // --- Evolving state getters ---
301
+
302
+ get offset(): Offset {
303
+ return this.#offset
304
+ }
305
+
306
+ get cursor(): string | undefined {
307
+ return this.#cursor
308
+ }
309
+
310
+ get upToDate(): boolean {
311
+ return this.#upToDate
312
+ }
313
+
314
+ get streamClosed(): boolean {
315
+ return this.#streamClosed
316
+ }
317
+
287
318
  // =================================
288
319
  // Internal helpers
289
320
  // =================================
@@ -324,13 +355,15 @@ export class StreamResponseImpl<
324
355
 
325
356
  /**
326
357
  * Determine if we should continue with live updates based on live mode
327
- * and whether we've received upToDate.
358
+ * and whether we've received upToDate or streamClosed.
328
359
  */
329
360
  #shouldContinueLive(): boolean {
330
361
  // Stop if we've received upToDate and a consumption method wants to stop after upToDate
331
362
  if (this.#stopAfterUpToDate && this.upToDate) return false
332
363
  // Stop if live mode is explicitly disabled
333
364
  if (this.live === false) return false
365
+ // Stop if stream is closed (EOF) - no more data will ever be appended
366
+ if (this.#streamClosed) return false
334
367
  return true
335
368
  }
336
369
 
@@ -340,10 +373,14 @@ export class StreamResponseImpl<
340
373
  #updateStateFromResponse(response: Response): void {
341
374
  // Update stream-specific state
342
375
  const offset = response.headers.get(STREAM_OFFSET_HEADER)
343
- if (offset) this.offset = offset
376
+ if (offset) this.#offset = offset
344
377
  const cursor = response.headers.get(STREAM_CURSOR_HEADER)
345
- if (cursor) this.cursor = cursor
346
- this.upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
378
+ if (cursor) this.#cursor = cursor
379
+ this.#upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
380
+ const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER)
381
+ if (streamClosedHeader?.toLowerCase() === `true`) {
382
+ this.#streamClosed = true
383
+ }
347
384
 
348
385
  // Update response metadata to reflect latest server response
349
386
  this.#headers = response.headers
@@ -354,7 +391,7 @@ export class StreamResponseImpl<
354
391
 
355
392
  /**
356
393
  * Extract stream metadata from Response headers.
357
- * Used by subscriber APIs to get the correct offset/cursor/upToDate for each
394
+ * Used by subscriber APIs to get the correct offset/cursor/upToDate/streamClosed for each
358
395
  * specific Response, rather than reading from `this` which may be stale due to
359
396
  * ReadableStream prefetching or timing issues.
360
397
  */
@@ -362,26 +399,94 @@ export class StreamResponseImpl<
362
399
  offset: Offset
363
400
  cursor: string | undefined
364
401
  upToDate: boolean
402
+ streamClosed: boolean
365
403
  } {
366
404
  const offset = response.headers.get(STREAM_OFFSET_HEADER)
367
405
  const cursor = response.headers.get(STREAM_CURSOR_HEADER)
368
406
  const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
407
+ const streamClosed =
408
+ response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
369
409
  return {
370
410
  offset: offset ?? this.offset, // Fall back to instance state if no header
371
411
  cursor: cursor ?? this.cursor,
372
412
  upToDate,
413
+ streamClosed: streamClosed || this.streamClosed, // Once closed, always closed
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Decode base64 string to Uint8Array.
419
+ * Per protocol: concatenate data lines, remove \n and \r, then decode.
420
+ */
421
+ #decodeBase64(base64Str: string): Uint8Array {
422
+ // Remove all newlines and carriage returns per protocol
423
+ const cleaned = base64Str.replace(/[\n\r]/g, ``)
424
+
425
+ // Empty string is valid
426
+ if (cleaned.length === 0) {
427
+ return new Uint8Array(0)
428
+ }
429
+
430
+ // Validate length is multiple of 4
431
+ if (cleaned.length % 4 !== 0) {
432
+ throw new DurableStreamError(
433
+ `Invalid base64 data: length ${cleaned.length} is not a multiple of 4`,
434
+ `PARSE_ERROR`
435
+ )
436
+ }
437
+
438
+ try {
439
+ // Prefer Buffer (native C++ in Node) over atob (requires JS charCodeAt loop)
440
+ if (typeof Buffer !== `undefined`) {
441
+ return new Uint8Array(Buffer.from(cleaned, `base64`))
442
+ } else {
443
+ const binaryStr = atob(cleaned)
444
+ const bytes = new Uint8Array(binaryStr.length)
445
+ for (let i = 0; i < binaryStr.length; i++) {
446
+ bytes[i] = binaryStr.charCodeAt(i)
447
+ }
448
+ return bytes
449
+ }
450
+ } catch (err) {
451
+ throw new DurableStreamError(
452
+ `Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`,
453
+ `PARSE_ERROR`
454
+ )
373
455
  }
374
456
  }
375
457
 
376
458
  /**
377
459
  * Create a synthetic Response from SSE data with proper headers.
378
- * Includes offset/cursor/upToDate in headers so subscribers can read them.
460
+ * Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
379
461
  */
380
462
  #createSSESyntheticResponse(
381
463
  data: string,
382
464
  offset: Offset,
383
465
  cursor: string | undefined,
384
- upToDate: boolean
466
+ upToDate: boolean,
467
+ streamClosed: boolean
468
+ ): Response {
469
+ return this.#createSSESyntheticResponseFromParts(
470
+ [data],
471
+ offset,
472
+ cursor,
473
+ upToDate,
474
+ streamClosed
475
+ )
476
+ }
477
+
478
+ /**
479
+ * Create a synthetic Response from multiple SSE data parts.
480
+ * For base64 mode, each part is independently encoded, so we decode each
481
+ * separately and concatenate the binary results.
482
+ * For text mode, parts are simply concatenated as strings.
483
+ */
484
+ #createSSESyntheticResponseFromParts(
485
+ dataParts: Array<string>,
486
+ offset: Offset,
487
+ cursor: string | undefined,
488
+ upToDate: boolean,
489
+ streamClosed: boolean
385
490
  ): Response {
386
491
  const headers: Record<string, string> = {
387
492
  "content-type": this.contentType ?? `application/json`,
@@ -393,19 +498,64 @@ export class StreamResponseImpl<
393
498
  if (upToDate) {
394
499
  headers[STREAM_UP_TO_DATE_HEADER] = `true`
395
500
  }
396
- return new Response(data, { status: 200, headers })
501
+ if (streamClosed) {
502
+ headers[STREAM_CLOSED_HEADER] = `true`
503
+ }
504
+
505
+ // Decode base64 if encoding is used
506
+ let body: BodyInit
507
+ if (this.#encoding === `base64`) {
508
+ // Each data part is independently base64 encoded, decode each separately
509
+ const decodedParts = dataParts
510
+ .filter((part) => part.length > 0)
511
+ .map((part) => this.#decodeBase64(part))
512
+
513
+ if (decodedParts.length === 0) {
514
+ // No data - return empty body
515
+ body = new ArrayBuffer(0)
516
+ } else if (decodedParts.length === 1) {
517
+ // Single part - use directly
518
+ const decoded = decodedParts[0]!
519
+ body = decoded.buffer.slice(
520
+ decoded.byteOffset,
521
+ decoded.byteOffset + decoded.byteLength
522
+ ) as ArrayBuffer
523
+ } else {
524
+ // Multiple parts - concatenate binary data
525
+ const totalLength = decodedParts.reduce(
526
+ (sum, part) => sum + part.length,
527
+ 0
528
+ )
529
+ const combined = new Uint8Array(totalLength)
530
+ let offset = 0
531
+ for (const part of decodedParts) {
532
+ combined.set(part, offset)
533
+ offset += part.length
534
+ }
535
+ body = combined.buffer
536
+ }
537
+ } else {
538
+ body = dataParts.join(``)
539
+ }
540
+
541
+ return new Response(body, { status: 200, headers })
397
542
  }
398
543
 
399
544
  /**
400
545
  * Update instance state from an SSE control event.
401
546
  */
402
547
  #updateStateFromSSEControl(controlEvent: SSEControlEvent): void {
403
- this.offset = controlEvent.streamNextOffset
548
+ this.#offset = controlEvent.streamNextOffset
404
549
  if (controlEvent.streamCursor) {
405
- this.cursor = controlEvent.streamCursor
550
+ this.#cursor = controlEvent.streamCursor
406
551
  }
407
552
  if (controlEvent.upToDate !== undefined) {
408
- this.upToDate = controlEvent.upToDate
553
+ this.#upToDate = controlEvent.upToDate
554
+ }
555
+ if (controlEvent.streamClosed) {
556
+ this.#streamClosed = true
557
+ // A closed stream is definitionally up-to-date - no more data will ever be appended
558
+ this.#upToDate = true
409
559
  }
410
560
  }
411
561
 
@@ -564,8 +714,22 @@ export class StreamResponseImpl<
564
714
  return this.#processSSEDataEvent(event.data, sseEventIterator)
565
715
  }
566
716
 
567
- // Control event without preceding data - update state and continue
717
+ // Control event without preceding data - update state
568
718
  this.#updateStateFromSSEControl(event)
719
+
720
+ // If upToDate is signaled, yield an empty response so subscribers receive the signal
721
+ // This is important for empty streams and for subscribers waiting for catch-up completion
722
+ if (event.upToDate) {
723
+ const response = this.#createSSESyntheticResponse(
724
+ ``,
725
+ event.streamNextOffset,
726
+ event.streamCursor,
727
+ true,
728
+ event.streamClosed ?? false
729
+ )
730
+ return { type: `response`, response }
731
+ }
732
+
569
733
  return { type: `continue` }
570
734
  }
571
735
 
@@ -573,6 +737,9 @@ export class StreamResponseImpl<
573
737
  * Process an SSE data event by waiting for its corresponding control event.
574
738
  * In SSE protocol, control events come AFTER data events.
575
739
  * Multiple data events may arrive before a single control event - we buffer them.
740
+ *
741
+ * For base64 mode, each data event is independently base64 encoded, so we
742
+ * collect them as an array and decode each separately.
576
743
  */
577
744
  async #processSSEDataEvent(
578
745
  pendingData: string,
@@ -586,7 +753,8 @@ export class StreamResponseImpl<
586
753
  | { type: `error`; error: Error }
587
754
  > {
588
755
  // Buffer to accumulate data from multiple consecutive data events
589
- let bufferedData = pendingData
756
+ // For base64 mode, we collect as array since each event is independently encoded
757
+ const bufferedDataParts: Array<string> = [pendingData]
590
758
 
591
759
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
592
760
  while (true) {
@@ -595,11 +763,12 @@ export class StreamResponseImpl<
595
763
 
596
764
  if (controlDone) {
597
765
  // Stream ended without control event - yield buffered data with current state
598
- const response = this.#createSSESyntheticResponse(
599
- bufferedData,
766
+ const response = this.#createSSESyntheticResponseFromParts(
767
+ bufferedDataParts,
600
768
  this.offset,
601
769
  this.cursor,
602
- this.upToDate
770
+ this.upToDate,
771
+ this.streamClosed
603
772
  )
604
773
 
605
774
  // Try to reconnect
@@ -622,18 +791,19 @@ export class StreamResponseImpl<
622
791
  if (controlEvent.type === `control`) {
623
792
  // Update state and create response with correct metadata
624
793
  this.#updateStateFromSSEControl(controlEvent)
625
- const response = this.#createSSESyntheticResponse(
626
- bufferedData,
794
+ const response = this.#createSSESyntheticResponseFromParts(
795
+ bufferedDataParts,
627
796
  controlEvent.streamNextOffset,
628
797
  controlEvent.streamCursor,
629
- controlEvent.upToDate ?? false
798
+ controlEvent.upToDate ?? false,
799
+ controlEvent.streamClosed ?? false
630
800
  )
631
801
  return { type: `response`, response }
632
802
  }
633
803
 
634
804
  // Got another data event before control - buffer it
635
805
  // Server sends multiple data events followed by one control event
636
- bufferedData += controlEvent.data
806
+ bufferedDataParts.push(controlEvent.data)
637
807
  }
638
808
  }
639
809
 
@@ -887,7 +1057,17 @@ export class StreamResponseImpl<
887
1057
  // Get response text first (handles empty responses gracefully)
888
1058
  const text = await result.value.text()
889
1059
  const content = text.trim() || `[]` // Default to empty array if no content or whitespace
890
- const parsed = JSON.parse(content) as T | Array<T>
1060
+ let parsed: T | Array<T>
1061
+ try {
1062
+ parsed = JSON.parse(content) as T | Array<T>
1063
+ } catch (err) {
1064
+ const preview =
1065
+ content.length > 100 ? content.slice(0, 100) + `...` : content
1066
+ throw new DurableStreamError(
1067
+ `Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
1068
+ `PARSE_ERROR`
1069
+ )
1070
+ }
891
1071
  if (Array.isArray(parsed)) {
892
1072
  items.push(...parsed)
893
1073
  } else {
@@ -1021,7 +1201,17 @@ export class StreamResponseImpl<
1021
1201
  // Parse JSON and flatten arrays (handle empty responses gracefully)
1022
1202
  const text = await response.text()
1023
1203
  const content = text.trim() || `[]` // Default to empty array if no content or whitespace
1024
- const parsed = JSON.parse(content) as TJson | Array<TJson>
1204
+ let parsed: TJson | Array<TJson>
1205
+ try {
1206
+ parsed = JSON.parse(content) as TJson | Array<TJson>
1207
+ } catch (err) {
1208
+ const preview =
1209
+ content.length > 100 ? content.slice(0, 100) + `...` : content
1210
+ throw new DurableStreamError(
1211
+ `Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
1212
+ `PARSE_ERROR`
1213
+ )
1214
+ }
1025
1215
  pendingItems = Array.isArray(parsed) ? parsed : [parsed]
1026
1216
 
1027
1217
  // Enqueue first item
@@ -1065,7 +1255,7 @@ export class StreamResponseImpl<
1065
1255
  // =====================
1066
1256
 
1067
1257
  subscribeJson<T = TJson>(
1068
- subscriber: (batch: JsonBatch<T>) => Promise<void>
1258
+ subscriber: (batch: JsonBatch<T>) => void | Promise<void>
1069
1259
  ): () => void {
1070
1260
  this.#ensureNoConsumption(`subscribeJson`)
1071
1261
  this.#ensureJsonMode()
@@ -1080,20 +1270,32 @@ export class StreamResponseImpl<
1080
1270
 
1081
1271
  // Get metadata from Response headers (not from `this` which may be stale)
1082
1272
  const response = result.value
1083
- const { offset, cursor, upToDate } =
1273
+ const { offset, cursor, upToDate, streamClosed } =
1084
1274
  this.#getMetadataFromResponse(response)
1085
1275
 
1086
1276
  // Get response text first (handles empty responses gracefully)
1087
1277
  const text = await response.text()
1088
1278
  const content = text.trim() || `[]` // Default to empty array if no content or whitespace
1089
- const parsed = JSON.parse(content) as T | Array<T>
1279
+ let parsed: T | Array<T>
1280
+ try {
1281
+ parsed = JSON.parse(content) as T | Array<T>
1282
+ } catch (err) {
1283
+ const preview =
1284
+ content.length > 100 ? content.slice(0, 100) + `...` : content
1285
+ throw new DurableStreamError(
1286
+ `Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
1287
+ `PARSE_ERROR`
1288
+ )
1289
+ }
1090
1290
  const items = Array.isArray(parsed) ? parsed : [parsed]
1091
1291
 
1292
+ // Await callback (handles both sync and async)
1092
1293
  await subscriber({
1093
1294
  items,
1094
1295
  offset,
1095
1296
  cursor,
1096
1297
  upToDate,
1298
+ streamClosed,
1097
1299
  })
1098
1300
 
1099
1301
  result = await reader.read()
@@ -1121,7 +1323,9 @@ export class StreamResponseImpl<
1121
1323
  }
1122
1324
  }
1123
1325
 
1124
- subscribeBytes(subscriber: (chunk: ByteChunk) => Promise<void>): () => void {
1326
+ subscribeBytes(
1327
+ subscriber: (chunk: ByteChunk) => void | Promise<void>
1328
+ ): () => void {
1125
1329
  this.#ensureNoConsumption(`subscribeBytes`)
1126
1330
  const abortController = new AbortController()
1127
1331
  const reader = this.#getResponseReader()
@@ -1134,16 +1338,18 @@ export class StreamResponseImpl<
1134
1338
 
1135
1339
  // Get metadata from Response headers (not from `this` which may be stale)
1136
1340
  const response = result.value
1137
- const { offset, cursor, upToDate } =
1341
+ const { offset, cursor, upToDate, streamClosed } =
1138
1342
  this.#getMetadataFromResponse(response)
1139
1343
 
1140
1344
  const buffer = await response.arrayBuffer()
1141
1345
 
1346
+ // Await callback (handles both sync and async)
1142
1347
  await subscriber({
1143
1348
  data: new Uint8Array(buffer),
1144
1349
  offset,
1145
1350
  cursor,
1146
1351
  upToDate,
1352
+ streamClosed,
1147
1353
  })
1148
1354
 
1149
1355
  result = await reader.read()
@@ -1171,7 +1377,9 @@ export class StreamResponseImpl<
1171
1377
  }
1172
1378
  }
1173
1379
 
1174
- subscribeText(subscriber: (chunk: TextChunk) => Promise<void>): () => void {
1380
+ subscribeText(
1381
+ subscriber: (chunk: TextChunk) => void | Promise<void>
1382
+ ): () => void {
1175
1383
  this.#ensureNoConsumption(`subscribeText`)
1176
1384
  const abortController = new AbortController()
1177
1385
  const reader = this.#getResponseReader()
@@ -1184,16 +1392,18 @@ export class StreamResponseImpl<
1184
1392
 
1185
1393
  // Get metadata from Response headers (not from `this` which may be stale)
1186
1394
  const response = result.value
1187
- const { offset, cursor, upToDate } =
1395
+ const { offset, cursor, upToDate, streamClosed } =
1188
1396
  this.#getMetadataFromResponse(response)
1189
1397
 
1190
1398
  const text = await response.text()
1191
1399
 
1400
+ // Await callback (handles both sync and async)
1192
1401
  await subscriber({
1193
1402
  text,
1194
1403
  offset,
1195
1404
  cursor,
1196
1405
  upToDate,
1406
+ streamClosed,
1197
1407
  })
1198
1408
 
1199
1409
  result = await reader.read()
package/src/sse.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * - `event: control` events contain `streamNextOffset` and optional `streamCursor` and `upToDate`
7
7
  */
8
8
 
9
+ import { DurableStreamError } from "./error"
9
10
  import type { Offset } from "./types"
10
11
 
11
12
  /**
@@ -21,6 +22,7 @@ export interface SSEControlEvent {
21
22
  streamNextOffset: Offset
22
23
  streamCursor?: string
23
24
  upToDate?: boolean
25
+ streamClosed?: boolean
24
26
  }
25
27
 
26
28
  export type SSEEvent = SSEDataEvent | SSEControlEvent
@@ -72,21 +74,34 @@ export async function* parseSSEStream(
72
74
  streamNextOffset: Offset
73
75
  streamCursor?: string
74
76
  upToDate?: boolean
77
+ streamClosed?: boolean
75
78
  }
76
79
  yield {
77
80
  type: `control`,
78
81
  streamNextOffset: control.streamNextOffset,
79
82
  streamCursor: control.streamCursor,
80
83
  upToDate: control.upToDate,
84
+ streamClosed: control.streamClosed,
81
85
  }
82
- } catch {
83
- // Invalid control event, skip
86
+ } catch (err) {
87
+ // Control events contain critical offset data - don't silently ignore
88
+ const preview =
89
+ dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr
90
+ throw new DurableStreamError(
91
+ `Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
92
+ `PARSE_ERROR`
93
+ )
84
94
  }
85
95
  }
96
+ // Unknown event types are silently skipped per protocol
86
97
  }
87
98
  currentEvent = { data: [] }
88
99
  } else if (line.startsWith(`event:`)) {
89
- 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
90
105
  } else if (line.startsWith(`data:`)) {
91
106
  // Per SSE spec, strip the optional space after "data:"
92
107
  const content = line.slice(5)
@@ -115,15 +130,22 @@ export async function* parseSSEStream(
115
130
  streamNextOffset: Offset
116
131
  streamCursor?: string
117
132
  upToDate?: boolean
133
+ streamClosed?: boolean
118
134
  }
119
135
  yield {
120
136
  type: `control`,
121
137
  streamNextOffset: control.streamNextOffset,
122
138
  streamCursor: control.streamCursor,
123
139
  upToDate: control.upToDate,
140
+ streamClosed: control.streamClosed,
124
141
  }
125
- } catch {
126
- // Invalid control event, skip
142
+ } catch (err) {
143
+ const preview =
144
+ dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr
145
+ throw new DurableStreamError(
146
+ `Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
147
+ `PARSE_ERROR`
148
+ )
127
149
  }
128
150
  }
129
151
  }