@durable-streams/client 0.2.0 → 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 +201 -8
- package/dist/index.cjs +331 -38
- package/dist/index.d.cts +139 -9
- package/dist/index.d.ts +139 -9
- package/dist/index.js +329 -39
- package/package.json +2 -2
- 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 +176 -17
- package/src/sse.ts +10 -1
- package/src/stream-api.ts +13 -0
- package/src/stream.ts +147 -26
- package/src/types.ts +73 -0
- package/src/utils.ts +10 -1
package/src/constants.ts
CHANGED
|
@@ -26,6 +26,12 @@ export const STREAM_CURSOR_HEADER = `Stream-Cursor`
|
|
|
26
26
|
*/
|
|
27
27
|
export const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Response/request header indicating stream is closed (EOF).
|
|
31
|
+
* When present with value "true", the stream is permanently closed.
|
|
32
|
+
*/
|
|
33
|
+
export const STREAM_CLOSED_HEADER = `Stream-Closed`
|
|
34
|
+
|
|
29
35
|
// ============================================================================
|
|
30
36
|
// Request Headers
|
|
31
37
|
// ============================================================================
|
|
@@ -97,6 +103,11 @@ export const LIVE_QUERY_PARAM = `live`
|
|
|
97
103
|
*/
|
|
98
104
|
export const CURSOR_QUERY_PARAM = `cursor`
|
|
99
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Response header indicating SSE data encoding (e.g., base64 for binary streams).
|
|
108
|
+
*/
|
|
109
|
+
export const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`
|
|
110
|
+
|
|
100
111
|
// ============================================================================
|
|
101
112
|
// SSE Control Event Fields (camelCase per PROTOCOL.md Section 5.7)
|
|
102
113
|
// ============================================================================
|
|
@@ -113,13 +124,19 @@ export const SSE_OFFSET_FIELD = `streamNextOffset`
|
|
|
113
124
|
*/
|
|
114
125
|
export const SSE_CURSOR_FIELD = `streamCursor`
|
|
115
126
|
|
|
127
|
+
/**
|
|
128
|
+
* SSE control event field for stream closed state.
|
|
129
|
+
* Note: Different from HTTP header name (camelCase vs Header-Case).
|
|
130
|
+
*/
|
|
131
|
+
export const SSE_CLOSED_FIELD = `streamClosed`
|
|
132
|
+
|
|
116
133
|
// ============================================================================
|
|
117
134
|
// Internal Constants
|
|
118
135
|
// ============================================================================
|
|
119
136
|
|
|
120
137
|
/**
|
|
121
|
-
* Content types that
|
|
122
|
-
*
|
|
138
|
+
* Content types that are natively compatible with SSE (UTF-8 text).
|
|
139
|
+
* Binary content types are also supported via automatic base64 encoding.
|
|
123
140
|
*/
|
|
124
141
|
export const SSE_COMPATIBLE_CONTENT_TYPES: ReadonlyArray<string> = [
|
|
125
142
|
`text/`,
|
package/src/error.ts
CHANGED
|
@@ -178,6 +178,26 @@ export class MissingStreamUrlError extends Error {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Error thrown when attempting to append to a closed stream.
|
|
183
|
+
*/
|
|
184
|
+
export class StreamClosedError extends DurableStreamError {
|
|
185
|
+
readonly code = `STREAM_CLOSED` as const
|
|
186
|
+
readonly status = 409
|
|
187
|
+
readonly streamClosed = true
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The final offset of the stream, if available from the response.
|
|
191
|
+
*/
|
|
192
|
+
readonly finalOffset?: string
|
|
193
|
+
|
|
194
|
+
constructor(url?: string, finalOffset?: string) {
|
|
195
|
+
super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url)
|
|
196
|
+
this.name = `StreamClosedError`
|
|
197
|
+
this.finalOffset = finalOffset
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
181
201
|
/**
|
|
182
202
|
* Error thrown when signal option is invalid.
|
|
183
203
|
*/
|
|
@@ -17,11 +17,12 @@ import {
|
|
|
17
17
|
PRODUCER_ID_HEADER,
|
|
18
18
|
PRODUCER_RECEIVED_SEQ_HEADER,
|
|
19
19
|
PRODUCER_SEQ_HEADER,
|
|
20
|
+
STREAM_CLOSED_HEADER,
|
|
20
21
|
STREAM_OFFSET_HEADER,
|
|
21
22
|
} from "./constants"
|
|
22
23
|
import type { queueAsPromised } from "fastq"
|
|
23
24
|
import type { DurableStream } from "./stream"
|
|
24
|
-
import type { IdempotentProducerOptions, Offset } from "./types"
|
|
25
|
+
import type { CloseResult, IdempotentProducerOptions, Offset } from "./types"
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Error thrown when a producer's epoch is stale (zombie fencing).
|
|
@@ -138,6 +139,8 @@ export class IdempotentProducer {
|
|
|
138
139
|
readonly #queue: queueAsPromised<BatchTask>
|
|
139
140
|
readonly #maxInFlight: number
|
|
140
141
|
#closed = false
|
|
142
|
+
#closeResult: CloseResult | null = null
|
|
143
|
+
#pendingFinalMessage?: Uint8Array | string
|
|
141
144
|
|
|
142
145
|
// When autoClaim is true, we must wait for the first batch to complete
|
|
143
146
|
// before allowing pipelining (to know what epoch was claimed)
|
|
@@ -318,11 +321,17 @@ export class IdempotentProducer {
|
|
|
318
321
|
}
|
|
319
322
|
|
|
320
323
|
/**
|
|
321
|
-
*
|
|
324
|
+
* Stop the producer without closing the underlying stream.
|
|
322
325
|
*
|
|
323
|
-
*
|
|
326
|
+
* Use this when you want to:
|
|
327
|
+
* - Hand off writing to another producer
|
|
328
|
+
* - Keep the stream open for future writes
|
|
329
|
+
* - Stop this producer but not signal EOF to readers
|
|
330
|
+
*
|
|
331
|
+
* Flushes any pending messages before detaching.
|
|
332
|
+
* After calling detach(), further append() calls will throw.
|
|
324
333
|
*/
|
|
325
|
-
async
|
|
334
|
+
async detach(): Promise<void> {
|
|
326
335
|
if (this.#closed) return
|
|
327
336
|
|
|
328
337
|
this.#closed = true
|
|
@@ -330,8 +339,138 @@ export class IdempotentProducer {
|
|
|
330
339
|
try {
|
|
331
340
|
await this.flush()
|
|
332
341
|
} catch {
|
|
333
|
-
// Ignore errors during
|
|
342
|
+
// Ignore errors during detach
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Flush pending messages and close the underlying stream (EOF).
|
|
348
|
+
*
|
|
349
|
+
* This is the typical way to end a producer session. It:
|
|
350
|
+
* 1. Flushes all pending messages
|
|
351
|
+
* 2. Optionally appends a final message
|
|
352
|
+
* 3. Closes the stream (no further appends permitted)
|
|
353
|
+
*
|
|
354
|
+
* **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
|
|
355
|
+
* idempotent even with a final message because it uses producer headers
|
|
356
|
+
* for deduplication. Safe to retry on network failures.
|
|
357
|
+
*
|
|
358
|
+
* @param finalMessage - Optional final message to append atomically with close
|
|
359
|
+
* @returns CloseResult with the final offset
|
|
360
|
+
*/
|
|
361
|
+
async close(finalMessage?: Uint8Array | string): Promise<CloseResult> {
|
|
362
|
+
if (this.#closed) {
|
|
363
|
+
// Already closed - return cached result for idempotency
|
|
364
|
+
if (this.#closeResult) {
|
|
365
|
+
return this.#closeResult
|
|
366
|
+
}
|
|
367
|
+
// Retry path: flush() threw on a previous attempt, so we need to re-run
|
|
368
|
+
// the entire close sequence with the stored finalMessage
|
|
369
|
+
await this.flush()
|
|
370
|
+
const result = await this.#doClose(this.#pendingFinalMessage)
|
|
371
|
+
this.#closeResult = result
|
|
372
|
+
return result
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.#closed = true
|
|
376
|
+
|
|
377
|
+
// Store finalMessage for retry safety (if flush() throws, we can retry)
|
|
378
|
+
this.#pendingFinalMessage = finalMessage
|
|
379
|
+
|
|
380
|
+
// Flush pending messages first
|
|
381
|
+
await this.flush()
|
|
382
|
+
|
|
383
|
+
// Close the stream with optional final message
|
|
384
|
+
const result = await this.#doClose(finalMessage)
|
|
385
|
+
this.#closeResult = result
|
|
386
|
+
return result
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Actually close the stream with optional final message.
|
|
391
|
+
* Uses producer headers for idempotency.
|
|
392
|
+
*/
|
|
393
|
+
async #doClose(finalMessage?: Uint8Array | string): Promise<CloseResult> {
|
|
394
|
+
const contentType = this.#stream.contentType ?? `application/octet-stream`
|
|
395
|
+
const isJson = normalizeContentType(contentType) === `application/json`
|
|
396
|
+
|
|
397
|
+
// Build body if final message is provided
|
|
398
|
+
let body: BodyInit | undefined
|
|
399
|
+
if (finalMessage !== undefined) {
|
|
400
|
+
const bodyBytes =
|
|
401
|
+
typeof finalMessage === `string`
|
|
402
|
+
? new TextEncoder().encode(finalMessage)
|
|
403
|
+
: finalMessage
|
|
404
|
+
|
|
405
|
+
if (isJson) {
|
|
406
|
+
// For JSON mode, wrap in array
|
|
407
|
+
const jsonStr = new TextDecoder().decode(bodyBytes)
|
|
408
|
+
body = `[${jsonStr}]`
|
|
409
|
+
} else {
|
|
410
|
+
body = bodyBytes as unknown as BodyInit
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Capture the sequence number for this request (for retry safety)
|
|
415
|
+
// We only increment #nextSeq after a successful response
|
|
416
|
+
const seqForThisRequest = this.#nextSeq
|
|
417
|
+
|
|
418
|
+
// Build headers with producer info and Stream-Closed
|
|
419
|
+
const headers: Record<string, string> = {
|
|
420
|
+
"content-type": contentType,
|
|
421
|
+
[PRODUCER_ID_HEADER]: this.#producerId,
|
|
422
|
+
[PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
|
|
423
|
+
[PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
|
|
424
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const response = await this.#fetchClient(this.#stream.url, {
|
|
428
|
+
method: `POST`,
|
|
429
|
+
headers,
|
|
430
|
+
body,
|
|
431
|
+
signal: this.#signal,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
// Handle 204 (duplicate close - idempotent success)
|
|
435
|
+
if (response.status === 204) {
|
|
436
|
+
// Only increment seq on success (retry-safe)
|
|
437
|
+
this.#nextSeq = seqForThisRequest + 1
|
|
438
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
|
|
439
|
+
return { finalOffset }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Handle success
|
|
443
|
+
if (response.status === 200) {
|
|
444
|
+
// Only increment seq on success (retry-safe)
|
|
445
|
+
this.#nextSeq = seqForThisRequest + 1
|
|
446
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
|
|
447
|
+
return { finalOffset }
|
|
334
448
|
}
|
|
449
|
+
|
|
450
|
+
// Handle errors
|
|
451
|
+
if (response.status === 403) {
|
|
452
|
+
// Stale epoch
|
|
453
|
+
const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER)
|
|
454
|
+
const currentEpoch = currentEpochStr
|
|
455
|
+
? parseInt(currentEpochStr, 10)
|
|
456
|
+
: this.#epoch
|
|
457
|
+
|
|
458
|
+
if (this.#autoClaim) {
|
|
459
|
+
// Auto-claim: retry with epoch+1
|
|
460
|
+
const newEpoch = currentEpoch + 1
|
|
461
|
+
this.#epoch = newEpoch
|
|
462
|
+
// Reset sequence for new epoch - set to 0 so the recursive call uses seq 0
|
|
463
|
+
// (the first operation in a new epoch should be seq 0)
|
|
464
|
+
this.#nextSeq = 0
|
|
465
|
+
return this.#doClose(finalMessage)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
throw new StaleEpochError(currentEpoch)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Other errors
|
|
472
|
+
const error = await FetchError.fromResponse(response, this.#stream.url)
|
|
473
|
+
throw error
|
|
335
474
|
}
|
|
336
475
|
|
|
337
476
|
/**
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,10 @@ export type {
|
|
|
65
65
|
HeadResult,
|
|
66
66
|
LegacyLiveMode,
|
|
67
67
|
|
|
68
|
+
// Close types
|
|
69
|
+
CloseResult,
|
|
70
|
+
CloseOptions,
|
|
71
|
+
|
|
68
72
|
// Idempotent producer types
|
|
69
73
|
IdempotentProducerOptions,
|
|
70
74
|
IdempotentAppendResult,
|
|
@@ -89,6 +93,7 @@ export {
|
|
|
89
93
|
DurableStreamError,
|
|
90
94
|
MissingStreamUrlError,
|
|
91
95
|
InvalidSignalError,
|
|
96
|
+
StreamClosedError,
|
|
92
97
|
} from "./error"
|
|
93
98
|
|
|
94
99
|
// ============================================================================
|
|
@@ -110,6 +115,7 @@ export {
|
|
|
110
115
|
STREAM_OFFSET_HEADER,
|
|
111
116
|
STREAM_CURSOR_HEADER,
|
|
112
117
|
STREAM_UP_TO_DATE_HEADER,
|
|
118
|
+
STREAM_CLOSED_HEADER,
|
|
113
119
|
STREAM_SEQ_HEADER,
|
|
114
120
|
STREAM_TTL_HEADER,
|
|
115
121
|
STREAM_EXPIRES_AT_HEADER,
|
|
@@ -117,6 +123,7 @@ export {
|
|
|
117
123
|
LIVE_QUERY_PARAM,
|
|
118
124
|
CURSOR_QUERY_PARAM,
|
|
119
125
|
SSE_COMPATIBLE_CONTENT_TYPES,
|
|
126
|
+
SSE_CLOSED_FIELD,
|
|
120
127
|
DURABLE_STREAM_PROTOCOL_QUERY_PARAMS,
|
|
121
128
|
// Idempotent producer headers
|
|
122
129
|
PRODUCER_ID_HEADER,
|
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
|
/**
|
|
@@ -99,6 +104,7 @@ export class StreamResponseImpl<
|
|
|
99
104
|
#offset: Offset
|
|
100
105
|
#cursor?: string
|
|
101
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
|
|
|
@@ -136,6 +145,7 @@ export class StreamResponseImpl<
|
|
|
136
145
|
this.#offset = config.initialOffset
|
|
137
146
|
this.#cursor = config.initialCursor
|
|
138
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
|
|
@@ -298,6 +311,10 @@ export class StreamResponseImpl<
|
|
|
298
311
|
return this.#upToDate
|
|
299
312
|
}
|
|
300
313
|
|
|
314
|
+
get streamClosed(): boolean {
|
|
315
|
+
return this.#streamClosed
|
|
316
|
+
}
|
|
317
|
+
|
|
301
318
|
// =================================
|
|
302
319
|
// Internal helpers
|
|
303
320
|
// =================================
|
|
@@ -338,13 +355,15 @@ export class StreamResponseImpl<
|
|
|
338
355
|
|
|
339
356
|
/**
|
|
340
357
|
* Determine if we should continue with live updates based on live mode
|
|
341
|
-
* and whether we've received upToDate.
|
|
358
|
+
* and whether we've received upToDate or streamClosed.
|
|
342
359
|
*/
|
|
343
360
|
#shouldContinueLive(): boolean {
|
|
344
361
|
// Stop if we've received upToDate and a consumption method wants to stop after upToDate
|
|
345
362
|
if (this.#stopAfterUpToDate && this.upToDate) return false
|
|
346
363
|
// Stop if live mode is explicitly disabled
|
|
347
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
|
|
348
367
|
return true
|
|
349
368
|
}
|
|
350
369
|
|
|
@@ -358,6 +377,10 @@ export class StreamResponseImpl<
|
|
|
358
377
|
const cursor = response.headers.get(STREAM_CURSOR_HEADER)
|
|
359
378
|
if (cursor) this.#cursor = cursor
|
|
360
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
|
+
}
|
|
361
384
|
|
|
362
385
|
// Update response metadata to reflect latest server response
|
|
363
386
|
this.#headers = response.headers
|
|
@@ -368,7 +391,7 @@ export class StreamResponseImpl<
|
|
|
368
391
|
|
|
369
392
|
/**
|
|
370
393
|
* Extract stream metadata from Response headers.
|
|
371
|
-
* 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
|
|
372
395
|
* specific Response, rather than reading from `this` which may be stale due to
|
|
373
396
|
* ReadableStream prefetching or timing issues.
|
|
374
397
|
*/
|
|
@@ -376,26 +399,94 @@ export class StreamResponseImpl<
|
|
|
376
399
|
offset: Offset
|
|
377
400
|
cursor: string | undefined
|
|
378
401
|
upToDate: boolean
|
|
402
|
+
streamClosed: boolean
|
|
379
403
|
} {
|
|
380
404
|
const offset = response.headers.get(STREAM_OFFSET_HEADER)
|
|
381
405
|
const cursor = response.headers.get(STREAM_CURSOR_HEADER)
|
|
382
406
|
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER)
|
|
407
|
+
const streamClosed =
|
|
408
|
+
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
383
409
|
return {
|
|
384
410
|
offset: offset ?? this.offset, // Fall back to instance state if no header
|
|
385
411
|
cursor: cursor ?? this.cursor,
|
|
386
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
|
+
)
|
|
387
455
|
}
|
|
388
456
|
}
|
|
389
457
|
|
|
390
458
|
/**
|
|
391
459
|
* Create a synthetic Response from SSE data with proper headers.
|
|
392
|
-
* Includes offset/cursor/upToDate in headers so subscribers can read them.
|
|
460
|
+
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
|
|
393
461
|
*/
|
|
394
462
|
#createSSESyntheticResponse(
|
|
395
463
|
data: string,
|
|
396
464
|
offset: Offset,
|
|
397
465
|
cursor: string | undefined,
|
|
398
|
-
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
|
|
399
490
|
): Response {
|
|
400
491
|
const headers: Record<string, string> = {
|
|
401
492
|
"content-type": this.contentType ?? `application/json`,
|
|
@@ -407,7 +498,47 @@ export class StreamResponseImpl<
|
|
|
407
498
|
if (upToDate) {
|
|
408
499
|
headers[STREAM_UP_TO_DATE_HEADER] = `true`
|
|
409
500
|
}
|
|
410
|
-
|
|
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 })
|
|
411
542
|
}
|
|
412
543
|
|
|
413
544
|
/**
|
|
@@ -421,6 +552,11 @@ export class StreamResponseImpl<
|
|
|
421
552
|
if (controlEvent.upToDate !== undefined) {
|
|
422
553
|
this.#upToDate = controlEvent.upToDate
|
|
423
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
|
+
}
|
|
424
560
|
}
|
|
425
561
|
|
|
426
562
|
/**
|
|
@@ -578,8 +714,22 @@ export class StreamResponseImpl<
|
|
|
578
714
|
return this.#processSSEDataEvent(event.data, sseEventIterator)
|
|
579
715
|
}
|
|
580
716
|
|
|
581
|
-
// Control event without preceding data - update state
|
|
717
|
+
// Control event without preceding data - update state
|
|
582
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
|
+
|
|
583
733
|
return { type: `continue` }
|
|
584
734
|
}
|
|
585
735
|
|
|
@@ -587,6 +737,9 @@ export class StreamResponseImpl<
|
|
|
587
737
|
* Process an SSE data event by waiting for its corresponding control event.
|
|
588
738
|
* In SSE protocol, control events come AFTER data events.
|
|
589
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.
|
|
590
743
|
*/
|
|
591
744
|
async #processSSEDataEvent(
|
|
592
745
|
pendingData: string,
|
|
@@ -600,7 +753,8 @@ export class StreamResponseImpl<
|
|
|
600
753
|
| { type: `error`; error: Error }
|
|
601
754
|
> {
|
|
602
755
|
// Buffer to accumulate data from multiple consecutive data events
|
|
603
|
-
|
|
756
|
+
// For base64 mode, we collect as array since each event is independently encoded
|
|
757
|
+
const bufferedDataParts: Array<string> = [pendingData]
|
|
604
758
|
|
|
605
759
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
606
760
|
while (true) {
|
|
@@ -609,11 +763,12 @@ export class StreamResponseImpl<
|
|
|
609
763
|
|
|
610
764
|
if (controlDone) {
|
|
611
765
|
// Stream ended without control event - yield buffered data with current state
|
|
612
|
-
const response = this.#
|
|
613
|
-
|
|
766
|
+
const response = this.#createSSESyntheticResponseFromParts(
|
|
767
|
+
bufferedDataParts,
|
|
614
768
|
this.offset,
|
|
615
769
|
this.cursor,
|
|
616
|
-
this.upToDate
|
|
770
|
+
this.upToDate,
|
|
771
|
+
this.streamClosed
|
|
617
772
|
)
|
|
618
773
|
|
|
619
774
|
// Try to reconnect
|
|
@@ -636,18 +791,19 @@ export class StreamResponseImpl<
|
|
|
636
791
|
if (controlEvent.type === `control`) {
|
|
637
792
|
// Update state and create response with correct metadata
|
|
638
793
|
this.#updateStateFromSSEControl(controlEvent)
|
|
639
|
-
const response = this.#
|
|
640
|
-
|
|
794
|
+
const response = this.#createSSESyntheticResponseFromParts(
|
|
795
|
+
bufferedDataParts,
|
|
641
796
|
controlEvent.streamNextOffset,
|
|
642
797
|
controlEvent.streamCursor,
|
|
643
|
-
controlEvent.upToDate ?? false
|
|
798
|
+
controlEvent.upToDate ?? false,
|
|
799
|
+
controlEvent.streamClosed ?? false
|
|
644
800
|
)
|
|
645
801
|
return { type: `response`, response }
|
|
646
802
|
}
|
|
647
803
|
|
|
648
804
|
// Got another data event before control - buffer it
|
|
649
805
|
// Server sends multiple data events followed by one control event
|
|
650
|
-
|
|
806
|
+
bufferedDataParts.push(controlEvent.data)
|
|
651
807
|
}
|
|
652
808
|
}
|
|
653
809
|
|
|
@@ -1114,7 +1270,7 @@ export class StreamResponseImpl<
|
|
|
1114
1270
|
|
|
1115
1271
|
// Get metadata from Response headers (not from `this` which may be stale)
|
|
1116
1272
|
const response = result.value
|
|
1117
|
-
const { offset, cursor, upToDate } =
|
|
1273
|
+
const { offset, cursor, upToDate, streamClosed } =
|
|
1118
1274
|
this.#getMetadataFromResponse(response)
|
|
1119
1275
|
|
|
1120
1276
|
// Get response text first (handles empty responses gracefully)
|
|
@@ -1139,6 +1295,7 @@ export class StreamResponseImpl<
|
|
|
1139
1295
|
offset,
|
|
1140
1296
|
cursor,
|
|
1141
1297
|
upToDate,
|
|
1298
|
+
streamClosed,
|
|
1142
1299
|
})
|
|
1143
1300
|
|
|
1144
1301
|
result = await reader.read()
|
|
@@ -1181,7 +1338,7 @@ export class StreamResponseImpl<
|
|
|
1181
1338
|
|
|
1182
1339
|
// Get metadata from Response headers (not from `this` which may be stale)
|
|
1183
1340
|
const response = result.value
|
|
1184
|
-
const { offset, cursor, upToDate } =
|
|
1341
|
+
const { offset, cursor, upToDate, streamClosed } =
|
|
1185
1342
|
this.#getMetadataFromResponse(response)
|
|
1186
1343
|
|
|
1187
1344
|
const buffer = await response.arrayBuffer()
|
|
@@ -1192,6 +1349,7 @@ export class StreamResponseImpl<
|
|
|
1192
1349
|
offset,
|
|
1193
1350
|
cursor,
|
|
1194
1351
|
upToDate,
|
|
1352
|
+
streamClosed,
|
|
1195
1353
|
})
|
|
1196
1354
|
|
|
1197
1355
|
result = await reader.read()
|
|
@@ -1234,7 +1392,7 @@ export class StreamResponseImpl<
|
|
|
1234
1392
|
|
|
1235
1393
|
// Get metadata from Response headers (not from `this` which may be stale)
|
|
1236
1394
|
const response = result.value
|
|
1237
|
-
const { offset, cursor, upToDate } =
|
|
1395
|
+
const { offset, cursor, upToDate, streamClosed } =
|
|
1238
1396
|
this.#getMetadataFromResponse(response)
|
|
1239
1397
|
|
|
1240
1398
|
const text = await response.text()
|
|
@@ -1245,6 +1403,7 @@ export class StreamResponseImpl<
|
|
|
1245
1403
|
offset,
|
|
1246
1404
|
cursor,
|
|
1247
1405
|
upToDate,
|
|
1406
|
+
streamClosed,
|
|
1248
1407
|
})
|
|
1249
1408
|
|
|
1250
1409
|
result = await reader.read()
|