@durable-streams/client 0.2.0 → 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/README.md +201 -8
- package/bin/intent.js +6 -0
- package/dist/index.cjs +624 -135
- package/dist/index.d.cts +139 -9
- package/dist/index.d.ts +139 -9
- package/dist/index.js +622 -136
- 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/constants.ts +19 -2
- package/src/error.ts +20 -0
- package/src/idempotent-producer.ts +144 -5
- package/src/index.ts +7 -0
- package/src/response.ts +376 -188
- package/src/sse.ts +10 -1
- package/src/stream-api.ts +13 -0
- package/src/stream-response-state.ts +306 -0
- package/src/stream.ts +147 -26
- package/src/types.ts +73 -0
- package/src/utils.ts +10 -1
package/src/response.ts
CHANGED
|
@@ -7,14 +7,17 @@
|
|
|
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,
|
|
13
14
|
} from "./constants"
|
|
14
15
|
import { DurableStreamError } from "./error"
|
|
15
16
|
import { parseSSEStream } from "./sse"
|
|
17
|
+
import { LongPollState, PausedState, SSEState } from "./stream-response-state"
|
|
16
18
|
import type { ReadableStreamAsyncIterable } from "./asyncIterableReadableStream"
|
|
17
19
|
import type { SSEControlEvent, SSEEvent } from "./sse"
|
|
20
|
+
import type { StreamResponseState } from "./stream-response-state"
|
|
18
21
|
import type {
|
|
19
22
|
ByteChunk,
|
|
20
23
|
StreamResponse as IStreamResponse,
|
|
@@ -55,6 +58,8 @@ export interface StreamResponseConfig {
|
|
|
55
58
|
initialCursor?: string
|
|
56
59
|
/** Initial upToDate from first response headers */
|
|
57
60
|
initialUpToDate: boolean
|
|
61
|
+
/** Initial streamClosed from first response headers */
|
|
62
|
+
initialStreamClosed: boolean
|
|
58
63
|
/** The held first Response object */
|
|
59
64
|
firstResponse: Response
|
|
60
65
|
/** Abort controller for the session */
|
|
@@ -74,6 +79,8 @@ export interface StreamResponseConfig {
|
|
|
74
79
|
) => Promise<Response>
|
|
75
80
|
/** SSE resilience options */
|
|
76
81
|
sseResilience?: SSEResilienceOptions
|
|
82
|
+
/** Encoding for SSE data events */
|
|
83
|
+
encoding?: `base64`
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
/**
|
|
@@ -95,10 +102,8 @@ export class StreamResponseImpl<
|
|
|
95
102
|
#ok: boolean
|
|
96
103
|
#isLoading: boolean
|
|
97
104
|
|
|
98
|
-
// --- Evolving state ---
|
|
99
|
-
#
|
|
100
|
-
#cursor?: string
|
|
101
|
-
#upToDate: boolean
|
|
105
|
+
// --- Evolving state (immutable state machine) ---
|
|
106
|
+
#syncState: StreamResponseState
|
|
102
107
|
|
|
103
108
|
// --- Internal state ---
|
|
104
109
|
#isJsonMode: boolean
|
|
@@ -117,13 +122,12 @@ export class StreamResponseImpl<
|
|
|
117
122
|
#unsubscribeFromVisibilityChanges?: () => void
|
|
118
123
|
#pausePromise?: Promise<void>
|
|
119
124
|
#pauseResolve?: () => void
|
|
120
|
-
#justResumedFromPause = false
|
|
121
125
|
|
|
122
|
-
// --- SSE Resilience
|
|
126
|
+
// --- SSE Resilience Config ---
|
|
123
127
|
#sseResilience: Required<SSEResilienceOptions>
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
#
|
|
128
|
+
|
|
129
|
+
// --- SSE Encoding State ---
|
|
130
|
+
#encoding?: `base64`
|
|
127
131
|
|
|
128
132
|
// Core primitive: a ReadableStream of Response objects
|
|
129
133
|
#responseStream: ReadableStream<Response>
|
|
@@ -133,9 +137,18 @@ export class StreamResponseImpl<
|
|
|
133
137
|
this.contentType = config.contentType
|
|
134
138
|
this.live = config.live
|
|
135
139
|
this.startOffset = config.startOffset
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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)
|
|
139
152
|
|
|
140
153
|
// Initialize response metadata from first response
|
|
141
154
|
this.#headers = config.firstResponse.headers
|
|
@@ -162,6 +175,9 @@ export class StreamResponseImpl<
|
|
|
162
175
|
logWarnings: config.sseResilience?.logWarnings ?? true,
|
|
163
176
|
}
|
|
164
177
|
|
|
178
|
+
// Initialize SSE encoding
|
|
179
|
+
this.#encoding = config.encoding
|
|
180
|
+
|
|
165
181
|
this.#closed = new Promise((resolve, reject) => {
|
|
166
182
|
this.#closedResolve = resolve
|
|
167
183
|
this.#closedReject = reject
|
|
@@ -233,6 +249,8 @@ export class StreamResponseImpl<
|
|
|
233
249
|
#pause(): void {
|
|
234
250
|
if (this.#state === `active`) {
|
|
235
251
|
this.#state = `pause-requested`
|
|
252
|
+
// Wrap state in PausedState to preserve it across pause/resume
|
|
253
|
+
this.#syncState = this.#syncState.pause()
|
|
236
254
|
// Create promise that pull() will await
|
|
237
255
|
this.#pausePromise = new Promise((resolve) => {
|
|
238
256
|
this.#pauseResolve = resolve
|
|
@@ -253,9 +271,13 @@ export class StreamResponseImpl<
|
|
|
253
271
|
return
|
|
254
272
|
}
|
|
255
273
|
|
|
274
|
+
// Unwrap PausedState to restore the inner state
|
|
275
|
+
if (this.#syncState instanceof PausedState) {
|
|
276
|
+
this.#syncState = this.#syncState.resume().state
|
|
277
|
+
}
|
|
278
|
+
|
|
256
279
|
// Transition to active and resolve the pause promise
|
|
257
280
|
this.#state = `active`
|
|
258
|
-
this.#justResumedFromPause = true // Flag for single-shot skip of live param
|
|
259
281
|
this.#pauseResolve?.()
|
|
260
282
|
this.#pausePromise = undefined
|
|
261
283
|
this.#pauseResolve = undefined
|
|
@@ -284,18 +306,22 @@ export class StreamResponseImpl<
|
|
|
284
306
|
return this.#isLoading
|
|
285
307
|
}
|
|
286
308
|
|
|
287
|
-
// --- Evolving state getters ---
|
|
309
|
+
// --- Evolving state getters (delegated to state machine) ---
|
|
288
310
|
|
|
289
311
|
get offset(): Offset {
|
|
290
|
-
return this.#offset
|
|
312
|
+
return this.#syncState.offset
|
|
291
313
|
}
|
|
292
314
|
|
|
293
315
|
get cursor(): string | undefined {
|
|
294
|
-
return this.#cursor
|
|
316
|
+
return this.#syncState.cursor
|
|
295
317
|
}
|
|
296
318
|
|
|
297
319
|
get upToDate(): boolean {
|
|
298
|
-
return this.#upToDate
|
|
320
|
+
return this.#syncState.upToDate
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get streamClosed(): boolean {
|
|
324
|
+
return this.#syncState.streamClosed
|
|
299
325
|
}
|
|
300
326
|
|
|
301
327
|
// =================================
|
|
@@ -338,26 +364,27 @@ export class StreamResponseImpl<
|
|
|
338
364
|
|
|
339
365
|
/**
|
|
340
366
|
* Determine if we should continue with live updates based on live mode
|
|
341
|
-
* and whether we've received upToDate.
|
|
367
|
+
* and whether we've received upToDate or streamClosed.
|
|
342
368
|
*/
|
|
343
369
|
#shouldContinueLive(): boolean {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return true
|
|
370
|
+
return this.#syncState.shouldContinueLive(
|
|
371
|
+
this.#stopAfterUpToDate,
|
|
372
|
+
this.live
|
|
373
|
+
)
|
|
349
374
|
}
|
|
350
375
|
|
|
351
376
|
/**
|
|
352
377
|
* Update state from response headers.
|
|
353
378
|
*/
|
|
354
379
|
#updateStateFromResponse(response: Response): void {
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
+
})
|
|
361
388
|
|
|
362
389
|
// Update response metadata to reflect latest server response
|
|
363
390
|
this.#headers = response.headers
|
|
@@ -366,125 +393,28 @@ export class StreamResponseImpl<
|
|
|
366
393
|
this.#ok = response.ok
|
|
367
394
|
}
|
|
368
395
|
|
|
369
|
-
/**
|
|
370
|
-
* Extract stream metadata from Response headers.
|
|
371
|
-
* Used by subscriber APIs to get the correct offset/cursor/upToDate for each
|
|
372
|
-
* specific Response, rather than reading from `this` which may be stale due to
|
|
373
|
-
* ReadableStream prefetching or timing issues.
|
|
374
|
-
*/
|
|
375
|
-
#getMetadataFromResponse(response: Response): {
|
|
376
|
-
offset: Offset
|
|
377
|
-
cursor: string | undefined
|
|
378
|
-
upToDate: boolean
|
|
379
|
-
} {
|
|
380
|
-
const offset = response.headers.get(STREAM_OFFSET_HEADER)
|
|
381
|
-
const cursor = response.headers.get(STREAM_CURSOR_HEADER)
|
|
382
|
-
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
|
|
383
|
-
return {
|
|
384
|
-
offset: offset ?? this.offset, // Fall back to instance state if no header
|
|
385
|
-
cursor: cursor ?? this.cursor,
|
|
386
|
-
upToDate,
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Create a synthetic Response from SSE data with proper headers.
|
|
392
|
-
* Includes offset/cursor/upToDate in headers so subscribers can read them.
|
|
393
|
-
*/
|
|
394
|
-
#createSSESyntheticResponse(
|
|
395
|
-
data: string,
|
|
396
|
-
offset: Offset,
|
|
397
|
-
cursor: string | undefined,
|
|
398
|
-
upToDate: boolean
|
|
399
|
-
): Response {
|
|
400
|
-
const headers: Record<string, string> = {
|
|
401
|
-
"content-type": this.contentType ?? `application/json`,
|
|
402
|
-
[STREAM_OFFSET_HEADER]: String(offset),
|
|
403
|
-
}
|
|
404
|
-
if (cursor) {
|
|
405
|
-
headers[STREAM_CURSOR_HEADER] = cursor
|
|
406
|
-
}
|
|
407
|
-
if (upToDate) {
|
|
408
|
-
headers[STREAM_UP_TO_DATE_HEADER] = `true`
|
|
409
|
-
}
|
|
410
|
-
return new Response(data, { status: 200, headers })
|
|
411
|
-
}
|
|
412
|
-
|
|
413
396
|
/**
|
|
414
397
|
* Update instance state from an SSE control event.
|
|
415
398
|
*/
|
|
416
399
|
#updateStateFromSSEControl(controlEvent: SSEControlEvent): void {
|
|
417
|
-
this.#
|
|
418
|
-
if (controlEvent.streamCursor) {
|
|
419
|
-
this.#cursor = controlEvent.streamCursor
|
|
420
|
-
}
|
|
421
|
-
if (controlEvent.upToDate !== undefined) {
|
|
422
|
-
this.#upToDate = controlEvent.upToDate
|
|
423
|
-
}
|
|
400
|
+
this.#syncState = this.#syncState.withSSEControl(controlEvent)
|
|
424
401
|
}
|
|
425
402
|
|
|
426
403
|
/**
|
|
427
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.
|
|
428
407
|
*/
|
|
429
408
|
#markSSEConnectionStart(): void {
|
|
430
|
-
this.#
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
async #handleSSEConnectionEnd(): Promise<number | null> {
|
|
438
|
-
if (this.#lastSSEConnectionStartTime === undefined) {
|
|
439
|
-
return 0 // No tracking, allow immediate reconnect
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const connectionDuration = Date.now() - this.#lastSSEConnectionStartTime
|
|
443
|
-
const wasAborted = this.#abortController.signal.aborted
|
|
444
|
-
|
|
445
|
-
if (
|
|
446
|
-
connectionDuration < this.#sseResilience.minConnectionDuration &&
|
|
447
|
-
!wasAborted
|
|
448
|
-
) {
|
|
449
|
-
// Connection was too short - likely proxy buffering or misconfiguration
|
|
450
|
-
this.#consecutiveShortSSEConnections++
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
this.#consecutiveShortSSEConnections >=
|
|
454
|
-
this.#sseResilience.maxShortConnections
|
|
455
|
-
) {
|
|
456
|
-
// Too many short connections - fall back to long polling
|
|
457
|
-
this.#sseFallbackToLongPoll = true
|
|
458
|
-
|
|
459
|
-
if (this.#sseResilience.logWarnings) {
|
|
460
|
-
console.warn(
|
|
461
|
-
`[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). ` +
|
|
462
|
-
`Falling back to long polling. ` +
|
|
463
|
-
`Your proxy must support streaming SSE responses (not buffer the complete response). ` +
|
|
464
|
-
`Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.`
|
|
465
|
-
)
|
|
466
|
-
}
|
|
467
|
-
return null // Signal to not reconnect SSE
|
|
468
|
-
} else {
|
|
469
|
-
// Add exponential backoff with full jitter to prevent tight infinite loop
|
|
470
|
-
// Formula: random(0, min(cap, base * 2^attempt))
|
|
471
|
-
const maxDelay = Math.min(
|
|
472
|
-
this.#sseResilience.backoffMaxDelay,
|
|
473
|
-
this.#sseResilience.backoffBaseDelay *
|
|
474
|
-
Math.pow(2, this.#consecutiveShortSSEConnections)
|
|
475
|
-
)
|
|
476
|
-
const delayMs = Math.floor(Math.random() * maxDelay)
|
|
477
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
478
|
-
return delayMs
|
|
479
|
-
}
|
|
480
|
-
} else if (
|
|
481
|
-
connectionDuration >= this.#sseResilience.minConnectionDuration
|
|
482
|
-
) {
|
|
483
|
-
// Connection was healthy - reset counter
|
|
484
|
-
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
|
+
})
|
|
485
416
|
}
|
|
486
|
-
|
|
487
|
-
return 0 // Allow immediate reconnect
|
|
417
|
+
this.#syncState = (this.#syncState as SSEState).startConnection(Date.now())
|
|
488
418
|
}
|
|
489
419
|
|
|
490
420
|
/**
|
|
@@ -496,8 +426,8 @@ export class StreamResponseImpl<
|
|
|
496
426
|
void,
|
|
497
427
|
undefined
|
|
498
428
|
> | null> {
|
|
499
|
-
// Check if we should fall back to long-poll
|
|
500
|
-
if (this.#
|
|
429
|
+
// Check if we should fall back to long-poll (state type encodes this)
|
|
430
|
+
if (!this.#syncState.shouldUseSse()) {
|
|
501
431
|
return null // Will cause fallback to long-poll
|
|
502
432
|
}
|
|
503
433
|
|
|
@@ -505,12 +435,37 @@ export class StreamResponseImpl<
|
|
|
505
435
|
return null
|
|
506
436
|
}
|
|
507
437
|
|
|
508
|
-
//
|
|
509
|
-
const
|
|
510
|
-
|
|
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
|
+
}
|
|
511
455
|
return null // Fallback to long-poll was triggered
|
|
512
456
|
}
|
|
513
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
|
+
|
|
514
469
|
// Track new connection start
|
|
515
470
|
this.#markSSEConnectionStart()
|
|
516
471
|
|
|
@@ -578,8 +533,24 @@ export class StreamResponseImpl<
|
|
|
578
533
|
return this.#processSSEDataEvent(event.data, sseEventIterator)
|
|
579
534
|
}
|
|
580
535
|
|
|
581
|
-
// Control event without preceding data - update state
|
|
536
|
+
// Control event without preceding data - update state
|
|
582
537
|
this.#updateStateFromSSEControl(event)
|
|
538
|
+
|
|
539
|
+
// If upToDate is signaled, yield an empty response so subscribers receive the signal
|
|
540
|
+
// This is important for empty streams and for subscribers waiting for catch-up completion
|
|
541
|
+
if (event.upToDate) {
|
|
542
|
+
const response = createSSESyntheticResponse(
|
|
543
|
+
``,
|
|
544
|
+
event.streamNextOffset,
|
|
545
|
+
event.streamCursor,
|
|
546
|
+
true,
|
|
547
|
+
event.streamClosed ?? false,
|
|
548
|
+
this.contentType,
|
|
549
|
+
this.#encoding
|
|
550
|
+
)
|
|
551
|
+
return { type: `response`, response }
|
|
552
|
+
}
|
|
553
|
+
|
|
583
554
|
return { type: `continue` }
|
|
584
555
|
}
|
|
585
556
|
|
|
@@ -587,6 +558,9 @@ export class StreamResponseImpl<
|
|
|
587
558
|
* Process an SSE data event by waiting for its corresponding control event.
|
|
588
559
|
* In SSE protocol, control events come AFTER data events.
|
|
589
560
|
* Multiple data events may arrive before a single control event - we buffer them.
|
|
561
|
+
*
|
|
562
|
+
* For base64 mode, each data event is independently base64 encoded, so we
|
|
563
|
+
* collect them as an array and decode each separately.
|
|
590
564
|
*/
|
|
591
565
|
async #processSSEDataEvent(
|
|
592
566
|
pendingData: string,
|
|
@@ -600,7 +574,8 @@ export class StreamResponseImpl<
|
|
|
600
574
|
| { type: `error`; error: Error }
|
|
601
575
|
> {
|
|
602
576
|
// Buffer to accumulate data from multiple consecutive data events
|
|
603
|
-
|
|
577
|
+
// For base64 mode, we collect as array since each event is independently encoded
|
|
578
|
+
const bufferedDataParts: Array<string> = [pendingData]
|
|
604
579
|
|
|
605
580
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
606
581
|
while (true) {
|
|
@@ -609,11 +584,15 @@ export class StreamResponseImpl<
|
|
|
609
584
|
|
|
610
585
|
if (controlDone) {
|
|
611
586
|
// Stream ended without control event - yield buffered data with current state
|
|
612
|
-
const response =
|
|
613
|
-
|
|
587
|
+
const response = createSSESyntheticResponseFromParts(
|
|
588
|
+
bufferedDataParts,
|
|
614
589
|
this.offset,
|
|
615
590
|
this.cursor,
|
|
616
|
-
this.upToDate
|
|
591
|
+
this.upToDate,
|
|
592
|
+
this.streamClosed,
|
|
593
|
+
this.contentType,
|
|
594
|
+
this.#encoding,
|
|
595
|
+
this.#isJsonMode
|
|
617
596
|
)
|
|
618
597
|
|
|
619
598
|
// Try to reconnect
|
|
@@ -636,18 +615,22 @@ export class StreamResponseImpl<
|
|
|
636
615
|
if (controlEvent.type === `control`) {
|
|
637
616
|
// Update state and create response with correct metadata
|
|
638
617
|
this.#updateStateFromSSEControl(controlEvent)
|
|
639
|
-
const response =
|
|
640
|
-
|
|
618
|
+
const response = createSSESyntheticResponseFromParts(
|
|
619
|
+
bufferedDataParts,
|
|
641
620
|
controlEvent.streamNextOffset,
|
|
642
621
|
controlEvent.streamCursor,
|
|
643
|
-
controlEvent.upToDate ?? false
|
|
622
|
+
controlEvent.upToDate ?? false,
|
|
623
|
+
controlEvent.streamClosed ?? false,
|
|
624
|
+
this.contentType,
|
|
625
|
+
this.#encoding,
|
|
626
|
+
this.#isJsonMode
|
|
644
627
|
)
|
|
645
628
|
return { type: `response`, response }
|
|
646
629
|
}
|
|
647
630
|
|
|
648
631
|
// Got another data event before control - buffer it
|
|
649
632
|
// Server sends multiple data events followed by one control event
|
|
650
|
-
|
|
633
|
+
bufferedDataParts.push(controlEvent.data)
|
|
651
634
|
}
|
|
652
635
|
}
|
|
653
636
|
|
|
@@ -762,8 +745,10 @@ export class StreamResponseImpl<
|
|
|
762
745
|
|
|
763
746
|
// Long-poll mode: continue with live updates if needed
|
|
764
747
|
if (this.#shouldContinueLive()) {
|
|
765
|
-
//
|
|
766
|
-
//
|
|
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
|
|
767
752
|
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
768
753
|
this.#state = `paused`
|
|
769
754
|
if (this.#pausePromise) {
|
|
@@ -775,6 +760,7 @@ export class StreamResponseImpl<
|
|
|
775
760
|
controller.close()
|
|
776
761
|
return
|
|
777
762
|
}
|
|
763
|
+
resumingFromPause = true
|
|
778
764
|
}
|
|
779
765
|
|
|
780
766
|
if (this.#abortController.signal.aborted) {
|
|
@@ -783,10 +769,6 @@ export class StreamResponseImpl<
|
|
|
783
769
|
return
|
|
784
770
|
}
|
|
785
771
|
|
|
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
772
|
// Create a new AbortController for this request (so we can abort on pause)
|
|
791
773
|
this.#requestAbortController = new AbortController()
|
|
792
774
|
|
|
@@ -1034,34 +1016,40 @@ export class StreamResponseImpl<
|
|
|
1034
1016
|
return
|
|
1035
1017
|
}
|
|
1036
1018
|
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
return
|
|
1043
|
-
}
|
|
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
|
|
1044
1024
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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]
|
|
1060
1040
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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()
|
|
1064
1048
|
}
|
|
1049
|
+
|
|
1050
|
+
this.#markClosed()
|
|
1051
|
+
controller.close()
|
|
1052
|
+
return
|
|
1065
1053
|
},
|
|
1066
1054
|
|
|
1067
1055
|
cancel: () => {
|
|
@@ -1114,8 +1102,13 @@ export class StreamResponseImpl<
|
|
|
1114
1102
|
|
|
1115
1103
|
// Get metadata from Response headers (not from `this` which may be stale)
|
|
1116
1104
|
const response = result.value
|
|
1117
|
-
const { offset, cursor, upToDate } =
|
|
1118
|
-
|
|
1105
|
+
const { offset, cursor, upToDate, streamClosed } =
|
|
1106
|
+
getMetadataFromResponse(
|
|
1107
|
+
response,
|
|
1108
|
+
this.offset,
|
|
1109
|
+
this.cursor,
|
|
1110
|
+
this.streamClosed
|
|
1111
|
+
)
|
|
1119
1112
|
|
|
1120
1113
|
// Get response text first (handles empty responses gracefully)
|
|
1121
1114
|
const text = await response.text()
|
|
@@ -1139,6 +1132,7 @@ export class StreamResponseImpl<
|
|
|
1139
1132
|
offset,
|
|
1140
1133
|
cursor,
|
|
1141
1134
|
upToDate,
|
|
1135
|
+
streamClosed,
|
|
1142
1136
|
})
|
|
1143
1137
|
|
|
1144
1138
|
result = await reader.read()
|
|
@@ -1181,8 +1175,13 @@ export class StreamResponseImpl<
|
|
|
1181
1175
|
|
|
1182
1176
|
// Get metadata from Response headers (not from `this` which may be stale)
|
|
1183
1177
|
const response = result.value
|
|
1184
|
-
const { offset, cursor, upToDate } =
|
|
1185
|
-
|
|
1178
|
+
const { offset, cursor, upToDate, streamClosed } =
|
|
1179
|
+
getMetadataFromResponse(
|
|
1180
|
+
response,
|
|
1181
|
+
this.offset,
|
|
1182
|
+
this.cursor,
|
|
1183
|
+
this.streamClosed
|
|
1184
|
+
)
|
|
1186
1185
|
|
|
1187
1186
|
const buffer = await response.arrayBuffer()
|
|
1188
1187
|
|
|
@@ -1192,6 +1191,7 @@ export class StreamResponseImpl<
|
|
|
1192
1191
|
offset,
|
|
1193
1192
|
cursor,
|
|
1194
1193
|
upToDate,
|
|
1194
|
+
streamClosed,
|
|
1195
1195
|
})
|
|
1196
1196
|
|
|
1197
1197
|
result = await reader.read()
|
|
@@ -1234,8 +1234,13 @@ export class StreamResponseImpl<
|
|
|
1234
1234
|
|
|
1235
1235
|
// Get metadata from Response headers (not from `this` which may be stale)
|
|
1236
1236
|
const response = result.value
|
|
1237
|
-
const { offset, cursor, upToDate } =
|
|
1238
|
-
|
|
1237
|
+
const { offset, cursor, upToDate, streamClosed } =
|
|
1238
|
+
getMetadataFromResponse(
|
|
1239
|
+
response,
|
|
1240
|
+
this.offset,
|
|
1241
|
+
this.cursor,
|
|
1242
|
+
this.streamClosed
|
|
1243
|
+
)
|
|
1239
1244
|
|
|
1240
1245
|
const text = await response.text()
|
|
1241
1246
|
|
|
@@ -1245,6 +1250,7 @@ export class StreamResponseImpl<
|
|
|
1245
1250
|
offset,
|
|
1246
1251
|
cursor,
|
|
1247
1252
|
upToDate,
|
|
1253
|
+
streamClosed,
|
|
1248
1254
|
})
|
|
1249
1255
|
|
|
1250
1256
|
result = await reader.read()
|
|
@@ -1286,3 +1292,185 @@ export class StreamResponseImpl<
|
|
|
1286
1292
|
return this.#closed
|
|
1287
1293
|
}
|
|
1288
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
|
+
}
|