@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 CHANGED
@@ -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
@@ -264,6 +265,28 @@ const res = await stream({ url, live: "long-poll" })
264
265
  const res = await stream({ url, live: "sse" })
265
266
  ```
266
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
+
267
290
  ### Headers and Params
268
291
 
269
292
  Headers and params support both static values and functions (sync or async) for dynamic values like authentication tokens.
@@ -610,6 +633,7 @@ res.startOffset // The starting offset passed to stream()
610
633
  res.offset // Current offset (updates as data is consumed)
611
634
  res.cursor // Cursor for collapsing (if provided by server)
612
635
  res.upToDate // Whether we've caught up to the stream head
636
+ res.streamClosed // Whether the stream is permanently closed (EOF)
613
637
  ```
614
638
 
615
639
  ---
@@ -897,12 +921,35 @@ Send any pending batch immediately and wait for all in-flight batches to complet
897
921
  await producer.flush()
898
922
  ```
899
923
 
900
- #### `close(): Promise<void>`
924
+ #### `close(finalMessage?): Promise<CloseResult>`
925
+
926
+ Flush pending messages and close the underlying **stream** (EOF). This is the typical way to end a producer session:
901
927
 
902
- Flush pending messages and close the producer. Further `append()` calls will throw.
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.
903
933
 
904
934
  ```typescript
905
- 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
906
953
  ```
907
954
 
908
955
  #### `restart(): Promise<void>`
@@ -949,19 +996,165 @@ await producer.flush() // Wait for all batches to complete
949
996
 
950
997
  ---
951
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
+
952
1142
  ## Types
953
1143
 
954
1144
  Key types exported from the package:
955
1145
 
956
1146
  - `Offset` - Opaque string for stream position
957
- - `StreamResponse` - Response object from stream()
958
- - `ByteChunk` - `{ data: Uint8Array, offset: Offset, upToDate: boolean, cursor?: string }`
959
- - `JsonBatch<T>` - `{ items: T[], offset: Offset, upToDate: boolean, cursor?: string }`
960
- - `TextChunk` - `{ text: string, offset: Offset, upToDate: boolean, cursor?: string }`
961
- - `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`)
962
1154
  - `IdempotentProducer` - Exactly-once producer class
963
1155
  - `StaleEpochError` - Thrown when producer epoch is stale (zombie fencing)
964
1156
  - `SequenceGapError` - Thrown when sequence numbers are out of order
1157
+ - `StreamClosedError` - Thrown when attempting to append to a closed stream (includes `finalOffset`)
965
1158
  - `DurableStreamError` - Protocol-level errors with codes
966
1159
  - `FetchError` - Transport/network errors
967
1160