@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 CHANGED
@@ -41,7 +41,7 @@ const res = await stream<{ message: string }>({
41
41
  Authorization: `Bearer ${process.env.DS_TOKEN!}`,
42
42
  },
43
43
  offset: savedOffset, // optional: resume from offset
44
- live: "auto", // default: behavior driven by consumption method
44
+ live: true, // default: auto-select best live mode
45
45
  })
46
46
 
47
47
  // Accumulate all JSON items until up-to-date
@@ -207,7 +207,7 @@ const res = await stream<TJson>({
207
207
  fetch?: typeof fetch, // Custom fetch implementation
208
208
  backoffOptions?: BackoffOptions,// Retry backoff configuration
209
209
  offset?: Offset, // Starting offset (default: start of stream)
210
- live?: LiveMode, // Live mode (default: "auto")
210
+ live?: LiveMode, // Live mode (default: true)
211
211
  json?: boolean, // Force JSON mode
212
212
  onError?: StreamErrorHandler, // Error handler
213
213
  })
@@ -232,6 +232,7 @@ class DurableStream {
232
232
  head(opts?: { signal?: AbortSignal }): Promise<HeadResult>
233
233
  create(opts?: CreateOptions): Promise<this>
234
234
  delete(opts?: { signal?: AbortSignal }): Promise<void>
235
+ close(opts?: CloseOptions): Promise<CloseResult> // Close stream (EOF)
235
236
  append(
236
237
  body: BodyInit | Uint8Array | string,
237
238
  opts?: AppendOptions
@@ -249,9 +250,10 @@ class DurableStream {
249
250
  ### Live Modes
250
251
 
251
252
  ```typescript
252
- // "auto" (default): behavior driven by consumption method
253
+ // true (default): auto-select best live mode
254
+ // - SSE for JSON streams, long-poll for binary
253
255
  // - Promise helpers (body/json/text): stop after upToDate
254
- // - Streams/subscribers: continue with long-poll
256
+ // - Streams/subscribers: continue with live updates
255
257
 
256
258
  // false: catch-up only, stop at first upToDate
257
259
  const res = await stream({ url, live: false })
@@ -263,6 +265,28 @@ const res = await stream({ url, live: "long-poll" })
263
265
  const res = await stream({ url, live: "sse" })
264
266
  ```
265
267
 
268
+ ### Binary Streams with SSE
269
+
270
+ For binary content types (e.g., `application/octet-stream`), SSE mode requires the `encoding` option:
271
+
272
+ ```typescript
273
+ const stream = await DurableStream.create({
274
+ url: "https://streams.example.com/my-binary-stream",
275
+ contentType: "application/octet-stream",
276
+ })
277
+
278
+ const response = await stream.read({
279
+ live: "sse",
280
+ encoding: "base64",
281
+ })
282
+
283
+ response.subscribe((chunk) => {
284
+ console.log(chunk.data) // Uint8Array - automatically decoded from base64
285
+ })
286
+ ```
287
+
288
+ The client automatically decodes base64 data events before returning them. This is required for any content type other than `text/*` or `application/json` when using SSE mode.
289
+
266
290
  ### Headers and Params
267
291
 
268
292
  Headers and params support both static values and functions (sync or async) for dynamic values like authentication tokens.
@@ -514,7 +538,7 @@ Subscribers provide callback-based consumption with backpressure. The next chunk
514
538
  Subscribe to JSON batches with metadata. Provides backpressure-aware consumption.
515
539
 
516
540
  ```typescript
517
- const res = await stream<{ event: string }>({ url, live: "auto" })
541
+ const res = await stream<{ event: string }>({ url, live: true })
518
542
 
519
543
  const unsubscribe = res.subscribeJson(async (batch) => {
520
544
  // Process items - next batch waits until this resolves
@@ -535,7 +559,7 @@ setTimeout(() => {
535
559
  Subscribe to byte chunks with metadata.
536
560
 
537
561
  ```typescript
538
- const res = await stream({ url, live: "auto" })
562
+ const res = await stream({ url, live: true })
539
563
 
540
564
  const unsubscribe = res.subscribeBytes(async (chunk) => {
541
565
  console.log("Received bytes:", chunk.data.length)
@@ -552,7 +576,7 @@ const unsubscribe = res.subscribeBytes(async (chunk) => {
552
576
  Subscribe to text chunks with metadata.
553
577
 
554
578
  ```typescript
555
- const res = await stream({ url, live: "auto" })
579
+ const res = await stream({ url, live: true })
556
580
 
557
581
  const unsubscribe = res.subscribeText(async (chunk) => {
558
582
  console.log("Text:", chunk.text)
@@ -569,7 +593,7 @@ const unsubscribe = res.subscribeText(async (chunk) => {
569
593
  Cancel the stream session. Aborts any pending requests.
570
594
 
571
595
  ```typescript
572
- const res = await stream({ url, live: "auto" })
596
+ const res = await stream({ url, live: true })
573
597
 
574
598
  // Start consuming
575
599
  res.subscribeBytes(async (chunk) => {
@@ -604,11 +628,12 @@ const res = await stream({ url })
604
628
 
605
629
  res.url // The stream URL
606
630
  res.contentType // Content-Type from response headers
607
- res.live // The live mode ("auto", "long-poll", "sse", or false)
631
+ res.live // The live mode (true, "long-poll", "sse", or false)
608
632
  res.startOffset // The starting offset passed to stream()
609
633
  res.offset // Current offset (updates as data is consumed)
610
634
  res.cursor // Cursor for collapsing (if provided by server)
611
635
  res.upToDate // Whether we've caught up to the stream head
636
+ res.streamClosed // Whether the stream is permanently closed (EOF)
612
637
  ```
613
638
 
614
639
  ---
@@ -825,7 +850,7 @@ const handle = await DurableStream.connect({
825
850
 
826
851
  const res = await handle.stream<{ message: string }>({
827
852
  offset: savedOffset,
828
- live: "auto",
853
+ live: true,
829
854
  })
830
855
 
831
856
  res.subscribeJson(async (batch) => {
@@ -896,12 +921,35 @@ Send any pending batch immediately and wait for all in-flight batches to complet
896
921
  await producer.flush()
897
922
  ```
898
923
 
899
- #### `close(): Promise<void>`
924
+ #### `close(finalMessage?): Promise<CloseResult>`
900
925
 
901
- Flush pending messages and close the producer. Further `append()` calls will throw.
926
+ Flush pending messages and close the underlying **stream** (EOF). This is the typical way to end a producer session:
927
+
928
+ 1. Flushes all pending messages
929
+ 2. Optionally appends a final message atomically with close
930
+ 3. Closes the stream (no further appends permitted by any producer)
931
+
932
+ **Idempotent**: Safe to retry on network failures - uses producer headers for deduplication.
902
933
 
903
934
  ```typescript
904
- await producer.close()
935
+ // Close stream (EOF)
936
+ const result = await producer.close()
937
+ console.log("Final offset:", result.finalOffset)
938
+
939
+ // Close with final message (atomic append + close)
940
+ const result = await producer.close('{"done": true}')
941
+ ```
942
+
943
+ #### `detach(): Promise<void>`
944
+
945
+ Stop the producer without closing the underlying stream. Use this when:
946
+
947
+ - Handing off writing to another producer
948
+ - Keeping the stream open for future writes
949
+ - Stopping this producer but not signaling EOF to readers
950
+
951
+ ```typescript
952
+ await producer.detach() // Stream remains open
905
953
  ```
906
954
 
907
955
  #### `restart(): Promise<void>`
@@ -948,19 +996,165 @@ await producer.flush() // Wait for all batches to complete
948
996
 
949
997
  ---
950
998
 
999
+ ## Stream Closure (EOF)
1000
+
1001
+ Durable Streams supports permanently closing streams to signal EOF (End of File). Once closed, no further appends are permitted, but data remains fully readable.
1002
+
1003
+ ### Writer Side
1004
+
1005
+ #### Using DurableStream.close()
1006
+
1007
+ ```typescript
1008
+ const stream = await DurableStream.connect({ url })
1009
+
1010
+ // Simple close (no final message)
1011
+ const result = await stream.close()
1012
+ console.log("Final offset:", result.finalOffset)
1013
+
1014
+ // Atomic append-and-close with final message
1015
+ const result = await stream.close({
1016
+ body: '{"status": "complete"}',
1017
+ })
1018
+ ```
1019
+
1020
+ **Options:**
1021
+
1022
+ ```typescript
1023
+ interface CloseOptions {
1024
+ body?: Uint8Array | string // Optional final message
1025
+ contentType?: string // Content type (must match stream)
1026
+ signal?: AbortSignal // Cancellation
1027
+ }
1028
+
1029
+ interface CloseResult {
1030
+ finalOffset: Offset // The offset after the last byte
1031
+ }
1032
+ ```
1033
+
1034
+ **Idempotency:**
1035
+
1036
+ - `close()` without body: Idempotent — safe to call multiple times
1037
+ - `close({ body })` with body: NOT idempotent — throws `StreamClosedError` if already closed. Use `IdempotentProducer.close(finalMessage)` for idempotent close-with-body.
1038
+
1039
+ #### Using IdempotentProducer.close()
1040
+
1041
+ For reliable close with final message (safe to retry):
1042
+
1043
+ ```typescript
1044
+ const producer = new IdempotentProducer(stream, "producer-1", {
1045
+ autoClaim: true,
1046
+ })
1047
+
1048
+ // Write some messages
1049
+ producer.append('{"event": "start"}')
1050
+ producer.append('{"event": "data"}')
1051
+
1052
+ // Close with final message (idempotent, safe to retry)
1053
+ const result = await producer.close('{"event": "end"}')
1054
+ ```
1055
+
1056
+ **Important:** `IdempotentProducer.close()` closes the **stream**, not just the producer. Use `detach()` to stop the producer without closing the stream.
1057
+
1058
+ #### Creating Closed Streams
1059
+
1060
+ Create a stream that's immediately closed (useful for cached responses, errors, single-shot data):
1061
+
1062
+ ```typescript
1063
+ // Empty closed stream
1064
+ const stream = await DurableStream.create({
1065
+ url: "https://streams.example.com/cached-response",
1066
+ contentType: "application/json",
1067
+ closed: true,
1068
+ })
1069
+
1070
+ // Closed stream with initial content
1071
+ const stream = await DurableStream.create({
1072
+ url: "https://streams.example.com/error-response",
1073
+ contentType: "application/json",
1074
+ body: '{"error": "Service unavailable"}',
1075
+ closed: true,
1076
+ })
1077
+ ```
1078
+
1079
+ ### Reader Side
1080
+
1081
+ #### Detecting Closure
1082
+
1083
+ The `streamClosed` property indicates when a stream is permanently closed:
1084
+
1085
+ ```typescript
1086
+ // StreamResponse properties
1087
+ const res = await stream({ url, live: true })
1088
+ console.log(res.streamClosed) // false initially
1089
+
1090
+ // In subscribers - batch/chunk metadata includes streamClosed
1091
+ res.subscribeJson((batch) => {
1092
+ console.log("Items:", batch.items)
1093
+ console.log("Stream closed:", batch.streamClosed) // true when EOF reached
1094
+ })
1095
+
1096
+ // In HEAD requests
1097
+ const metadata = await stream.head()
1098
+ console.log("Stream closed:", metadata.streamClosed)
1099
+ ```
1100
+
1101
+ #### Live Mode Behavior
1102
+
1103
+ When a stream is closed:
1104
+
1105
+ - **Long-poll**: Returns immediately with `streamClosed: true` (no waiting)
1106
+ - **SSE**: Sends `streamClosed: true` in final control event, then closes connection
1107
+ - **Subscribers**: Receive final batch with `streamClosed: true`, then stop
1108
+
1109
+ ```typescript
1110
+ const res = await stream({ url, live: true })
1111
+
1112
+ res.subscribeJson((batch) => {
1113
+ for (const item of batch.items) {
1114
+ process(item)
1115
+ }
1116
+
1117
+ if (batch.streamClosed) {
1118
+ console.log("Stream complete, no more data will arrive")
1119
+ // Connection will close automatically
1120
+ }
1121
+ })
1122
+ ```
1123
+
1124
+ ### Error Handling
1125
+
1126
+ Attempting to append to a closed stream throws `StreamClosedError`:
1127
+
1128
+ ```typescript
1129
+ import { StreamClosedError } from "@durable-streams/client"
1130
+
1131
+ try {
1132
+ await stream.append("data")
1133
+ } catch (error) {
1134
+ if (error instanceof StreamClosedError) {
1135
+ console.log("Stream is closed at offset:", error.finalOffset)
1136
+ }
1137
+ }
1138
+ ```
1139
+
1140
+ ---
1141
+
951
1142
  ## Types
952
1143
 
953
1144
  Key types exported from the package:
954
1145
 
955
1146
  - `Offset` - Opaque string for stream position
956
- - `StreamResponse` - Response object from stream()
957
- - `ByteChunk` - `{ data: Uint8Array, offset: Offset, upToDate: boolean, cursor?: string }`
958
- - `JsonBatch<T>` - `{ items: T[], offset: Offset, upToDate: boolean, cursor?: string }`
959
- - `TextChunk` - `{ text: string, offset: Offset, upToDate: boolean, cursor?: string }`
960
- - `HeadResult` - Metadata from HEAD requests
1147
+ - `StreamResponse` - Response object from stream() (includes `streamClosed` property)
1148
+ - `ByteChunk` - `{ data: Uint8Array, offset: Offset, upToDate: boolean, streamClosed: boolean, cursor?: string }`
1149
+ - `JsonBatch<T>` - `{ items: T[], offset: Offset, upToDate: boolean, streamClosed: boolean, cursor?: string }`
1150
+ - `TextChunk` - `{ text: string, offset: Offset, upToDate: boolean, streamClosed: boolean, cursor?: string }`
1151
+ - `HeadResult` - Metadata from HEAD requests (includes `streamClosed` property)
1152
+ - `CloseOptions` - Options for closing a stream
1153
+ - `CloseResult` - Result from closing a stream (includes `finalOffset`)
961
1154
  - `IdempotentProducer` - Exactly-once producer class
962
1155
  - `StaleEpochError` - Thrown when producer epoch is stale (zombie fencing)
963
1156
  - `SequenceGapError` - Thrown when sequence numbers are out of order
1157
+ - `StreamClosedError` - Thrown when attempting to append to a closed stream (includes `finalOffset`)
964
1158
  - `DurableStreamError` - Protocol-level errors with codes
965
1159
  - `FetchError` - Transport/network errors
966
1160