@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/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:
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 (
|
|
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:
|
|
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<
|
|
924
|
+
#### `close(finalMessage?): Promise<CloseResult>`
|
|
900
925
|
|
|
901
|
-
Flush pending messages and close the
|
|
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
|
-
|
|
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
|
|