@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/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 { handleErrorResponse, resolveHeaders, resolveParams } from "./utils"
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: "auto",
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
- const live: LiveMode = options.live ?? `auto`
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 in auto mode, use long-poll
201
- // BUT: if we're resuming from a paused state, don't set live mode
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 === `auto` || live === `long-poll`) {
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
- SSE_COMPATIBLE_CONTENT_TYPES,
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: unknown
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 !== null &&
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` may be Uint8Array, string, or any JSON-serializable value (for JSON streams).
354
- * - `body` may also be a Promise that resolves to any of the above types.
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
- * // Direct value
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: BodyInit | Uint8Array | string | unknown,
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: BodyInit | Uint8Array | string | unknown,
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
- const bodyToEncode = isJson ? [body] : body
403
- const encodedBody = encodeBody(bodyToEncode)
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: unknown,
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
- const values = batch.map((m) => m.data)
515
- batchedBody = JSON.stringify(values)
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: concatenate all chunks
518
- const totalSize = batch.reduce((sum, m) => {
519
- const size =
520
- typeof m.data === `string`
521
- ? new TextEncoder().encode(m.data).length
522
- : (m.data as Uint8Array).length
523
- return sum + size
524
- }, 0)
525
-
526
- const concatenated = new Uint8Array(totalSize)
527
- let offset = 0
528
- for (const msg of batch) {
529
- const bytes =
530
- typeof msg.data === `string`
531
- ? new TextEncoder().encode(msg.data)
532
- : (msg.data as Uint8Array)
533
- concatenated.set(bytes, offset)
534
- offset += bytes.length
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(opts?: AppendOptions): WritableStream<Uint8Array | string> {
649
- const chunks: Array<Uint8Array | string> = []
650
- const stream = this
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
- chunks.push(chunk)
816
+ producer.append(chunk)
655
817
  },
656
818
  async close() {
657
- if (chunks.length > 0) {
658
- // Create a ReadableStream from collected chunks
659
- const readable = new ReadableStream<Uint8Array | string>({
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(reason) {
671
- console.error(`WritableStream aborted:`, reason)
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,