@durable-streams/client 0.1.4 → 0.2.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@durable-streams/client",
3
3
  "description": "TypeScript client for the Durable Streams protocol",
4
- "version": "0.1.4",
4
+ "version": "0.2.0",
5
5
  "author": "Durable Stream contributors",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -47,7 +47,7 @@
47
47
  "devDependencies": {
48
48
  "fast-check": "^4.4.0",
49
49
  "tsdown": "^0.9.0",
50
- "@durable-streams/server": "0.1.5"
50
+ "@durable-streams/server": "0.1.7"
51
51
  },
52
52
  "engines": {
53
53
  "node": ">=18.0.0"
@@ -76,12 +76,9 @@ function normalizeContentType(contentType: string | undefined): string {
76
76
 
77
77
  /**
78
78
  * Internal type for pending batch entries.
79
- * Stores original data for proper JSON batching.
80
79
  */
81
80
  interface PendingEntry {
82
- /** Original data - parsed for JSON mode batching */
83
- data: unknown
84
- /** Encoded bytes for byte-stream mode */
81
+ /** Encoded bytes */
85
82
  body: Uint8Array
86
83
  }
87
84
 
@@ -173,18 +170,37 @@ export class IdempotentProducer {
173
170
  producerId: string,
174
171
  opts?: IdempotentProducerOptions
175
172
  ) {
173
+ // Validate inputs
174
+ const epoch = opts?.epoch ?? 0
175
+ const maxBatchBytes = opts?.maxBatchBytes ?? 1024 * 1024 // 1MB
176
+ const maxInFlight = opts?.maxInFlight ?? 5
177
+ const lingerMs = opts?.lingerMs ?? 5
178
+
179
+ if (epoch < 0) {
180
+ throw new Error(`epoch must be >= 0`)
181
+ }
182
+ if (maxBatchBytes <= 0) {
183
+ throw new Error(`maxBatchBytes must be > 0`)
184
+ }
185
+ if (maxInFlight <= 0) {
186
+ throw new Error(`maxInFlight must be > 0`)
187
+ }
188
+ if (lingerMs < 0) {
189
+ throw new Error(`lingerMs must be >= 0`)
190
+ }
191
+
176
192
  this.#stream = stream
177
193
  this.#producerId = producerId
178
- this.#epoch = opts?.epoch ?? 0
194
+ this.#epoch = epoch
179
195
  this.#autoClaim = opts?.autoClaim ?? false
180
- this.#maxBatchBytes = opts?.maxBatchBytes ?? 1024 * 1024 // 1MB
181
- this.#lingerMs = opts?.lingerMs ?? 5
196
+ this.#maxBatchBytes = maxBatchBytes
197
+ this.#lingerMs = lingerMs
182
198
  this.#signal = opts?.signal
183
199
  this.#onError = opts?.onError
184
200
  this.#fetchClient =
185
201
  opts?.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
186
202
 
187
- this.#maxInFlight = opts?.maxInFlight ?? 5
203
+ this.#maxInFlight = maxInFlight
188
204
 
189
205
  // When autoClaim is true, epoch is not yet known until first batch completes
190
206
  // We block pipelining until then to avoid racing with the claim
@@ -224,12 +240,22 @@ export class IdempotentProducer {
224
240
  * Errors are reported via onError callback if configured. Use flush() to
225
241
  * wait for all pending messages to be sent.
226
242
  *
227
- * For JSON streams, pass native objects (which will be serialized internally).
243
+ * For JSON streams, pass pre-serialized JSON strings.
228
244
  * For byte streams, pass string or Uint8Array.
229
245
  *
230
- * @param body - Data to append (object for JSON streams, string or Uint8Array for byte streams)
246
+ * @param body - Data to append (string or Uint8Array)
247
+ *
248
+ * @example
249
+ * ```typescript
250
+ * // JSON stream
251
+ * producer.append(JSON.stringify({ message: "hello" }));
252
+ *
253
+ * // Byte stream
254
+ * producer.append("raw text data");
255
+ * producer.append(new Uint8Array([1, 2, 3]));
256
+ * ```
231
257
  */
232
- append(body: Uint8Array | string | unknown): void {
258
+ append(body: Uint8Array | string): void {
233
259
  if (this.#closed) {
234
260
  throw new DurableStreamError(
235
261
  `Producer is closed`,
@@ -239,35 +265,21 @@ export class IdempotentProducer {
239
265
  )
240
266
  }
241
267
 
242
- const isJson =
243
- normalizeContentType(this.#stream.contentType) === `application/json`
244
-
245
268
  let bytes: Uint8Array
246
- let data: unknown
247
-
248
- if (isJson) {
249
- // For JSON streams: accept native objects, serialize internally
250
- const json = JSON.stringify(body)
251
- bytes = new TextEncoder().encode(json)
252
- data = body
269
+ if (typeof body === `string`) {
270
+ bytes = new TextEncoder().encode(body)
271
+ } else if (body instanceof Uint8Array) {
272
+ bytes = body
253
273
  } else {
254
- // For byte streams, require string or Uint8Array
255
- if (typeof body === `string`) {
256
- bytes = new TextEncoder().encode(body)
257
- } else if (body instanceof Uint8Array) {
258
- bytes = body
259
- } else {
260
- throw new DurableStreamError(
261
- `Non-JSON streams require string or Uint8Array`,
262
- `BAD_REQUEST`,
263
- 400,
264
- undefined
265
- )
266
- }
267
- data = bytes
274
+ throw new DurableStreamError(
275
+ `append() requires string or Uint8Array. For objects, use JSON.stringify().`,
276
+ `BAD_REQUEST`,
277
+ 400,
278
+ undefined
279
+ )
268
280
  }
269
281
 
270
- this.#pendingBatch.push({ data, body: bytes })
282
+ this.#pendingBatch.push({ body: bytes })
271
283
  this.#batchBytes += bytes.length
272
284
 
273
285
  // Check if batch should be sent immediately
@@ -523,8 +535,9 @@ export class IdempotentProducer {
523
535
  // For JSON mode: always send as array (server flattens one level)
524
536
  // Single append: [value] → server stores value
525
537
  // Multiple appends: [val1, val2] → server stores val1, val2
526
- const values = batch.map((e) => e.data)
527
- batchedBody = JSON.stringify(values)
538
+ // Input is pre-serialized JSON strings, join them into an array
539
+ const jsonStrings = batch.map((e) => new TextDecoder().decode(e.body))
540
+ batchedBody = `[${jsonStrings.join(`,`)}]`
528
541
  } else {
529
542
  // For byte mode: concatenate all chunks
530
543
  const totalSize = batch.reduce((sum, e) => sum + e.body.length, 0)
package/src/response.ts CHANGED
@@ -25,6 +25,16 @@ import type {
25
25
  TextChunk,
26
26
  } from "./types"
27
27
 
28
+ /**
29
+ * Constant used as abort reason when pausing the stream due to visibility change.
30
+ */
31
+ const PAUSE_STREAM = `PAUSE_STREAM`
32
+
33
+ /**
34
+ * State machine for visibility-based pause/resume.
35
+ */
36
+ type StreamState = `active` | `pause-requested` | `paused`
37
+
28
38
  /**
29
39
  * Internal configuration for creating a StreamResponse.
30
40
  */
@@ -53,7 +63,8 @@ export interface StreamResponseConfig {
53
63
  fetchNext: (
54
64
  offset: Offset,
55
65
  cursor: string | undefined,
56
- signal: AbortSignal
66
+ signal: AbortSignal,
67
+ resumingFromPause?: boolean
57
68
  ) => Promise<Response>
58
69
  /** Function to start SSE connection and return a Response with SSE body */
59
70
  startSSE?: (
@@ -85,9 +96,9 @@ export class StreamResponseImpl<
85
96
  #isLoading: boolean
86
97
 
87
98
  // --- Evolving state ---
88
- offset: Offset
89
- cursor?: string
90
- upToDate: boolean
99
+ #offset: Offset
100
+ #cursor?: string
101
+ #upToDate: boolean
91
102
 
92
103
  // --- Internal state ---
93
104
  #isJsonMode: boolean
@@ -100,6 +111,14 @@ export class StreamResponseImpl<
100
111
  #stopAfterUpToDate = false
101
112
  #consumptionMethod: string | null = null
102
113
 
114
+ // --- Visibility/Pause State ---
115
+ #state: StreamState = `active`
116
+ #requestAbortController?: AbortController
117
+ #unsubscribeFromVisibilityChanges?: () => void
118
+ #pausePromise?: Promise<void>
119
+ #pauseResolve?: () => void
120
+ #justResumedFromPause = false
121
+
103
122
  // --- SSE Resilience State ---
104
123
  #sseResilience: Required<SSEResilienceOptions>
105
124
  #lastSSEConnectionStartTime?: number
@@ -114,9 +133,9 @@ export class StreamResponseImpl<
114
133
  this.contentType = config.contentType
115
134
  this.live = config.live
116
135
  this.startOffset = config.startOffset
117
- this.offset = config.initialOffset
118
- this.cursor = config.initialCursor
119
- this.upToDate = config.initialUpToDate
136
+ this.#offset = config.initialOffset
137
+ this.#cursor = config.initialCursor
138
+ this.#upToDate = config.initialUpToDate
120
139
 
121
140
  // Initialize response metadata from first response
122
141
  this.#headers = config.firstResponse.headers
@@ -150,6 +169,97 @@ export class StreamResponseImpl<
150
169
 
151
170
  // Create the core response stream
152
171
  this.#responseStream = this.#createResponseStream(config.firstResponse)
172
+
173
+ // Install single abort listener that propagates to current request controller
174
+ // and unblocks any paused pull() (avoids accumulating one listener per request)
175
+ this.#abortController.signal.addEventListener(
176
+ `abort`,
177
+ () => {
178
+ this.#requestAbortController?.abort(this.#abortController.signal.reason)
179
+ // Unblock pull() if paused, so it can see the abort and close
180
+ this.#pauseResolve?.()
181
+ this.#pausePromise = undefined
182
+ this.#pauseResolve = undefined
183
+ },
184
+ { once: true }
185
+ )
186
+
187
+ // Subscribe to visibility changes for pause/resume (browser only)
188
+ this.#subscribeToVisibilityChanges()
189
+ }
190
+
191
+ /**
192
+ * Subscribe to document visibility changes to pause/resume syncing.
193
+ * When the page is hidden, we pause to save battery and bandwidth.
194
+ * When visible again, we resume syncing.
195
+ */
196
+ #subscribeToVisibilityChanges(): void {
197
+ // Only subscribe in browser environments
198
+ if (
199
+ typeof document === `object` &&
200
+ typeof document.hidden === `boolean` &&
201
+ typeof document.addEventListener === `function`
202
+ ) {
203
+ const visibilityHandler = (): void => {
204
+ if (document.hidden) {
205
+ this.#pause()
206
+ } else {
207
+ this.#resume()
208
+ }
209
+ }
210
+
211
+ document.addEventListener(`visibilitychange`, visibilityHandler)
212
+
213
+ // Store cleanup function to remove the event listener
214
+ // Check document still exists (may be undefined in tests after cleanup)
215
+ this.#unsubscribeFromVisibilityChanges = () => {
216
+ if (typeof document === `object`) {
217
+ document.removeEventListener(`visibilitychange`, visibilityHandler)
218
+ }
219
+ }
220
+
221
+ // Check initial state - page might already be hidden when stream starts
222
+ if (document.hidden) {
223
+ this.#pause()
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Pause the stream when page becomes hidden.
230
+ * Aborts any in-flight request to free resources.
231
+ * Creates a promise that pull() will await while paused.
232
+ */
233
+ #pause(): void {
234
+ if (this.#state === `active`) {
235
+ this.#state = `pause-requested`
236
+ // Create promise that pull() will await
237
+ this.#pausePromise = new Promise((resolve) => {
238
+ this.#pauseResolve = resolve
239
+ })
240
+ // Abort current request if any
241
+ this.#requestAbortController?.abort(PAUSE_STREAM)
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Resume the stream when page becomes visible.
247
+ * Resolves the pause promise to unblock pull().
248
+ */
249
+ #resume(): void {
250
+ if (this.#state === `paused` || this.#state === `pause-requested`) {
251
+ // Don't resume if the user's signal is already aborted
252
+ if (this.#abortController.signal.aborted) {
253
+ return
254
+ }
255
+
256
+ // Transition to active and resolve the pause promise
257
+ this.#state = `active`
258
+ this.#justResumedFromPause = true // Flag for single-shot skip of live param
259
+ this.#pauseResolve?.()
260
+ this.#pausePromise = undefined
261
+ this.#pauseResolve = undefined
262
+ }
153
263
  }
154
264
 
155
265
  // --- Response metadata getters ---
@@ -174,6 +284,20 @@ export class StreamResponseImpl<
174
284
  return this.#isLoading
175
285
  }
176
286
 
287
+ // --- Evolving state getters ---
288
+
289
+ get offset(): Offset {
290
+ return this.#offset
291
+ }
292
+
293
+ get cursor(): string | undefined {
294
+ return this.#cursor
295
+ }
296
+
297
+ get upToDate(): boolean {
298
+ return this.#upToDate
299
+ }
300
+
177
301
  // =================================
178
302
  // Internal helpers
179
303
  // =================================
@@ -189,10 +313,12 @@ export class StreamResponseImpl<
189
313
  }
190
314
 
191
315
  #markClosed(): void {
316
+ this.#unsubscribeFromVisibilityChanges?.()
192
317
  this.#closedResolve()
193
318
  }
194
319
 
195
320
  #markError(err: Error): void {
321
+ this.#unsubscribeFromVisibilityChanges?.()
196
322
  this.#closedReject(err)
197
323
  }
198
324
 
@@ -228,10 +354,10 @@ export class StreamResponseImpl<
228
354
  #updateStateFromResponse(response: Response): void {
229
355
  // Update stream-specific state
230
356
  const offset = response.headers.get(STREAM_OFFSET_HEADER)
231
- if (offset) this.offset = offset
357
+ if (offset) this.#offset = offset
232
358
  const cursor = response.headers.get(STREAM_CURSOR_HEADER)
233
- if (cursor) this.cursor = cursor
234
- this.upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
359
+ if (cursor) this.#cursor = cursor
360
+ this.#upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
235
361
 
236
362
  // Update response metadata to reflect latest server response
237
363
  this.#headers = response.headers
@@ -288,12 +414,12 @@ export class StreamResponseImpl<
288
414
  * Update instance state from an SSE control event.
289
415
  */
290
416
  #updateStateFromSSEControl(controlEvent: SSEControlEvent): void {
291
- this.offset = controlEvent.streamNextOffset
417
+ this.#offset = controlEvent.streamNextOffset
292
418
  if (controlEvent.streamCursor) {
293
- this.cursor = controlEvent.streamCursor
419
+ this.#cursor = controlEvent.streamCursor
294
420
  }
295
421
  if (controlEvent.upToDate !== undefined) {
296
- this.upToDate = controlEvent.upToDate
422
+ this.#upToDate = controlEvent.upToDate
297
423
  }
298
424
  }
299
425
 
@@ -388,13 +514,19 @@ export class StreamResponseImpl<
388
514
  // Track new connection start
389
515
  this.#markSSEConnectionStart()
390
516
 
517
+ // Create new per-request abort controller for this SSE connection
518
+ this.#requestAbortController = new AbortController()
519
+
391
520
  const newSSEResponse = await this.#startSSE(
392
521
  this.offset,
393
522
  this.cursor,
394
- this.#abortController.signal
523
+ this.#requestAbortController.signal
395
524
  )
396
525
  if (newSSEResponse.body) {
397
- return parseSSEStream(newSSEResponse.body, this.#abortController.signal)
526
+ return parseSSEStream(
527
+ newSSEResponse.body,
528
+ this.#requestAbortController.signal
529
+ )
398
530
  }
399
531
  return null
400
532
  }
@@ -548,10 +680,12 @@ export class StreamResponseImpl<
548
680
  if (isSSE && firstResponse.body) {
549
681
  // Track SSE connection start for resilience monitoring
550
682
  this.#markSSEConnectionStart()
683
+ // Create per-request abort controller for SSE connection
684
+ this.#requestAbortController = new AbortController()
551
685
  // Start parsing SSE events
552
686
  sseEventIterator = parseSSEStream(
553
687
  firstResponse.body,
554
- this.#abortController.signal
688
+ this.#requestAbortController.signal
555
689
  )
556
690
  // Fall through to SSE processing below
557
691
  } else {
@@ -570,6 +704,30 @@ export class StreamResponseImpl<
570
704
 
571
705
  // SSE mode: process events from the SSE stream
572
706
  if (sseEventIterator) {
707
+ // Check for pause state before processing SSE events
708
+ if (this.#state === `pause-requested` || this.#state === `paused`) {
709
+ this.#state = `paused`
710
+ if (this.#pausePromise) {
711
+ await this.#pausePromise
712
+ }
713
+ // After resume, check if we should still continue
714
+ if (this.#abortController.signal.aborted) {
715
+ this.#markClosed()
716
+ controller.close()
717
+ return
718
+ }
719
+ // Reconnect SSE after resume
720
+ const newIterator = await this.#trySSEReconnect()
721
+ if (newIterator) {
722
+ sseEventIterator = newIterator
723
+ } else {
724
+ // Could not reconnect - close the stream
725
+ this.#markClosed()
726
+ controller.close()
727
+ return
728
+ }
729
+ }
730
+
573
731
  // Keep reading events until we get data or stream ends
574
732
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
575
733
  while (true) {
@@ -604,16 +762,39 @@ export class StreamResponseImpl<
604
762
 
605
763
  // Long-poll mode: continue with live updates if needed
606
764
  if (this.#shouldContinueLive()) {
765
+ // If paused or pause-requested, await the pause promise
766
+ // This blocks pull() until resume() is called, avoiding deadlock
767
+ if (this.#state === `pause-requested` || this.#state === `paused`) {
768
+ this.#state = `paused`
769
+ if (this.#pausePromise) {
770
+ await this.#pausePromise
771
+ }
772
+ // After resume, check if we should still continue
773
+ if (this.#abortController.signal.aborted) {
774
+ this.#markClosed()
775
+ controller.close()
776
+ return
777
+ }
778
+ }
779
+
607
780
  if (this.#abortController.signal.aborted) {
608
781
  this.#markClosed()
609
782
  controller.close()
610
783
  return
611
784
  }
612
785
 
786
+ // Consume the single-shot resume flag (only first fetch after resume skips live param)
787
+ const resumingFromPause = this.#justResumedFromPause
788
+ this.#justResumedFromPause = false
789
+
790
+ // Create a new AbortController for this request (so we can abort on pause)
791
+ this.#requestAbortController = new AbortController()
792
+
613
793
  const response = await this.#fetchNext(
614
794
  this.offset,
615
795
  this.cursor,
616
- this.#abortController.signal
796
+ this.#requestAbortController.signal,
797
+ resumingFromPause
617
798
  )
618
799
 
619
800
  this.#updateStateFromResponse(response)
@@ -626,6 +807,21 @@ export class StreamResponseImpl<
626
807
  this.#markClosed()
627
808
  controller.close()
628
809
  } catch (err) {
810
+ // Check if this was a pause-triggered abort
811
+ // Treat PAUSE_STREAM aborts as benign regardless of current state
812
+ // (handles race where resume() was called before abort completed)
813
+ if (
814
+ this.#requestAbortController?.signal.aborted &&
815
+ this.#requestAbortController.signal.reason === PAUSE_STREAM
816
+ ) {
817
+ // Only transition to paused if we're still in pause-requested state
818
+ if (this.#state === `pause-requested`) {
819
+ this.#state = `paused`
820
+ }
821
+ // Return - either we're paused, or already resumed and next pull will proceed
822
+ return
823
+ }
824
+
629
825
  if (this.#abortController.signal.aborted) {
630
826
  this.#markClosed()
631
827
  controller.close()
@@ -638,6 +834,7 @@ export class StreamResponseImpl<
638
834
 
639
835
  cancel: () => {
640
836
  this.#abortController.abort()
837
+ this.#unsubscribeFromVisibilityChanges?.()
641
838
  this.#markClosed()
642
839
  },
643
840
  })
@@ -704,7 +901,17 @@ export class StreamResponseImpl<
704
901
  // Get response text first (handles empty responses gracefully)
705
902
  const text = await result.value.text()
706
903
  const content = text.trim() || `[]` // Default to empty array if no content or whitespace
707
- const parsed = JSON.parse(content) as T | Array<T>
904
+ let parsed: T | Array<T>
905
+ try {
906
+ parsed = JSON.parse(content) as T | Array<T>
907
+ } catch (err) {
908
+ const preview =
909
+ content.length > 100 ? content.slice(0, 100) + `...` : content
910
+ throw new DurableStreamError(
911
+ `Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
912
+ `PARSE_ERROR`
913
+ )
914
+ }
708
915
  if (Array.isArray(parsed)) {
709
916
  items.push(...parsed)
710
917
  } else {
@@ -838,7 +1045,17 @@ export class StreamResponseImpl<
838
1045
  // Parse JSON and flatten arrays (handle empty responses gracefully)
839
1046
  const text = await response.text()
840
1047
  const content = text.trim() || `[]` // Default to empty array if no content or whitespace
841
- const parsed = JSON.parse(content) as TJson | Array<TJson>
1048
+ let parsed: TJson | Array<TJson>
1049
+ try {
1050
+ parsed = JSON.parse(content) as TJson | Array<TJson>
1051
+ } catch (err) {
1052
+ const preview =
1053
+ content.length > 100 ? content.slice(0, 100) + `...` : content
1054
+ throw new DurableStreamError(
1055
+ `Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
1056
+ `PARSE_ERROR`
1057
+ )
1058
+ }
842
1059
  pendingItems = Array.isArray(parsed) ? parsed : [parsed]
843
1060
 
844
1061
  // Enqueue first item
@@ -882,7 +1099,7 @@ export class StreamResponseImpl<
882
1099
  // =====================
883
1100
 
884
1101
  subscribeJson<T = TJson>(
885
- subscriber: (batch: JsonBatch<T>) => Promise<void>
1102
+ subscriber: (batch: JsonBatch<T>) => void | Promise<void>
886
1103
  ): () => void {
887
1104
  this.#ensureNoConsumption(`subscribeJson`)
888
1105
  this.#ensureJsonMode()
@@ -903,9 +1120,20 @@ export class StreamResponseImpl<
903
1120
  // Get response text first (handles empty responses gracefully)
904
1121
  const text = await response.text()
905
1122
  const content = text.trim() || `[]` // Default to empty array if no content or whitespace
906
- const parsed = JSON.parse(content) as T | Array<T>
1123
+ let parsed: T | Array<T>
1124
+ try {
1125
+ parsed = JSON.parse(content) as T | Array<T>
1126
+ } catch (err) {
1127
+ const preview =
1128
+ content.length > 100 ? content.slice(0, 100) + `...` : content
1129
+ throw new DurableStreamError(
1130
+ `Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
1131
+ `PARSE_ERROR`
1132
+ )
1133
+ }
907
1134
  const items = Array.isArray(parsed) ? parsed : [parsed]
908
1135
 
1136
+ // Await callback (handles both sync and async)
909
1137
  await subscriber({
910
1138
  items,
911
1139
  offset,
@@ -938,7 +1166,9 @@ export class StreamResponseImpl<
938
1166
  }
939
1167
  }
940
1168
 
941
- subscribeBytes(subscriber: (chunk: ByteChunk) => Promise<void>): () => void {
1169
+ subscribeBytes(
1170
+ subscriber: (chunk: ByteChunk) => void | Promise<void>
1171
+ ): () => void {
942
1172
  this.#ensureNoConsumption(`subscribeBytes`)
943
1173
  const abortController = new AbortController()
944
1174
  const reader = this.#getResponseReader()
@@ -956,6 +1186,7 @@ export class StreamResponseImpl<
956
1186
 
957
1187
  const buffer = await response.arrayBuffer()
958
1188
 
1189
+ // Await callback (handles both sync and async)
959
1190
  await subscriber({
960
1191
  data: new Uint8Array(buffer),
961
1192
  offset,
@@ -988,7 +1219,9 @@ export class StreamResponseImpl<
988
1219
  }
989
1220
  }
990
1221
 
991
- subscribeText(subscriber: (chunk: TextChunk) => Promise<void>): () => void {
1222
+ subscribeText(
1223
+ subscriber: (chunk: TextChunk) => void | Promise<void>
1224
+ ): () => void {
992
1225
  this.#ensureNoConsumption(`subscribeText`)
993
1226
  const abortController = new AbortController()
994
1227
  const reader = this.#getResponseReader()
@@ -1006,6 +1239,7 @@ export class StreamResponseImpl<
1006
1239
 
1007
1240
  const text = await response.text()
1008
1241
 
1242
+ // Await callback (handles both sync and async)
1009
1243
  await subscriber({
1010
1244
  text,
1011
1245
  offset,
@@ -1044,6 +1278,7 @@ export class StreamResponseImpl<
1044
1278
 
1045
1279
  cancel(reason?: unknown): void {
1046
1280
  this.#abortController.abort(reason)
1281
+ this.#unsubscribeFromVisibilityChanges?.()
1047
1282
  this.#markClosed()
1048
1283
  }
1049
1284
 
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
  /**
@@ -79,10 +80,17 @@ export async function* parseSSEStream(
79
80
  streamCursor: control.streamCursor,
80
81
  upToDate: control.upToDate,
81
82
  }
82
- } catch {
83
- // Invalid control event, skip
83
+ } catch (err) {
84
+ // Control events contain critical offset data - don't silently ignore
85
+ const preview =
86
+ dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr
87
+ throw new DurableStreamError(
88
+ `Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
89
+ `PARSE_ERROR`
90
+ )
84
91
  }
85
92
  }
93
+ // Unknown event types are silently skipped per protocol
86
94
  }
87
95
  currentEvent = { data: [] }
88
96
  } else if (line.startsWith(`event:`)) {
@@ -122,8 +130,13 @@ export async function* parseSSEStream(
122
130
  streamCursor: control.streamCursor,
123
131
  upToDate: control.upToDate,
124
132
  }
125
- } catch {
126
- // Invalid control event, skip
133
+ } catch (err) {
134
+ const preview =
135
+ dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr
136
+ throw new DurableStreamError(
137
+ `Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
138
+ `PARSE_ERROR`
139
+ )
127
140
  }
128
141
  }
129
142
  }