@durable-streams/client 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/intent.js +6 -0
- package/dist/index.cjs +364 -168
- package/dist/index.js +364 -168
- package/package.json +10 -3
- package/skills/getting-started/SKILL.md +223 -0
- package/skills/go-to-production/SKILL.md +243 -0
- package/skills/reading-streams/SKILL.md +247 -0
- package/skills/reading-streams/references/stream-response-methods.md +133 -0
- package/skills/server-deployment/SKILL.md +211 -0
- package/skills/writing-data/SKILL.md +311 -0
- package/src/response.ts +332 -303
- package/src/stream-response-state.ts +306 -0
package/src/response.ts
CHANGED
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
} from "./constants"
|
|
15
15
|
import { DurableStreamError } from "./error"
|
|
16
16
|
import { parseSSEStream } from "./sse"
|
|
17
|
+
import { LongPollState, PausedState, SSEState } from "./stream-response-state"
|
|
17
18
|
import type { ReadableStreamAsyncIterable } from "./asyncIterableReadableStream"
|
|
18
19
|
import type { SSEControlEvent, SSEEvent } from "./sse"
|
|
20
|
+
import type { StreamResponseState } from "./stream-response-state"
|
|
19
21
|
import type {
|
|
20
22
|
ByteChunk,
|
|
21
23
|
StreamResponse as IStreamResponse,
|
|
@@ -100,11 +102,8 @@ export class StreamResponseImpl<
|
|
|
100
102
|
#ok: boolean
|
|
101
103
|
#isLoading: boolean
|
|
102
104
|
|
|
103
|
-
// --- Evolving state ---
|
|
104
|
-
#
|
|
105
|
-
#cursor?: string
|
|
106
|
-
#upToDate: boolean
|
|
107
|
-
#streamClosed: boolean
|
|
105
|
+
// --- Evolving state (immutable state machine) ---
|
|
106
|
+
#syncState: StreamResponseState
|
|
108
107
|
|
|
109
108
|
// --- Internal state ---
|
|
110
109
|
#isJsonMode: boolean
|
|
@@ -123,13 +122,9 @@ export class StreamResponseImpl<
|
|
|
123
122
|
#unsubscribeFromVisibilityChanges?: () => void
|
|
124
123
|
#pausePromise?: Promise<void>
|
|
125
124
|
#pauseResolve?: () => void
|
|
126
|
-
#justResumedFromPause = false
|
|
127
125
|
|
|
128
|
-
// --- SSE Resilience
|
|
126
|
+
// --- SSE Resilience Config ---
|
|
129
127
|
#sseResilience: Required<SSEResilienceOptions>
|
|
130
|
-
#lastSSEConnectionStartTime?: number
|
|
131
|
-
#consecutiveShortSSEConnections = 0
|
|
132
|
-
#sseFallbackToLongPoll = false
|
|
133
128
|
|
|
134
129
|
// --- SSE Encoding State ---
|
|
135
130
|
#encoding?: `base64`
|
|
@@ -142,10 +137,18 @@ export class StreamResponseImpl<
|
|
|
142
137
|
this.contentType = config.contentType
|
|
143
138
|
this.live = config.live
|
|
144
139
|
this.startOffset = config.startOffset
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
140
|
+
|
|
141
|
+
// Initialize immutable state machine — SSEState if SSE is available,
|
|
142
|
+
// LongPollState otherwise. The type encodes whether SSE has fallen back.
|
|
143
|
+
const syncFields = {
|
|
144
|
+
offset: config.initialOffset,
|
|
145
|
+
cursor: config.initialCursor,
|
|
146
|
+
upToDate: config.initialUpToDate,
|
|
147
|
+
streamClosed: config.initialStreamClosed,
|
|
148
|
+
}
|
|
149
|
+
this.#syncState = config.startSSE
|
|
150
|
+
? new SSEState(syncFields)
|
|
151
|
+
: new LongPollState(syncFields)
|
|
149
152
|
|
|
150
153
|
// Initialize response metadata from first response
|
|
151
154
|
this.#headers = config.firstResponse.headers
|
|
@@ -246,6 +249,8 @@ export class StreamResponseImpl<
|
|
|
246
249
|
#pause(): void {
|
|
247
250
|
if (this.#state === `active`) {
|
|
248
251
|
this.#state = `pause-requested`
|
|
252
|
+
// Wrap state in PausedState to preserve it across pause/resume
|
|
253
|
+
this.#syncState = this.#syncState.pause()
|
|
249
254
|
// Create promise that pull() will await
|
|
250
255
|
this.#pausePromise = new Promise((resolve) => {
|
|
251
256
|
this.#pauseResolve = resolve
|
|
@@ -266,9 +271,13 @@ export class StreamResponseImpl<
|
|
|
266
271
|
return
|
|
267
272
|
}
|
|
268
273
|
|
|
274
|
+
// Unwrap PausedState to restore the inner state
|
|
275
|
+
if (this.#syncState instanceof PausedState) {
|
|
276
|
+
this.#syncState = this.#syncState.resume().state
|
|
277
|
+
}
|
|
278
|
+
|
|
269
279
|
// Transition to active and resolve the pause promise
|
|
270
280
|
this.#state = `active`
|
|
271
|
-
this.#justResumedFromPause = true // Flag for single-shot skip of live param
|
|
272
281
|
this.#pauseResolve?.()
|
|
273
282
|
this.#pausePromise = undefined
|
|
274
283
|
this.#pauseResolve = undefined
|
|
@@ -297,22 +306,22 @@ export class StreamResponseImpl<
|
|
|
297
306
|
return this.#isLoading
|
|
298
307
|
}
|
|
299
308
|
|
|
300
|
-
// --- Evolving state getters ---
|
|
309
|
+
// --- Evolving state getters (delegated to state machine) ---
|
|
301
310
|
|
|
302
311
|
get offset(): Offset {
|
|
303
|
-
return this.#offset
|
|
312
|
+
return this.#syncState.offset
|
|
304
313
|
}
|
|
305
314
|
|
|
306
315
|
get cursor(): string | undefined {
|
|
307
|
-
return this.#cursor
|
|
316
|
+
return this.#syncState.cursor
|
|
308
317
|
}
|
|
309
318
|
|
|
310
319
|
get upToDate(): boolean {
|
|
311
|
-
return this.#upToDate
|
|
320
|
+
return this.#syncState.upToDate
|
|
312
321
|
}
|
|
313
322
|
|
|
314
323
|
get streamClosed(): boolean {
|
|
315
|
-
return this.#streamClosed
|
|
324
|
+
return this.#syncState.streamClosed
|
|
316
325
|
}
|
|
317
326
|
|
|
318
327
|
// =================================
|
|
@@ -358,29 +367,24 @@ export class StreamResponseImpl<
|
|
|
358
367
|
* and whether we've received upToDate or streamClosed.
|
|
359
368
|
*/
|
|
360
369
|
#shouldContinueLive(): boolean {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// Stop if stream is closed (EOF) - no more data will ever be appended
|
|
366
|
-
if (this.#streamClosed) return false
|
|
367
|
-
return true
|
|
370
|
+
return this.#syncState.shouldContinueLive(
|
|
371
|
+
this.#stopAfterUpToDate,
|
|
372
|
+
this.live
|
|
373
|
+
)
|
|
368
374
|
}
|
|
369
375
|
|
|
370
376
|
/**
|
|
371
377
|
* Update state from response headers.
|
|
372
378
|
*/
|
|
373
379
|
#updateStateFromResponse(response: Response): void {
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
this.#streamClosed = true
|
|
383
|
-
}
|
|
380
|
+
// Immutable state transition
|
|
381
|
+
this.#syncState = this.#syncState.withResponseMetadata({
|
|
382
|
+
offset: response.headers.get(STREAM_OFFSET_HEADER) || undefined,
|
|
383
|
+
cursor: response.headers.get(STREAM_CURSOR_HEADER) || undefined,
|
|
384
|
+
upToDate: response.headers.has(STREAM_UP_TO_DATE_HEADER),
|
|
385
|
+
streamClosed:
|
|
386
|
+
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`,
|
|
387
|
+
})
|
|
384
388
|
|
|
385
389
|
// Update response metadata to reflect latest server response
|
|
386
390
|
this.#headers = response.headers
|
|
@@ -389,238 +393,28 @@ export class StreamResponseImpl<
|
|
|
389
393
|
this.#ok = response.ok
|
|
390
394
|
}
|
|
391
395
|
|
|
392
|
-
/**
|
|
393
|
-
* Extract stream metadata from Response headers.
|
|
394
|
-
* Used by subscriber APIs to get the correct offset/cursor/upToDate/streamClosed for each
|
|
395
|
-
* specific Response, rather than reading from `this` which may be stale due to
|
|
396
|
-
* ReadableStream prefetching or timing issues.
|
|
397
|
-
*/
|
|
398
|
-
#getMetadataFromResponse(response: Response): {
|
|
399
|
-
offset: Offset
|
|
400
|
-
cursor: string | undefined
|
|
401
|
-
upToDate: boolean
|
|
402
|
-
streamClosed: boolean
|
|
403
|
-
} {
|
|
404
|
-
const offset = response.headers.get(STREAM_OFFSET_HEADER)
|
|
405
|
-
const cursor = response.headers.get(STREAM_CURSOR_HEADER)
|
|
406
|
-
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
|
|
407
|
-
const streamClosed =
|
|
408
|
-
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
409
|
-
return {
|
|
410
|
-
offset: offset ?? this.offset, // Fall back to instance state if no header
|
|
411
|
-
cursor: cursor ?? this.cursor,
|
|
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
|
-
)
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Create a synthetic Response from SSE data with proper headers.
|
|
460
|
-
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
|
|
461
|
-
*/
|
|
462
|
-
#createSSESyntheticResponse(
|
|
463
|
-
data: string,
|
|
464
|
-
offset: Offset,
|
|
465
|
-
cursor: string | undefined,
|
|
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
|
|
490
|
-
): Response {
|
|
491
|
-
const headers: Record<string, string> = {
|
|
492
|
-
"content-type": this.contentType ?? `application/json`,
|
|
493
|
-
[STREAM_OFFSET_HEADER]: String(offset),
|
|
494
|
-
}
|
|
495
|
-
if (cursor) {
|
|
496
|
-
headers[STREAM_CURSOR_HEADER] = cursor
|
|
497
|
-
}
|
|
498
|
-
if (upToDate) {
|
|
499
|
-
headers[STREAM_UP_TO_DATE_HEADER] = `true`
|
|
500
|
-
}
|
|
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 })
|
|
542
|
-
}
|
|
543
|
-
|
|
544
396
|
/**
|
|
545
397
|
* Update instance state from an SSE control event.
|
|
546
398
|
*/
|
|
547
399
|
#updateStateFromSSEControl(controlEvent: SSEControlEvent): void {
|
|
548
|
-
this.#
|
|
549
|
-
if (controlEvent.streamCursor) {
|
|
550
|
-
this.#cursor = controlEvent.streamCursor
|
|
551
|
-
}
|
|
552
|
-
if (controlEvent.upToDate !== undefined) {
|
|
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
|
|
559
|
-
}
|
|
400
|
+
this.#syncState = this.#syncState.withSSEControl(controlEvent)
|
|
560
401
|
}
|
|
561
402
|
|
|
562
403
|
/**
|
|
563
404
|
* Mark the start of an SSE connection for duration tracking.
|
|
405
|
+
* If the state is not SSEState (e.g., auto-detected SSE from content-type),
|
|
406
|
+
* transitions to SSEState first.
|
|
564
407
|
*/
|
|
565
408
|
#markSSEConnectionStart(): void {
|
|
566
|
-
this.#
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
async #handleSSEConnectionEnd(): Promise<number | null> {
|
|
574
|
-
if (this.#lastSSEConnectionStartTime === undefined) {
|
|
575
|
-
return 0 // No tracking, allow immediate reconnect
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
const connectionDuration = Date.now() - this.#lastSSEConnectionStartTime
|
|
579
|
-
const wasAborted = this.#abortController.signal.aborted
|
|
580
|
-
|
|
581
|
-
if (
|
|
582
|
-
connectionDuration < this.#sseResilience.minConnectionDuration &&
|
|
583
|
-
!wasAborted
|
|
584
|
-
) {
|
|
585
|
-
// Connection was too short - likely proxy buffering or misconfiguration
|
|
586
|
-
this.#consecutiveShortSSEConnections++
|
|
587
|
-
|
|
588
|
-
if (
|
|
589
|
-
this.#consecutiveShortSSEConnections >=
|
|
590
|
-
this.#sseResilience.maxShortConnections
|
|
591
|
-
) {
|
|
592
|
-
// Too many short connections - fall back to long polling
|
|
593
|
-
this.#sseFallbackToLongPoll = true
|
|
594
|
-
|
|
595
|
-
if (this.#sseResilience.logWarnings) {
|
|
596
|
-
console.warn(
|
|
597
|
-
`[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). ` +
|
|
598
|
-
`Falling back to long polling. ` +
|
|
599
|
-
`Your proxy must support streaming SSE responses (not buffer the complete response). ` +
|
|
600
|
-
`Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.`
|
|
601
|
-
)
|
|
602
|
-
}
|
|
603
|
-
return null // Signal to not reconnect SSE
|
|
604
|
-
} else {
|
|
605
|
-
// Add exponential backoff with full jitter to prevent tight infinite loop
|
|
606
|
-
// Formula: random(0, min(cap, base * 2^attempt))
|
|
607
|
-
const maxDelay = Math.min(
|
|
608
|
-
this.#sseResilience.backoffMaxDelay,
|
|
609
|
-
this.#sseResilience.backoffBaseDelay *
|
|
610
|
-
Math.pow(2, this.#consecutiveShortSSEConnections)
|
|
611
|
-
)
|
|
612
|
-
const delayMs = Math.floor(Math.random() * maxDelay)
|
|
613
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
614
|
-
return delayMs
|
|
615
|
-
}
|
|
616
|
-
} else if (
|
|
617
|
-
connectionDuration >= this.#sseResilience.minConnectionDuration
|
|
618
|
-
) {
|
|
619
|
-
// Connection was healthy - reset counter
|
|
620
|
-
this.#consecutiveShortSSEConnections = 0
|
|
409
|
+
if (!(this.#syncState instanceof SSEState)) {
|
|
410
|
+
this.#syncState = new SSEState({
|
|
411
|
+
offset: this.#syncState.offset,
|
|
412
|
+
cursor: this.#syncState.cursor,
|
|
413
|
+
upToDate: this.#syncState.upToDate,
|
|
414
|
+
streamClosed: this.#syncState.streamClosed,
|
|
415
|
+
})
|
|
621
416
|
}
|
|
622
|
-
|
|
623
|
-
return 0 // Allow immediate reconnect
|
|
417
|
+
this.#syncState = (this.#syncState as SSEState).startConnection(Date.now())
|
|
624
418
|
}
|
|
625
419
|
|
|
626
420
|
/**
|
|
@@ -632,8 +426,8 @@ export class StreamResponseImpl<
|
|
|
632
426
|
void,
|
|
633
427
|
undefined
|
|
634
428
|
> | null> {
|
|
635
|
-
// Check if we should fall back to long-poll
|
|
636
|
-
if (this.#
|
|
429
|
+
// Check if we should fall back to long-poll (state type encodes this)
|
|
430
|
+
if (!this.#syncState.shouldUseSse()) {
|
|
637
431
|
return null // Will cause fallback to long-poll
|
|
638
432
|
}
|
|
639
433
|
|
|
@@ -641,12 +435,37 @@ export class StreamResponseImpl<
|
|
|
641
435
|
return null
|
|
642
436
|
}
|
|
643
437
|
|
|
644
|
-
//
|
|
645
|
-
const
|
|
646
|
-
|
|
438
|
+
// Pure state transition: check connection duration, manage counters
|
|
439
|
+
const result = (this.#syncState as SSEState).handleConnectionEnd(
|
|
440
|
+
Date.now(),
|
|
441
|
+
this.#abortController.signal.aborted,
|
|
442
|
+
this.#sseResilience
|
|
443
|
+
)
|
|
444
|
+
this.#syncState = result.state
|
|
445
|
+
|
|
446
|
+
if (result.action === `fallback`) {
|
|
447
|
+
if (this.#sseResilience.logWarnings) {
|
|
448
|
+
console.warn(
|
|
449
|
+
`[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). ` +
|
|
450
|
+
`Falling back to long polling. ` +
|
|
451
|
+
`Your proxy must support streaming SSE responses (not buffer the complete response). ` +
|
|
452
|
+
`Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.`
|
|
453
|
+
)
|
|
454
|
+
}
|
|
647
455
|
return null // Fallback to long-poll was triggered
|
|
648
456
|
}
|
|
649
457
|
|
|
458
|
+
if (result.action === `reconnect`) {
|
|
459
|
+
// Host applies jitter/delay — state machine only returns backoffAttempt
|
|
460
|
+
const maxDelay = Math.min(
|
|
461
|
+
this.#sseResilience.backoffMaxDelay,
|
|
462
|
+
this.#sseResilience.backoffBaseDelay *
|
|
463
|
+
Math.pow(2, result.backoffAttempt)
|
|
464
|
+
)
|
|
465
|
+
const delayMs = Math.floor(Math.random() * maxDelay)
|
|
466
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
467
|
+
}
|
|
468
|
+
|
|
650
469
|
// Track new connection start
|
|
651
470
|
this.#markSSEConnectionStart()
|
|
652
471
|
|
|
@@ -720,12 +539,14 @@ export class StreamResponseImpl<
|
|
|
720
539
|
// If upToDate is signaled, yield an empty response so subscribers receive the signal
|
|
721
540
|
// This is important for empty streams and for subscribers waiting for catch-up completion
|
|
722
541
|
if (event.upToDate) {
|
|
723
|
-
const response =
|
|
542
|
+
const response = createSSESyntheticResponse(
|
|
724
543
|
``,
|
|
725
544
|
event.streamNextOffset,
|
|
726
545
|
event.streamCursor,
|
|
727
546
|
true,
|
|
728
|
-
event.streamClosed ?? false
|
|
547
|
+
event.streamClosed ?? false,
|
|
548
|
+
this.contentType,
|
|
549
|
+
this.#encoding
|
|
729
550
|
)
|
|
730
551
|
return { type: `response`, response }
|
|
731
552
|
}
|
|
@@ -763,12 +584,15 @@ export class StreamResponseImpl<
|
|
|
763
584
|
|
|
764
585
|
if (controlDone) {
|
|
765
586
|
// Stream ended without control event - yield buffered data with current state
|
|
766
|
-
const response =
|
|
587
|
+
const response = createSSESyntheticResponseFromParts(
|
|
767
588
|
bufferedDataParts,
|
|
768
589
|
this.offset,
|
|
769
590
|
this.cursor,
|
|
770
591
|
this.upToDate,
|
|
771
|
-
this.streamClosed
|
|
592
|
+
this.streamClosed,
|
|
593
|
+
this.contentType,
|
|
594
|
+
this.#encoding,
|
|
595
|
+
this.#isJsonMode
|
|
772
596
|
)
|
|
773
597
|
|
|
774
598
|
// Try to reconnect
|
|
@@ -791,12 +615,15 @@ export class StreamResponseImpl<
|
|
|
791
615
|
if (controlEvent.type === `control`) {
|
|
792
616
|
// Update state and create response with correct metadata
|
|
793
617
|
this.#updateStateFromSSEControl(controlEvent)
|
|
794
|
-
const response =
|
|
618
|
+
const response = createSSESyntheticResponseFromParts(
|
|
795
619
|
bufferedDataParts,
|
|
796
620
|
controlEvent.streamNextOffset,
|
|
797
621
|
controlEvent.streamCursor,
|
|
798
622
|
controlEvent.upToDate ?? false,
|
|
799
|
-
controlEvent.streamClosed ?? false
|
|
623
|
+
controlEvent.streamClosed ?? false,
|
|
624
|
+
this.contentType,
|
|
625
|
+
this.#encoding,
|
|
626
|
+
this.#isJsonMode
|
|
800
627
|
)
|
|
801
628
|
return { type: `response`, response }
|
|
802
629
|
}
|
|
@@ -918,8 +745,10 @@ export class StreamResponseImpl<
|
|
|
918
745
|
|
|
919
746
|
// Long-poll mode: continue with live updates if needed
|
|
920
747
|
if (this.#shouldContinueLive()) {
|
|
921
|
-
//
|
|
922
|
-
//
|
|
748
|
+
// Determine if we're resuming from pause — local variable replaces
|
|
749
|
+
// the old #justResumedFromPause one-shot field. If we enter the pause
|
|
750
|
+
// branch and wake up without abort, we just resumed.
|
|
751
|
+
let resumingFromPause = false
|
|
923
752
|
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
924
753
|
this.#state = `paused`
|
|
925
754
|
if (this.#pausePromise) {
|
|
@@ -931,6 +760,7 @@ export class StreamResponseImpl<
|
|
|
931
760
|
controller.close()
|
|
932
761
|
return
|
|
933
762
|
}
|
|
763
|
+
resumingFromPause = true
|
|
934
764
|
}
|
|
935
765
|
|
|
936
766
|
if (this.#abortController.signal.aborted) {
|
|
@@ -939,10 +769,6 @@ export class StreamResponseImpl<
|
|
|
939
769
|
return
|
|
940
770
|
}
|
|
941
771
|
|
|
942
|
-
// Consume the single-shot resume flag (only first fetch after resume skips live param)
|
|
943
|
-
const resumingFromPause = this.#justResumedFromPause
|
|
944
|
-
this.#justResumedFromPause = false
|
|
945
|
-
|
|
946
772
|
// Create a new AbortController for this request (so we can abort on pause)
|
|
947
773
|
this.#requestAbortController = new AbortController()
|
|
948
774
|
|
|
@@ -1190,34 +1016,40 @@ export class StreamResponseImpl<
|
|
|
1190
1016
|
return
|
|
1191
1017
|
}
|
|
1192
1018
|
|
|
1193
|
-
//
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
return
|
|
1199
|
-
}
|
|
1019
|
+
// Keep reading until we can enqueue at least one item.
|
|
1020
|
+
// This avoids stalling when a response contains an empty JSON array.
|
|
1021
|
+
let result = await reader.read()
|
|
1022
|
+
while (!result.done) {
|
|
1023
|
+
const response = result.value
|
|
1200
1024
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1025
|
+
// Parse JSON and flatten arrays (handle empty responses gracefully)
|
|
1026
|
+
const text = await response.text()
|
|
1027
|
+
const content = text.trim() || `[]` // Default to empty array if no content or whitespace
|
|
1028
|
+
let parsed: TJson | Array<TJson>
|
|
1029
|
+
try {
|
|
1030
|
+
parsed = JSON.parse(content) as TJson | Array<TJson>
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
const preview =
|
|
1033
|
+
content.length > 100 ? content.slice(0, 100) + `...` : content
|
|
1034
|
+
throw new DurableStreamError(
|
|
1035
|
+
`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`,
|
|
1036
|
+
`PARSE_ERROR`
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
pendingItems = Array.isArray(parsed) ? parsed : [parsed]
|
|
1216
1040
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1041
|
+
if (pendingItems.length > 0) {
|
|
1042
|
+
controller.enqueue(pendingItems.shift())
|
|
1043
|
+
return
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Empty JSON batch; read the next response.
|
|
1047
|
+
result = await reader.read()
|
|
1220
1048
|
}
|
|
1049
|
+
|
|
1050
|
+
this.#markClosed()
|
|
1051
|
+
controller.close()
|
|
1052
|
+
return
|
|
1221
1053
|
},
|
|
1222
1054
|
|
|
1223
1055
|
cancel: () => {
|
|
@@ -1271,7 +1103,12 @@ export class StreamResponseImpl<
|
|
|
1271
1103
|
// Get metadata from Response headers (not from `this` which may be stale)
|
|
1272
1104
|
const response = result.value
|
|
1273
1105
|
const { offset, cursor, upToDate, streamClosed } =
|
|
1274
|
-
|
|
1106
|
+
getMetadataFromResponse(
|
|
1107
|
+
response,
|
|
1108
|
+
this.offset,
|
|
1109
|
+
this.cursor,
|
|
1110
|
+
this.streamClosed
|
|
1111
|
+
)
|
|
1275
1112
|
|
|
1276
1113
|
// Get response text first (handles empty responses gracefully)
|
|
1277
1114
|
const text = await response.text()
|
|
@@ -1339,7 +1176,12 @@ export class StreamResponseImpl<
|
|
|
1339
1176
|
// Get metadata from Response headers (not from `this` which may be stale)
|
|
1340
1177
|
const response = result.value
|
|
1341
1178
|
const { offset, cursor, upToDate, streamClosed } =
|
|
1342
|
-
|
|
1179
|
+
getMetadataFromResponse(
|
|
1180
|
+
response,
|
|
1181
|
+
this.offset,
|
|
1182
|
+
this.cursor,
|
|
1183
|
+
this.streamClosed
|
|
1184
|
+
)
|
|
1343
1185
|
|
|
1344
1186
|
const buffer = await response.arrayBuffer()
|
|
1345
1187
|
|
|
@@ -1393,7 +1235,12 @@ export class StreamResponseImpl<
|
|
|
1393
1235
|
// Get metadata from Response headers (not from `this` which may be stale)
|
|
1394
1236
|
const response = result.value
|
|
1395
1237
|
const { offset, cursor, upToDate, streamClosed } =
|
|
1396
|
-
|
|
1238
|
+
getMetadataFromResponse(
|
|
1239
|
+
response,
|
|
1240
|
+
this.offset,
|
|
1241
|
+
this.cursor,
|
|
1242
|
+
this.streamClosed
|
|
1243
|
+
)
|
|
1397
1244
|
|
|
1398
1245
|
const text = await response.text()
|
|
1399
1246
|
|
|
@@ -1445,3 +1292,185 @@ export class StreamResponseImpl<
|
|
|
1445
1292
|
return this.#closed
|
|
1446
1293
|
}
|
|
1447
1294
|
}
|
|
1295
|
+
|
|
1296
|
+
// =================================
|
|
1297
|
+
// Pure helper functions
|
|
1298
|
+
// =================================
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Extract stream metadata from Response headers.
|
|
1302
|
+
* Falls back to the provided defaults when headers are absent.
|
|
1303
|
+
*/
|
|
1304
|
+
function getMetadataFromResponse(
|
|
1305
|
+
response: Response,
|
|
1306
|
+
fallbackOffset: Offset,
|
|
1307
|
+
fallbackCursor: string | undefined,
|
|
1308
|
+
fallbackStreamClosed: boolean
|
|
1309
|
+
): {
|
|
1310
|
+
offset: Offset
|
|
1311
|
+
cursor: string | undefined
|
|
1312
|
+
upToDate: boolean
|
|
1313
|
+
streamClosed: boolean
|
|
1314
|
+
} {
|
|
1315
|
+
const offset = response.headers.get(STREAM_OFFSET_HEADER)
|
|
1316
|
+
const cursor = response.headers.get(STREAM_CURSOR_HEADER)
|
|
1317
|
+
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
|
|
1318
|
+
const streamClosed =
|
|
1319
|
+
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
1320
|
+
return {
|
|
1321
|
+
offset: offset ?? fallbackOffset,
|
|
1322
|
+
cursor: cursor ?? fallbackCursor,
|
|
1323
|
+
upToDate,
|
|
1324
|
+
streamClosed: streamClosed || fallbackStreamClosed,
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Decode base64 string to Uint8Array.
|
|
1330
|
+
* Per protocol: concatenate data lines, remove \n and \r, then decode.
|
|
1331
|
+
*/
|
|
1332
|
+
function decodeBase64(base64Str: string): Uint8Array {
|
|
1333
|
+
// Remove all newlines and carriage returns per protocol
|
|
1334
|
+
const cleaned = base64Str.replace(/[\n\r]/g, ``)
|
|
1335
|
+
|
|
1336
|
+
// Empty string is valid
|
|
1337
|
+
if (cleaned.length === 0) {
|
|
1338
|
+
return new Uint8Array(0)
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Validate length is multiple of 4
|
|
1342
|
+
if (cleaned.length % 4 !== 0) {
|
|
1343
|
+
throw new DurableStreamError(
|
|
1344
|
+
`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`,
|
|
1345
|
+
`PARSE_ERROR`
|
|
1346
|
+
)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
try {
|
|
1350
|
+
// Prefer Buffer (native C++ in Node) over atob (requires JS charCodeAt loop)
|
|
1351
|
+
if (typeof Buffer !== `undefined`) {
|
|
1352
|
+
return new Uint8Array(Buffer.from(cleaned, `base64`))
|
|
1353
|
+
} else {
|
|
1354
|
+
const binaryStr = atob(cleaned)
|
|
1355
|
+
const bytes = new Uint8Array(binaryStr.length)
|
|
1356
|
+
for (let i = 0; i < binaryStr.length; i++) {
|
|
1357
|
+
bytes[i] = binaryStr.charCodeAt(i)
|
|
1358
|
+
}
|
|
1359
|
+
return bytes
|
|
1360
|
+
}
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
throw new DurableStreamError(
|
|
1363
|
+
`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`,
|
|
1364
|
+
`PARSE_ERROR`
|
|
1365
|
+
)
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Create a synthetic Response from SSE data with proper headers.
|
|
1371
|
+
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
|
|
1372
|
+
*/
|
|
1373
|
+
function createSSESyntheticResponse(
|
|
1374
|
+
data: string,
|
|
1375
|
+
offset: Offset,
|
|
1376
|
+
cursor: string | undefined,
|
|
1377
|
+
upToDate: boolean,
|
|
1378
|
+
streamClosed: boolean,
|
|
1379
|
+
contentType: string | undefined,
|
|
1380
|
+
encoding: `base64` | undefined
|
|
1381
|
+
): Response {
|
|
1382
|
+
return createSSESyntheticResponseFromParts(
|
|
1383
|
+
[data],
|
|
1384
|
+
offset,
|
|
1385
|
+
cursor,
|
|
1386
|
+
upToDate,
|
|
1387
|
+
streamClosed,
|
|
1388
|
+
contentType,
|
|
1389
|
+
encoding
|
|
1390
|
+
)
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Create a synthetic Response from multiple SSE data parts.
|
|
1395
|
+
* For base64 mode, each part is independently encoded, so we decode each
|
|
1396
|
+
* separately and concatenate the binary results.
|
|
1397
|
+
* For text mode, parts are simply concatenated as strings.
|
|
1398
|
+
*/
|
|
1399
|
+
function createSSESyntheticResponseFromParts(
|
|
1400
|
+
dataParts: Array<string>,
|
|
1401
|
+
offset: Offset,
|
|
1402
|
+
cursor: string | undefined,
|
|
1403
|
+
upToDate: boolean,
|
|
1404
|
+
streamClosed: boolean,
|
|
1405
|
+
contentType: string | undefined,
|
|
1406
|
+
encoding: `base64` | undefined,
|
|
1407
|
+
isJsonMode?: boolean
|
|
1408
|
+
): Response {
|
|
1409
|
+
const headers: Record<string, string> = {
|
|
1410
|
+
"content-type": contentType ?? `application/json`,
|
|
1411
|
+
[STREAM_OFFSET_HEADER]: String(offset),
|
|
1412
|
+
}
|
|
1413
|
+
if (cursor) {
|
|
1414
|
+
headers[STREAM_CURSOR_HEADER] = cursor
|
|
1415
|
+
}
|
|
1416
|
+
if (upToDate) {
|
|
1417
|
+
headers[STREAM_UP_TO_DATE_HEADER] = `true`
|
|
1418
|
+
}
|
|
1419
|
+
if (streamClosed) {
|
|
1420
|
+
headers[STREAM_CLOSED_HEADER] = `true`
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// Decode base64 if encoding is used
|
|
1424
|
+
let body: BodyInit
|
|
1425
|
+
if (encoding === `base64`) {
|
|
1426
|
+
// Each data part is independently base64 encoded, decode each separately
|
|
1427
|
+
const decodedParts = dataParts
|
|
1428
|
+
.filter((part) => part.length > 0)
|
|
1429
|
+
.map((part) => decodeBase64(part))
|
|
1430
|
+
|
|
1431
|
+
if (decodedParts.length === 0) {
|
|
1432
|
+
// No data - return empty body
|
|
1433
|
+
body = new ArrayBuffer(0)
|
|
1434
|
+
} else if (decodedParts.length === 1) {
|
|
1435
|
+
// Single part - use directly
|
|
1436
|
+
const decoded = decodedParts[0]!
|
|
1437
|
+
body = decoded.buffer.slice(
|
|
1438
|
+
decoded.byteOffset,
|
|
1439
|
+
decoded.byteOffset + decoded.byteLength
|
|
1440
|
+
) as ArrayBuffer
|
|
1441
|
+
} else {
|
|
1442
|
+
// Multiple parts - concatenate binary data
|
|
1443
|
+
const totalLength = decodedParts.reduce(
|
|
1444
|
+
(sum, part) => sum + part.length,
|
|
1445
|
+
0
|
|
1446
|
+
)
|
|
1447
|
+
const combined = new Uint8Array(totalLength)
|
|
1448
|
+
let offset = 0
|
|
1449
|
+
for (const part of decodedParts) {
|
|
1450
|
+
combined.set(part, offset)
|
|
1451
|
+
offset += part.length
|
|
1452
|
+
}
|
|
1453
|
+
body = combined.buffer
|
|
1454
|
+
}
|
|
1455
|
+
} else if (isJsonMode) {
|
|
1456
|
+
const mergedParts: Array<string> = []
|
|
1457
|
+
for (const part of dataParts) {
|
|
1458
|
+
const trimmed = part.trim()
|
|
1459
|
+
if (trimmed.length === 0) continue
|
|
1460
|
+
|
|
1461
|
+
if (trimmed.startsWith(`[`) && trimmed.endsWith(`]`)) {
|
|
1462
|
+
const inner = trimmed.slice(1, -1).trim()
|
|
1463
|
+
if (inner.length > 0) {
|
|
1464
|
+
mergedParts.push(inner)
|
|
1465
|
+
}
|
|
1466
|
+
} else {
|
|
1467
|
+
mergedParts.push(trimmed)
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
body = `[${mergedParts.join(`,`)}]`
|
|
1471
|
+
} else {
|
|
1472
|
+
body = dataParts.join(``)
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
return new Response(body, { status: 200, headers })
|
|
1476
|
+
}
|