@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/sse.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface SSEControlEvent {
|
|
|
22
22
|
streamNextOffset: Offset
|
|
23
23
|
streamCursor?: string
|
|
24
24
|
upToDate?: boolean
|
|
25
|
+
streamClosed?: boolean
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export type SSEEvent = SSEDataEvent | SSEControlEvent
|
|
@@ -73,12 +74,14 @@ export async function* parseSSEStream(
|
|
|
73
74
|
streamNextOffset: Offset
|
|
74
75
|
streamCursor?: string
|
|
75
76
|
upToDate?: boolean
|
|
77
|
+
streamClosed?: boolean
|
|
76
78
|
}
|
|
77
79
|
yield {
|
|
78
80
|
type: `control`,
|
|
79
81
|
streamNextOffset: control.streamNextOffset,
|
|
80
82
|
streamCursor: control.streamCursor,
|
|
81
83
|
upToDate: control.upToDate,
|
|
84
|
+
streamClosed: control.streamClosed,
|
|
82
85
|
}
|
|
83
86
|
} catch (err) {
|
|
84
87
|
// Control events contain critical offset data - don't silently ignore
|
|
@@ -94,7 +97,11 @@ export async function* parseSSEStream(
|
|
|
94
97
|
}
|
|
95
98
|
currentEvent = { data: [] }
|
|
96
99
|
} else if (line.startsWith(`event:`)) {
|
|
97
|
-
|
|
100
|
+
// Per SSE spec, strip only one optional space after "event:"
|
|
101
|
+
const eventType = line.slice(6)
|
|
102
|
+
currentEvent.type = eventType.startsWith(` `)
|
|
103
|
+
? eventType.slice(1)
|
|
104
|
+
: eventType
|
|
98
105
|
} else if (line.startsWith(`data:`)) {
|
|
99
106
|
// Per SSE spec, strip the optional space after "data:"
|
|
100
107
|
const content = line.slice(5)
|
|
@@ -123,12 +130,14 @@ export async function* parseSSEStream(
|
|
|
123
130
|
streamNextOffset: Offset
|
|
124
131
|
streamCursor?: string
|
|
125
132
|
upToDate?: boolean
|
|
133
|
+
streamClosed?: boolean
|
|
126
134
|
}
|
|
127
135
|
yield {
|
|
128
136
|
type: `control`,
|
|
129
137
|
streamNextOffset: control.streamNextOffset,
|
|
130
138
|
streamCursor: control.streamCursor,
|
|
131
139
|
upToDate: control.upToDate,
|
|
140
|
+
streamClosed: control.streamClosed,
|
|
132
141
|
}
|
|
133
142
|
} catch (err) {
|
|
134
143
|
const preview =
|
package/src/stream-api.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
import {
|
|
8
8
|
LIVE_QUERY_PARAM,
|
|
9
9
|
OFFSET_QUERY_PARAM,
|
|
10
|
+
STREAM_CLOSED_HEADER,
|
|
10
11
|
STREAM_CURSOR_HEADER,
|
|
11
12
|
STREAM_OFFSET_HEADER,
|
|
13
|
+
STREAM_SSE_DATA_ENCODING_HEADER,
|
|
12
14
|
STREAM_UP_TO_DATE_HEADER,
|
|
13
15
|
} from "./constants"
|
|
14
16
|
import { DurableStreamError, FetchBackoffAbortError } from "./error"
|
|
@@ -190,12 +192,21 @@ async function streamInternal<TJson = unknown>(
|
|
|
190
192
|
const initialCursor =
|
|
191
193
|
firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? undefined
|
|
192
194
|
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER)
|
|
195
|
+
const initialStreamClosed =
|
|
196
|
+
firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
193
197
|
|
|
194
198
|
// Determine if JSON mode
|
|
195
199
|
const isJsonMode =
|
|
196
200
|
options.json === true ||
|
|
197
201
|
(contentType?.includes(`application/json`) ?? false)
|
|
198
202
|
|
|
203
|
+
// Detect SSE data encoding from response header (server auto-sets for binary streams)
|
|
204
|
+
const sseDataEncoding = firstResponse.headers.get(
|
|
205
|
+
STREAM_SSE_DATA_ENCODING_HEADER
|
|
206
|
+
)
|
|
207
|
+
const encoding =
|
|
208
|
+
sseDataEncoding === `base64` ? (`base64` as const) : undefined
|
|
209
|
+
|
|
199
210
|
// Create the fetch function for subsequent requests
|
|
200
211
|
const fetchNext = async (
|
|
201
212
|
offset: Offset,
|
|
@@ -288,10 +299,12 @@ async function streamInternal<TJson = unknown>(
|
|
|
288
299
|
initialOffset,
|
|
289
300
|
initialCursor,
|
|
290
301
|
initialUpToDate,
|
|
302
|
+
initialStreamClosed,
|
|
291
303
|
firstResponse,
|
|
292
304
|
abortController,
|
|
293
305
|
fetchNext,
|
|
294
306
|
startSSE,
|
|
295
307
|
sseResilience: options.sseResilience,
|
|
308
|
+
encoding,
|
|
296
309
|
})
|
|
297
310
|
}
|
package/src/stream.ts
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import fastq from "fastq"
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
DurableStreamError,
|
|
11
10
|
InvalidSignalError,
|
|
12
11
|
MissingStreamUrlError,
|
|
12
|
+
StreamClosedError,
|
|
13
13
|
} from "./error"
|
|
14
14
|
import { IdempotentProducer } from "./idempotent-producer"
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
STREAM_CLOSED_HEADER,
|
|
17
17
|
STREAM_EXPIRES_AT_HEADER,
|
|
18
18
|
STREAM_OFFSET_HEADER,
|
|
19
19
|
STREAM_SEQ_HEADER,
|
|
@@ -35,6 +35,8 @@ import type { BackoffOptions } from "./fetch"
|
|
|
35
35
|
import type { queueAsPromised } from "fastq"
|
|
36
36
|
import type {
|
|
37
37
|
AppendOptions,
|
|
38
|
+
CloseOptions,
|
|
39
|
+
CloseResult,
|
|
38
40
|
CreateOptions,
|
|
39
41
|
HeadResult,
|
|
40
42
|
HeadersRecord,
|
|
@@ -204,6 +206,7 @@ export class DurableStream {
|
|
|
204
206
|
ttlSeconds: opts.ttlSeconds,
|
|
205
207
|
expiresAt: opts.expiresAt,
|
|
206
208
|
body: opts.body,
|
|
209
|
+
closed: opts.closed,
|
|
207
210
|
})
|
|
208
211
|
return stream
|
|
209
212
|
}
|
|
@@ -269,6 +272,8 @@ export class DurableStream {
|
|
|
269
272
|
const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? undefined
|
|
270
273
|
const etag = response.headers.get(`etag`) ?? undefined
|
|
271
274
|
const cacheControl = response.headers.get(`cache-control`) ?? undefined
|
|
275
|
+
const streamClosed =
|
|
276
|
+
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
272
277
|
|
|
273
278
|
// Update instance contentType
|
|
274
279
|
if (contentType) {
|
|
@@ -281,6 +286,7 @@ export class DurableStream {
|
|
|
281
286
|
offset,
|
|
282
287
|
etag,
|
|
283
288
|
cacheControl,
|
|
289
|
+
streamClosed,
|
|
284
290
|
}
|
|
285
291
|
}
|
|
286
292
|
|
|
@@ -300,6 +306,9 @@ export class DurableStream {
|
|
|
300
306
|
if (opts?.expiresAt) {
|
|
301
307
|
requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt
|
|
302
308
|
}
|
|
309
|
+
if (opts?.closed) {
|
|
310
|
+
requestHeaders[STREAM_CLOSED_HEADER] = `true`
|
|
311
|
+
}
|
|
303
312
|
|
|
304
313
|
const body = encodeBody(opts?.body)
|
|
305
314
|
|
|
@@ -342,6 +351,84 @@ export class DurableStream {
|
|
|
342
351
|
}
|
|
343
352
|
}
|
|
344
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Close the stream, optionally with a final message.
|
|
356
|
+
*
|
|
357
|
+
* After closing:
|
|
358
|
+
* - No further appends are permitted (server returns 409)
|
|
359
|
+
* - Readers can observe the closed state and treat it as EOF
|
|
360
|
+
* - The stream's data remains fully readable
|
|
361
|
+
*
|
|
362
|
+
* Closing is:
|
|
363
|
+
* - **Durable**: The closed state is persisted
|
|
364
|
+
* - **Monotonic**: Once closed, a stream cannot be reopened
|
|
365
|
+
*
|
|
366
|
+
* **Idempotency:**
|
|
367
|
+
* - `close()` without body: Idempotent — safe to call multiple times
|
|
368
|
+
* - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
|
|
369
|
+
* if stream is already closed (use `IdempotentProducer.close()` for
|
|
370
|
+
* idempotent close-with-body semantics)
|
|
371
|
+
*
|
|
372
|
+
* @returns CloseResult with the final offset
|
|
373
|
+
* @throws StreamClosedError if called with body on an already-closed stream
|
|
374
|
+
*/
|
|
375
|
+
async close(opts?: CloseOptions): Promise<CloseResult> {
|
|
376
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest()
|
|
377
|
+
|
|
378
|
+
const contentType =
|
|
379
|
+
opts?.contentType ?? this.#options.contentType ?? this.contentType
|
|
380
|
+
if (contentType) {
|
|
381
|
+
requestHeaders[`content-type`] = contentType
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Always send Stream-Closed: true header for close operation
|
|
385
|
+
requestHeaders[STREAM_CLOSED_HEADER] = `true`
|
|
386
|
+
|
|
387
|
+
// For JSON mode with body, wrap in array
|
|
388
|
+
let body: BodyInit | undefined
|
|
389
|
+
if (opts?.body !== undefined) {
|
|
390
|
+
const isJson = normalizeContentType(contentType) === `application/json`
|
|
391
|
+
if (isJson) {
|
|
392
|
+
const bodyStr =
|
|
393
|
+
typeof opts.body === `string`
|
|
394
|
+
? opts.body
|
|
395
|
+
: new TextDecoder().decode(opts.body)
|
|
396
|
+
body = `[${bodyStr}]`
|
|
397
|
+
} else {
|
|
398
|
+
body =
|
|
399
|
+
typeof opts.body === `string`
|
|
400
|
+
? opts.body
|
|
401
|
+
: (opts.body as unknown as BodyInit)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
406
|
+
method: `POST`,
|
|
407
|
+
headers: requestHeaders,
|
|
408
|
+
body,
|
|
409
|
+
signal: opts?.signal ?? this.#options.signal,
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Check for 409 Conflict with Stream-Closed header
|
|
413
|
+
if (response.status === 409) {
|
|
414
|
+
const isClosed =
|
|
415
|
+
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
416
|
+
if (isClosed) {
|
|
417
|
+
const finalOffset =
|
|
418
|
+
response.headers.get(STREAM_OFFSET_HEADER) ?? undefined
|
|
419
|
+
throw new StreamClosedError(this.url, finalOffset)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
await handleErrorResponse(response, this.url)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
|
|
428
|
+
|
|
429
|
+
return { finalOffset }
|
|
430
|
+
}
|
|
431
|
+
|
|
345
432
|
/**
|
|
346
433
|
* Append a single payload to the stream.
|
|
347
434
|
*
|
|
@@ -403,9 +490,24 @@ export class DurableStream {
|
|
|
403
490
|
// For JSON mode, wrap body in array to match protocol (server flattens one level)
|
|
404
491
|
// Input is pre-serialized JSON string
|
|
405
492
|
const isJson = normalizeContentType(contentType) === `application/json`
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
493
|
+
let encodedBody: BodyInit
|
|
494
|
+
if (isJson) {
|
|
495
|
+
// JSON mode: decode as UTF-8 string and wrap in array
|
|
496
|
+
const bodyStr =
|
|
497
|
+
typeof body === `string` ? body : new TextDecoder().decode(body)
|
|
498
|
+
encodedBody = `[${bodyStr}]`
|
|
499
|
+
} else {
|
|
500
|
+
// Binary mode: preserve raw bytes
|
|
501
|
+
// Use ArrayBuffer for cross-platform BodyInit compatibility
|
|
502
|
+
if (typeof body === `string`) {
|
|
503
|
+
encodedBody = body
|
|
504
|
+
} else {
|
|
505
|
+
encodedBody = body.buffer.slice(
|
|
506
|
+
body.byteOffset,
|
|
507
|
+
body.byteOffset + body.byteLength
|
|
508
|
+
) as ArrayBuffer
|
|
509
|
+
}
|
|
510
|
+
}
|
|
409
511
|
|
|
410
512
|
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
411
513
|
method: `POST`,
|
|
@@ -522,11 +624,43 @@ export class DurableStream {
|
|
|
522
624
|
)
|
|
523
625
|
batchedBody = `[${jsonStrings.join(`,`)}]`
|
|
524
626
|
} else {
|
|
525
|
-
// For byte mode:
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
627
|
+
// For byte mode: preserve original data types
|
|
628
|
+
// - Strings are concatenated as strings (for text/* content types)
|
|
629
|
+
// - Uint8Arrays are concatenated as binary (for application/octet-stream)
|
|
630
|
+
// - Mixed types: convert all to binary to avoid data corruption
|
|
631
|
+
const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array)
|
|
632
|
+
const hasString = batch.some((m) => typeof m.data === `string`)
|
|
633
|
+
|
|
634
|
+
if (hasUint8Array && !hasString) {
|
|
635
|
+
// All binary: concatenate Uint8Arrays
|
|
636
|
+
const chunks = batch.map((m) => m.data as Uint8Array)
|
|
637
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
|
|
638
|
+
const combined = new Uint8Array(totalLength)
|
|
639
|
+
let offset = 0
|
|
640
|
+
for (const chunk of chunks) {
|
|
641
|
+
combined.set(chunk, offset)
|
|
642
|
+
offset += chunk.length
|
|
643
|
+
}
|
|
644
|
+
batchedBody = combined
|
|
645
|
+
} else if (hasString && !hasUint8Array) {
|
|
646
|
+
// All strings: concatenate as string
|
|
647
|
+
batchedBody = batch.map((m) => m.data as string).join(``)
|
|
648
|
+
} else {
|
|
649
|
+
// Mixed types: convert strings to binary and concatenate
|
|
650
|
+
// This preserves binary data integrity
|
|
651
|
+
const encoder = new TextEncoder()
|
|
652
|
+
const chunks = batch.map((m) =>
|
|
653
|
+
typeof m.data === `string` ? encoder.encode(m.data) : m.data
|
|
654
|
+
)
|
|
655
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
|
|
656
|
+
const combined = new Uint8Array(totalLength)
|
|
657
|
+
let offset = 0
|
|
658
|
+
for (const chunk of chunks) {
|
|
659
|
+
combined.set(chunk, offset)
|
|
660
|
+
offset += chunk.length
|
|
661
|
+
}
|
|
662
|
+
batchedBody = combined
|
|
663
|
+
}
|
|
530
664
|
}
|
|
531
665
|
|
|
532
666
|
// Combine signals: stream-level signal + any per-message signals
|
|
@@ -682,12 +816,13 @@ export class DurableStream {
|
|
|
682
816
|
producer.append(chunk)
|
|
683
817
|
},
|
|
684
818
|
async close() {
|
|
685
|
-
|
|
819
|
+
// close() flushes pending and closes the stream (EOF)
|
|
686
820
|
await producer.close()
|
|
687
821
|
if (writeError) throw writeError // Causes pipeTo() to reject
|
|
688
822
|
},
|
|
689
823
|
abort(_reason) {
|
|
690
|
-
|
|
824
|
+
// detach() stops the producer without closing the stream
|
|
825
|
+
producer.detach().catch((err) => {
|
|
691
826
|
opts?.onError?.(err) // Report instead of swallowing
|
|
692
827
|
})
|
|
693
828
|
},
|
|
@@ -736,20 +871,6 @@ export class DurableStream {
|
|
|
736
871
|
async stream<TJson = unknown>(
|
|
737
872
|
options?: Omit<StreamOptions, `url`>
|
|
738
873
|
): Promise<StreamResponse<TJson>> {
|
|
739
|
-
// Check SSE compatibility if SSE mode is requested
|
|
740
|
-
if (options?.live === `sse` && this.contentType) {
|
|
741
|
-
const isSSECompatible = SSE_COMPATIBLE_CONTENT_TYPES.some((prefix) =>
|
|
742
|
-
this.contentType!.startsWith(prefix)
|
|
743
|
-
)
|
|
744
|
-
if (!isSSECompatible) {
|
|
745
|
-
throw new DurableStreamError(
|
|
746
|
-
`SSE is not supported for content-type: ${this.contentType}`,
|
|
747
|
-
`SSE_NOT_SUPPORTED`,
|
|
748
|
-
400
|
|
749
|
-
)
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
874
|
// Merge handle-level and call-specific headers
|
|
754
875
|
const mergedHeaders: HeadersRecord = {
|
|
755
876
|
...this.#options.headers,
|
package/src/types.ts
CHANGED
|
@@ -228,6 +228,12 @@ export interface JsonBatchMeta {
|
|
|
228
228
|
* Last Stream-Cursor / streamCursor, if present.
|
|
229
229
|
*/
|
|
230
230
|
cursor?: string
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Whether the stream is closed and this batch contains the final data.
|
|
234
|
+
* When true, no more data will ever be appended to the stream.
|
|
235
|
+
*/
|
|
236
|
+
streamClosed: boolean
|
|
231
237
|
}
|
|
232
238
|
|
|
233
239
|
/**
|
|
@@ -357,6 +363,18 @@ export interface CreateOptions extends StreamHandleOptions {
|
|
|
357
363
|
* @default true
|
|
358
364
|
*/
|
|
359
365
|
batching?: boolean
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* If true, create the stream in the closed state.
|
|
369
|
+
* Any body provided becomes the complete and final content.
|
|
370
|
+
*
|
|
371
|
+
* Useful for:
|
|
372
|
+
* - Cached responses
|
|
373
|
+
* - Placeholder errors
|
|
374
|
+
* - Pre-computed results
|
|
375
|
+
* - Single-message streams that are immediately complete
|
|
376
|
+
*/
|
|
377
|
+
closed?: boolean
|
|
360
378
|
}
|
|
361
379
|
|
|
362
380
|
/**
|
|
@@ -403,6 +421,41 @@ export interface AppendOptions {
|
|
|
403
421
|
producerSeq?: number
|
|
404
422
|
}
|
|
405
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Result of a close operation.
|
|
426
|
+
*/
|
|
427
|
+
export interface CloseResult {
|
|
428
|
+
/**
|
|
429
|
+
* The final offset of the stream.
|
|
430
|
+
* This is the offset after the last byte (including any final message).
|
|
431
|
+
* Returned via the `Stream-Next-Offset` header.
|
|
432
|
+
*/
|
|
433
|
+
finalOffset: Offset
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Options for closing a stream.
|
|
438
|
+
*/
|
|
439
|
+
export interface CloseOptions {
|
|
440
|
+
/**
|
|
441
|
+
* Optional final message to append atomically with close.
|
|
442
|
+
* For JSON streams, pass a pre-serialized JSON string.
|
|
443
|
+
* Strings are UTF-8 encoded.
|
|
444
|
+
*/
|
|
445
|
+
body?: Uint8Array | string
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Content type for the final message.
|
|
449
|
+
* Defaults to the stream's content type. Must match if provided.
|
|
450
|
+
*/
|
|
451
|
+
contentType?: string
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* AbortSignal for this operation.
|
|
455
|
+
*/
|
|
456
|
+
signal?: AbortSignal
|
|
457
|
+
}
|
|
458
|
+
|
|
406
459
|
/**
|
|
407
460
|
* Legacy live mode type (internal use only).
|
|
408
461
|
* @internal
|
|
@@ -470,6 +523,12 @@ export interface HeadResult {
|
|
|
470
523
|
* Cache-Control header value.
|
|
471
524
|
*/
|
|
472
525
|
cacheControl?: string
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Whether the stream is closed.
|
|
529
|
+
* When true, no further appends are permitted.
|
|
530
|
+
*/
|
|
531
|
+
streamClosed: boolean
|
|
473
532
|
}
|
|
474
533
|
|
|
475
534
|
/**
|
|
@@ -519,6 +578,7 @@ export type DurableStreamErrorCode =
|
|
|
519
578
|
| `ALREADY_CONSUMED`
|
|
520
579
|
| `ALREADY_CLOSED`
|
|
521
580
|
| `PARSE_ERROR`
|
|
581
|
+
| `STREAM_CLOSED`
|
|
522
582
|
| `UNKNOWN`
|
|
523
583
|
|
|
524
584
|
/**
|
|
@@ -676,6 +736,19 @@ export interface StreamResponse<TJson = unknown> {
|
|
|
676
736
|
*/
|
|
677
737
|
readonly upToDate: boolean
|
|
678
738
|
|
|
739
|
+
/**
|
|
740
|
+
* Whether the stream is closed (EOF).
|
|
741
|
+
*
|
|
742
|
+
* When true, no more data will ever be appended to the stream.
|
|
743
|
+
* This is updated after each chunk is delivered to the consumer.
|
|
744
|
+
*
|
|
745
|
+
* In live mode, when streamClosed becomes true:
|
|
746
|
+
* - Long-poll requests return immediately (no waiting)
|
|
747
|
+
* - SSE connections are closed by the server
|
|
748
|
+
* - Clients stop reconnecting automatically
|
|
749
|
+
*/
|
|
750
|
+
readonly streamClosed: boolean
|
|
751
|
+
|
|
679
752
|
// =================================
|
|
680
753
|
// 1) Accumulating helpers (Promise)
|
|
681
754
|
// =================================
|
package/src/utils.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Shared utility functions for the Durable Streams client.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { STREAM_CLOSED_HEADER, STREAM_OFFSET_HEADER } from "./constants"
|
|
6
|
+
import { DurableStreamError, StreamClosedError } from "./error"
|
|
6
7
|
import type { HeadersRecord, MaybePromise } from "./types"
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -45,6 +46,14 @@ export async function handleErrorResponse(
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
if (status === 409) {
|
|
49
|
+
// Check if this is a stream closed error
|
|
50
|
+
const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER)
|
|
51
|
+
if (streamClosedHeader?.toLowerCase() === `true`) {
|
|
52
|
+
const finalOffset =
|
|
53
|
+
response.headers.get(STREAM_OFFSET_HEADER) ?? undefined
|
|
54
|
+
throw new StreamClosedError(url, finalOffset)
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
// Context-specific 409 messages
|
|
49
58
|
const message =
|
|
50
59
|
context?.operation === `create`
|