@durable-streams/client 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +212 -18
- package/dist/index.cjs +1152 -805
- package/dist/index.d.cts +201 -33
- package/dist/index.d.ts +201 -33
- package/dist/index.js +1150 -806
- package/package.json +2 -2
- package/src/constants.ts +19 -2
- package/src/error.ts +20 -0
- package/src/idempotent-producer.ts +195 -43
- package/src/index.ts +7 -0
- package/src/response.ts +245 -35
- package/src/sse.ts +27 -5
- package/src/stream-api.ts +30 -10
- package/src/stream.ts +213 -71
- package/src/types.ts +97 -12
- package/src/utils.ts +10 -1
package/src/stream-api.ts
CHANGED
|
@@ -7,14 +7,21 @@
|
|
|
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"
|
|
15
17
|
import { BackoffDefaults, createFetchWithBackoff } from "./fetch"
|
|
16
18
|
import { StreamResponseImpl } from "./response"
|
|
17
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
handleErrorResponse,
|
|
21
|
+
resolveHeaders,
|
|
22
|
+
resolveParams,
|
|
23
|
+
warnIfUsingHttpInBrowser,
|
|
24
|
+
} from "./utils"
|
|
18
25
|
import type { LiveMode, Offset, StreamOptions, StreamResponse } from "./types"
|
|
19
26
|
|
|
20
27
|
/**
|
|
@@ -41,7 +48,7 @@ import type { LiveMode, Offset, StreamOptions, StreamResponse } from "./types"
|
|
|
41
48
|
* url,
|
|
42
49
|
* auth,
|
|
43
50
|
* offset: savedOffset,
|
|
44
|
-
* live:
|
|
51
|
+
* live: true,
|
|
45
52
|
* })
|
|
46
53
|
* live.subscribeJson(async (batch) => {
|
|
47
54
|
* for (const item of batch.items) {
|
|
@@ -119,6 +126,9 @@ async function streamInternal<TJson = unknown>(
|
|
|
119
126
|
// Normalize URL
|
|
120
127
|
const url = options.url instanceof URL ? options.url.toString() : options.url
|
|
121
128
|
|
|
129
|
+
// Warn if using HTTP in browser (can cause connection limit issues)
|
|
130
|
+
warnIfUsingHttpInBrowser(url, options.warnOnHttp)
|
|
131
|
+
|
|
122
132
|
// Build the first request
|
|
123
133
|
const fetchUrl = new URL(url)
|
|
124
134
|
|
|
@@ -127,7 +137,8 @@ async function streamInternal<TJson = unknown>(
|
|
|
127
137
|
fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, startOffset)
|
|
128
138
|
|
|
129
139
|
// Set live query param for explicit modes
|
|
130
|
-
|
|
140
|
+
// true means auto-select (no query param, handled by consumption method)
|
|
141
|
+
const live: LiveMode = options.live ?? true
|
|
131
142
|
if (live === `long-poll` || live === `sse`) {
|
|
132
143
|
fetchUrl.searchParams.set(LIVE_QUERY_PARAM, live)
|
|
133
144
|
}
|
|
@@ -181,12 +192,21 @@ async function streamInternal<TJson = unknown>(
|
|
|
181
192
|
const initialCursor =
|
|
182
193
|
firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? undefined
|
|
183
194
|
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER)
|
|
195
|
+
const initialStreamClosed =
|
|
196
|
+
firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
184
197
|
|
|
185
198
|
// Determine if JSON mode
|
|
186
199
|
const isJsonMode =
|
|
187
200
|
options.json === true ||
|
|
188
201
|
(contentType?.includes(`application/json`) ?? false)
|
|
189
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
|
+
|
|
190
210
|
// Create the fetch function for subsequent requests
|
|
191
211
|
const fetchNext = async (
|
|
192
212
|
offset: Offset,
|
|
@@ -197,15 +217,13 @@ async function streamInternal<TJson = unknown>(
|
|
|
197
217
|
const nextUrl = new URL(url)
|
|
198
218
|
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset)
|
|
199
219
|
|
|
200
|
-
// For subsequent requests
|
|
201
|
-
//
|
|
202
|
-
// to avoid a long-poll that holds for 20sec - we want an immediate response
|
|
203
|
-
// so the UI can show "connected" status quickly
|
|
220
|
+
// For subsequent requests, set live mode unless resuming from pause
|
|
221
|
+
// (resuming from pause needs immediate response for UI status)
|
|
204
222
|
if (!resumingFromPause) {
|
|
205
|
-
if (live === `
|
|
206
|
-
nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`)
|
|
207
|
-
} else if (live === `sse`) {
|
|
223
|
+
if (live === `sse`) {
|
|
208
224
|
nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`)
|
|
225
|
+
} else if (live === true || live === `long-poll`) {
|
|
226
|
+
nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`)
|
|
209
227
|
}
|
|
210
228
|
}
|
|
211
229
|
|
|
@@ -281,10 +299,12 @@ async function streamInternal<TJson = unknown>(
|
|
|
281
299
|
initialOffset,
|
|
282
300
|
initialCursor,
|
|
283
301
|
initialUpToDate,
|
|
302
|
+
initialStreamClosed,
|
|
284
303
|
firstResponse,
|
|
285
304
|
abortController,
|
|
286
305
|
fetchNext,
|
|
287
306
|
startSSE,
|
|
288
307
|
sseResilience: options.sseResilience,
|
|
308
|
+
encoding,
|
|
289
309
|
})
|
|
290
310
|
}
|
package/src/stream.ts
CHANGED
|
@@ -7,12 +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
|
+
import { IdempotentProducer } from "./idempotent-producer"
|
|
14
15
|
import {
|
|
15
|
-
|
|
16
|
+
STREAM_CLOSED_HEADER,
|
|
16
17
|
STREAM_EXPIRES_AT_HEADER,
|
|
17
18
|
STREAM_OFFSET_HEADER,
|
|
18
19
|
STREAM_SEQ_HEADER,
|
|
@@ -34,9 +35,12 @@ import type { BackoffOptions } from "./fetch"
|
|
|
34
35
|
import type { queueAsPromised } from "fastq"
|
|
35
36
|
import type {
|
|
36
37
|
AppendOptions,
|
|
38
|
+
CloseOptions,
|
|
39
|
+
CloseResult,
|
|
37
40
|
CreateOptions,
|
|
38
41
|
HeadResult,
|
|
39
42
|
HeadersRecord,
|
|
43
|
+
IdempotentProducerOptions,
|
|
40
44
|
MaybePromise,
|
|
41
45
|
ParamsRecord,
|
|
42
46
|
StreamErrorHandler,
|
|
@@ -49,7 +53,7 @@ import type {
|
|
|
49
53
|
* Queued message for batching.
|
|
50
54
|
*/
|
|
51
55
|
interface QueuedMessage {
|
|
52
|
-
data:
|
|
56
|
+
data: Uint8Array | string
|
|
53
57
|
seq?: string
|
|
54
58
|
contentType?: string
|
|
55
59
|
signal?: AbortSignal
|
|
@@ -71,10 +75,7 @@ function normalizeContentType(contentType: string | undefined): string {
|
|
|
71
75
|
*/
|
|
72
76
|
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
73
77
|
return (
|
|
74
|
-
value
|
|
75
|
-
typeof value === `object` &&
|
|
76
|
-
`then` in value &&
|
|
77
|
-
typeof (value as PromiseLike<unknown>).then === `function`
|
|
78
|
+
value != null && typeof (value as PromiseLike<unknown>).then === `function`
|
|
78
79
|
)
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -121,7 +122,7 @@ export interface DurableStreamOptions extends StreamHandleOptions {
|
|
|
121
122
|
* });
|
|
122
123
|
*
|
|
123
124
|
* // Write data
|
|
124
|
-
* await stream.append({ message: "hello" });
|
|
125
|
+
* await stream.append(JSON.stringify({ message: "hello" }));
|
|
125
126
|
*
|
|
126
127
|
* // Read with the new API
|
|
127
128
|
* const res = await stream.stream<{ message: string }>();
|
|
@@ -205,6 +206,7 @@ export class DurableStream {
|
|
|
205
206
|
ttlSeconds: opts.ttlSeconds,
|
|
206
207
|
expiresAt: opts.expiresAt,
|
|
207
208
|
body: opts.body,
|
|
209
|
+
closed: opts.closed,
|
|
208
210
|
})
|
|
209
211
|
return stream
|
|
210
212
|
}
|
|
@@ -270,6 +272,8 @@ export class DurableStream {
|
|
|
270
272
|
const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? undefined
|
|
271
273
|
const etag = response.headers.get(`etag`) ?? undefined
|
|
272
274
|
const cacheControl = response.headers.get(`cache-control`) ?? undefined
|
|
275
|
+
const streamClosed =
|
|
276
|
+
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
273
277
|
|
|
274
278
|
// Update instance contentType
|
|
275
279
|
if (contentType) {
|
|
@@ -282,6 +286,7 @@ export class DurableStream {
|
|
|
282
286
|
offset,
|
|
283
287
|
etag,
|
|
284
288
|
cacheControl,
|
|
289
|
+
streamClosed,
|
|
285
290
|
}
|
|
286
291
|
}
|
|
287
292
|
|
|
@@ -301,6 +306,9 @@ export class DurableStream {
|
|
|
301
306
|
if (opts?.expiresAt) {
|
|
302
307
|
requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt
|
|
303
308
|
}
|
|
309
|
+
if (opts?.closed) {
|
|
310
|
+
requestHeaders[STREAM_CLOSED_HEADER] = `true`
|
|
311
|
+
}
|
|
304
312
|
|
|
305
313
|
const body = encodeBody(opts?.body)
|
|
306
314
|
|
|
@@ -343,6 +351,84 @@ export class DurableStream {
|
|
|
343
351
|
}
|
|
344
352
|
}
|
|
345
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
|
+
|
|
346
432
|
/**
|
|
347
433
|
* Append a single payload to the stream.
|
|
348
434
|
*
|
|
@@ -350,23 +436,27 @@ export class DurableStream {
|
|
|
350
436
|
* a POST is in-flight will be batched together into a single request.
|
|
351
437
|
* This significantly improves throughput for high-frequency writes.
|
|
352
438
|
*
|
|
353
|
-
* - `body`
|
|
354
|
-
* -
|
|
439
|
+
* - `body` must be string or Uint8Array.
|
|
440
|
+
* - For JSON streams, pass pre-serialized JSON strings.
|
|
441
|
+
* - `body` may also be a Promise that resolves to string or Uint8Array.
|
|
355
442
|
* - Strings are encoded as UTF-8.
|
|
356
443
|
* - `seq` (if provided) is sent as stream-seq (writer coordination).
|
|
357
444
|
*
|
|
358
445
|
* @example
|
|
359
446
|
* ```typescript
|
|
360
|
-
* //
|
|
361
|
-
* await stream.append({ message: "hello" });
|
|
447
|
+
* // JSON stream - pass pre-serialized JSON
|
|
448
|
+
* await stream.append(JSON.stringify({ message: "hello" }));
|
|
449
|
+
*
|
|
450
|
+
* // Byte stream
|
|
451
|
+
* await stream.append("raw text data");
|
|
452
|
+
* await stream.append(new Uint8Array([1, 2, 3]));
|
|
362
453
|
*
|
|
363
454
|
* // Promise value - awaited before buffering
|
|
364
455
|
* await stream.append(fetchData());
|
|
365
|
-
* await stream.append(Promise.all([a, b, c]));
|
|
366
456
|
* ```
|
|
367
457
|
*/
|
|
368
458
|
async append(
|
|
369
|
-
body:
|
|
459
|
+
body: Uint8Array | string | Promise<Uint8Array | string>,
|
|
370
460
|
opts?: AppendOptions
|
|
371
461
|
): Promise<void> {
|
|
372
462
|
// Await promises before buffering
|
|
@@ -382,7 +472,7 @@ export class DurableStream {
|
|
|
382
472
|
* Direct append without batching (used when batching is disabled).
|
|
383
473
|
*/
|
|
384
474
|
async #appendDirect(
|
|
385
|
-
body:
|
|
475
|
+
body: Uint8Array | string,
|
|
386
476
|
opts?: AppendOptions
|
|
387
477
|
): Promise<void> {
|
|
388
478
|
const { requestHeaders, fetchUrl } = await this.#buildRequest()
|
|
@@ -398,9 +488,26 @@ export class DurableStream {
|
|
|
398
488
|
}
|
|
399
489
|
|
|
400
490
|
// For JSON mode, wrap body in array to match protocol (server flattens one level)
|
|
491
|
+
// Input is pre-serialized JSON string
|
|
401
492
|
const isJson = normalizeContentType(contentType) === `application/json`
|
|
402
|
-
|
|
403
|
-
|
|
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
|
+
}
|
|
404
511
|
|
|
405
512
|
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
406
513
|
method: `POST`,
|
|
@@ -418,7 +525,7 @@ export class DurableStream {
|
|
|
418
525
|
* Append with batching - buffers messages and sends them in batches.
|
|
419
526
|
*/
|
|
420
527
|
async #appendWithBatching(
|
|
421
|
-
body:
|
|
528
|
+
body: Uint8Array | string,
|
|
422
529
|
opts?: AppendOptions
|
|
423
530
|
): Promise<void> {
|
|
424
531
|
return new Promise<void>((resolve, reject) => {
|
|
@@ -511,29 +618,49 @@ export class DurableStream {
|
|
|
511
618
|
// For JSON mode: always send as array (server flattens one level)
|
|
512
619
|
// Single append: [value] → server stores value
|
|
513
620
|
// Multiple appends: [val1, val2] → server stores val1, val2
|
|
514
|
-
|
|
515
|
-
|
|
621
|
+
// Input is pre-serialized JSON strings, join them into an array
|
|
622
|
+
const jsonStrings = batch.map((m) =>
|
|
623
|
+
typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data)
|
|
624
|
+
)
|
|
625
|
+
batchedBody = `[${jsonStrings.join(`,`)}]`
|
|
516
626
|
} else {
|
|
517
|
-
// For byte mode:
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
535
663
|
}
|
|
536
|
-
batchedBody = concatenated
|
|
537
664
|
}
|
|
538
665
|
|
|
539
666
|
// Combine signals: stream-level signal + any per-message signals
|
|
@@ -634,6 +761,11 @@ export class DurableStream {
|
|
|
634
761
|
* Returns a WritableStream that can be used with `pipeTo()` or
|
|
635
762
|
* `pipeThrough()` from any ReadableStream source.
|
|
636
763
|
*
|
|
764
|
+
* Uses IdempotentProducer internally for:
|
|
765
|
+
* - Automatic batching (controlled by lingerMs, maxBatchBytes)
|
|
766
|
+
* - Exactly-once delivery semantics
|
|
767
|
+
* - Streaming writes (doesn't buffer entire content in memory)
|
|
768
|
+
*
|
|
637
769
|
* @example
|
|
638
770
|
* ```typescript
|
|
639
771
|
* // Pipe from fetch response
|
|
@@ -643,32 +775,56 @@ export class DurableStream {
|
|
|
643
775
|
* // Pipe through a transform
|
|
644
776
|
* const readable = someStream.pipeThrough(new TextEncoderStream());
|
|
645
777
|
* await readable.pipeTo(stream.writable());
|
|
778
|
+
*
|
|
779
|
+
* // With custom producer options
|
|
780
|
+
* await source.pipeTo(stream.writable({
|
|
781
|
+
* producerId: "my-producer",
|
|
782
|
+
* lingerMs: 10,
|
|
783
|
+
* maxBatchBytes: 64 * 1024,
|
|
784
|
+
* }));
|
|
646
785
|
* ```
|
|
647
786
|
*/
|
|
648
|
-
writable(
|
|
649
|
-
|
|
650
|
-
|
|
787
|
+
writable(
|
|
788
|
+
opts?: Pick<
|
|
789
|
+
IdempotentProducerOptions,
|
|
790
|
+
`lingerMs` | `maxBatchBytes` | `onError`
|
|
791
|
+
> & {
|
|
792
|
+
producerId?: string
|
|
793
|
+
signal?: AbortSignal
|
|
794
|
+
}
|
|
795
|
+
): WritableStream<Uint8Array | string> {
|
|
796
|
+
// Generate a random producer ID if not provided
|
|
797
|
+
const producerId =
|
|
798
|
+
opts?.producerId ?? `writable-${crypto.randomUUID().slice(0, 8)}`
|
|
799
|
+
|
|
800
|
+
// Track async errors to surface in close() so pipeTo() rejects on failure
|
|
801
|
+
let writeError: Error | null = null
|
|
802
|
+
|
|
803
|
+
const producer = new IdempotentProducer(this, producerId, {
|
|
804
|
+
autoClaim: true, // Ephemeral producer, auto-claim epoch
|
|
805
|
+
lingerMs: opts?.lingerMs,
|
|
806
|
+
maxBatchBytes: opts?.maxBatchBytes,
|
|
807
|
+
onError: (error) => {
|
|
808
|
+
if (!writeError) writeError = error // Capture first error
|
|
809
|
+
opts?.onError?.(error) // Still call user's handler
|
|
810
|
+
},
|
|
811
|
+
signal: opts?.signal ?? this.#options.signal,
|
|
812
|
+
})
|
|
651
813
|
|
|
652
814
|
return new WritableStream<Uint8Array | string>({
|
|
653
815
|
write(chunk) {
|
|
654
|
-
|
|
816
|
+
producer.append(chunk)
|
|
655
817
|
},
|
|
656
818
|
async close() {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
start(controller) {
|
|
661
|
-
for (const chunk of chunks) {
|
|
662
|
-
controller.enqueue(chunk)
|
|
663
|
-
}
|
|
664
|
-
controller.close()
|
|
665
|
-
},
|
|
666
|
-
})
|
|
667
|
-
await stream.appendStream(readable, opts)
|
|
668
|
-
}
|
|
819
|
+
// close() flushes pending and closes the stream (EOF)
|
|
820
|
+
await producer.close()
|
|
821
|
+
if (writeError) throw writeError // Causes pipeTo() to reject
|
|
669
822
|
},
|
|
670
|
-
abort(
|
|
671
|
-
|
|
823
|
+
abort(_reason) {
|
|
824
|
+
// detach() stops the producer without closing the stream
|
|
825
|
+
producer.detach().catch((err) => {
|
|
826
|
+
opts?.onError?.(err) // Report instead of swallowing
|
|
827
|
+
})
|
|
672
828
|
},
|
|
673
829
|
})
|
|
674
830
|
}
|
|
@@ -715,20 +871,6 @@ export class DurableStream {
|
|
|
715
871
|
async stream<TJson = unknown>(
|
|
716
872
|
options?: Omit<StreamOptions, `url`>
|
|
717
873
|
): Promise<StreamResponse<TJson>> {
|
|
718
|
-
// Check SSE compatibility if SSE mode is requested
|
|
719
|
-
if (options?.live === `sse` && this.contentType) {
|
|
720
|
-
const isSSECompatible = SSE_COMPATIBLE_CONTENT_TYPES.some((prefix) =>
|
|
721
|
-
this.contentType!.startsWith(prefix)
|
|
722
|
-
)
|
|
723
|
-
if (!isSSECompatible) {
|
|
724
|
-
throw new DurableStreamError(
|
|
725
|
-
`SSE is not supported for content-type: ${this.contentType}`,
|
|
726
|
-
`SSE_NOT_SUPPORTED`,
|
|
727
|
-
400
|
|
728
|
-
)
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
874
|
// Merge handle-level and call-specific headers
|
|
733
875
|
const mergedHeaders: HeadersRecord = {
|
|
734
876
|
...this.#options.headers,
|