@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/README.md +11 -10
- package/dist/index.cjs +954 -795
- package/dist/index.d.cts +63 -25
- package/dist/index.d.ts +63 -25
- package/dist/index.js +954 -795
- package/package.json +2 -2
- package/src/idempotent-producer.ts +51 -38
- package/src/response.ts +258 -23
- package/src/sse.ts +17 -4
- package/src/stream-api.ts +22 -9
- package/src/stream.ts +77 -56
- package/src/types.ts +24 -12
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.
|
|
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.
|
|
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
|
-
/**
|
|
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 =
|
|
194
|
+
this.#epoch = epoch
|
|
179
195
|
this.#autoClaim = opts?.autoClaim ?? false
|
|
180
|
-
this.#maxBatchBytes =
|
|
181
|
-
this.#lingerMs =
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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({
|
|
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
|
-
|
|
527
|
-
|
|
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
|
|
118
|
-
this
|
|
119
|
-
this
|
|
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
|
|
357
|
+
if (offset) this.#offset = offset
|
|
232
358
|
const cursor = response.headers.get(STREAM_CURSOR_HEADER)
|
|
233
|
-
if (cursor) this
|
|
234
|
-
this
|
|
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
|
|
417
|
+
this.#offset = controlEvent.streamNextOffset
|
|
292
418
|
if (controlEvent.streamCursor) {
|
|
293
|
-
this
|
|
419
|
+
this.#cursor = controlEvent.streamCursor
|
|
294
420
|
}
|
|
295
421
|
if (controlEvent.upToDate !== undefined) {
|
|
296
|
-
this
|
|
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.#
|
|
523
|
+
this.#requestAbortController.signal
|
|
395
524
|
)
|
|
396
525
|
if (newSSEResponse.body) {
|
|
397
|
-
return parseSSEStream(
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|