@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/README.md +212 -18
- package/dist/index.cjs +1152 -805
- package/dist/index.d.cts +201 -33
- package/dist/index.d.ts +201 -33
- package/dist/index.js +1150 -806
- package/package.json +2 -2
- package/src/constants.ts +19 -2
- package/src/error.ts +20 -0
- package/src/idempotent-producer.ts +195 -43
- package/src/index.ts +7 -0
- package/src/response.ts +245 -35
- package/src/sse.ts +27 -5
- package/src/stream-api.ts +30 -10
- package/src/stream.ts +213 -71
- package/src/types.ts +97 -12
- package/src/utils.ts +10 -1
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
|
|
137
|
-
this
|
|
138
|
-
this
|
|
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
|
|
376
|
+
if (offset) this.#offset = offset
|
|
344
377
|
const cursor = response.headers.get(STREAM_CURSOR_HEADER)
|
|
345
|
-
if (cursor) this
|
|
346
|
-
this
|
|
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
|
-
|
|
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
|
|
548
|
+
this.#offset = controlEvent.streamNextOffset
|
|
404
549
|
if (controlEvent.streamCursor) {
|
|
405
|
-
this
|
|
550
|
+
this.#cursor = controlEvent.streamCursor
|
|
406
551
|
}
|
|
407
552
|
if (controlEvent.upToDate !== undefined) {
|
|
408
|
-
this
|
|
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
|
|
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
|
-
|
|
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.#
|
|
599
|
-
|
|
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.#
|
|
626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|