UNPKG

@durable-streams/client

Version:

TypeScript client for the Durable Streams protocol

1,164 lines (870 loc) 31.5 kB
# @durable-streams/client TypeScript client for the Durable Streams protocol. ## Installation ```bash npm install @durable-streams/client ``` ## Overview The Durable Streams client provides three main APIs: 1. **`stream()` function** - A fetch-like read-only API for consuming streams 2. **`DurableStream` class** - A handle for read/write operations on a stream 3. **`IdempotentProducer` class** - High-throughput producer with exactly-once write semantics (recommended for writes) ## Key Features - **Exactly-Once Writes**: `IdempotentProducer` provides Kafka-style exactly-once semantics with automatic deduplication - **Automatic Batching**: Multiple writes are automatically batched together for high throughput - **Pipelining**: Up to 5 concurrent batches in flight by default for maximum throughput - **Streaming Reads**: `stream()` and `DurableStream.stream()` provide rich consumption options (promises, ReadableStreams, subscribers) - **Resumable**: Offset-based reads let you resume from any point - **Real-time**: Long-poll and SSE modes for live tailing with catch-up from any offset ## Usage ### Read-only: Using `stream()` (fetch-like API) The `stream()` function provides a simple, fetch-like interface for reading from streams: ```typescript import { stream } from "@durable-streams/client" // Connect and get a StreamResponse const res = await stream<{ message: string }>({ url: "https://streams.example.com/my-account/chat/room-1", headers: { Authorization: `Bearer ${process.env.DS_TOKEN!}`, }, offset: savedOffset, // optional: resume from offset live: true, // default: auto-select best live mode }) // Accumulate all JSON items until up-to-date const items = await res.json() console.log("All items:", items) // Or stream live with a subscriber res.subscribeJson(async (batch) => { for (const item of batch.items) { console.log("item:", item) saveOffset(batch.offset) // persist for resumption } }) ``` ### StreamResponse consumption methods The `StreamResponse` object returned by `stream()` offers multiple ways to consume data: ```typescript // Promise helpers (accumulate until first upToDate) const bytes = await res.body() // Uint8Array const items = await res.json() // Array<TJson> const text = await res.text() // string // ReadableStreams const byteStream = res.bodyStream() // ReadableStream<Uint8Array> const jsonStream = res.jsonStream() // ReadableStream<TJson> const textStream = res.textStream() // ReadableStream<string> // Subscribers (with backpressure) const unsubscribe = res.subscribeJson(async (batch) => { await processBatch(batch.items) }) const unsubscribe2 = res.subscribeBytes(async (chunk) => { await processBytes(chunk.data) }) const unsubscribe3 = res.subscribeText(async (chunk) => { await processText(chunk.text) }) ``` ### High-Throughput Writes: Using `IdempotentProducer` (Recommended) For reliable, high-throughput writes with exactly-once semantics, use `IdempotentProducer`: ```typescript import { DurableStream, IdempotentProducer } from "@durable-streams/client" const stream = await DurableStream.create({ url: "https://streams.example.com/events", contentType: "application/json", }) const producer = new IdempotentProducer(stream, "event-processor-1", { autoClaim: true, onError: (err) => console.error("Batch failed:", err), // Errors reported here }) // Fire-and-forget - don't await, errors go to onError callback for (const event of events) { producer.append(event) // Objects serialized automatically for JSON streams } // IMPORTANT: Always flush before shutdown to ensure delivery await producer.flush() await producer.close() ``` For high-throughput scenarios, `append()` is fire-and-forget (returns immediately): ```typescript // Fire-and-forget - errors reported via onError callback for (const event of events) { producer.append(event) // Returns void, adds to batch } // Always flush before shutdown to ensure delivery await producer.flush() ``` **Why use IdempotentProducer?** - **Exactly-once delivery**: Server deduplicates using `(producerId, epoch, seq)` tuple - **Automatic batching**: Multiple writes batched into single HTTP requests - **Pipelining**: Multiple batches in flight concurrently - **Zombie fencing**: Stale producers are rejected, preventing split-brain scenarios - **Network resilience**: Safe to retry on network errors (server deduplicates) ### Read/Write: Using `DurableStream` For simple write operations or when you need a persistent handle: ```typescript import { DurableStream } from "@durable-streams/client" // Create a new stream const handle = await DurableStream.create({ url: "https://streams.example.com/my-account/chat/room-1", headers: { Authorization: `Bearer ${process.env.DS_TOKEN!}`, }, contentType: "application/json", ttlSeconds: 3600, }) // Append data (simple API without exactly-once guarantees) await handle.append(JSON.stringify({ type: "message", text: "Hello" }), { seq: "writer-1-000001", }) // Read using the new stream() API const res = await handle.stream<{ type: string; text: string }>() res.subscribeJson(async (batch) => { for (const item of batch.items) { console.log("message:", item.text) } }) ``` ### Read from "now" (skip existing data) ```typescript // HEAD gives you the current tail offset if the server exposes it const handle = await DurableStream.connect({ url, headers: { Authorization: `Bearer ${token}` }, }) const { offset } = await handle.head() // Read only new data from that point on const res = await handle.stream({ offset }) res.subscribeBytes(async (chunk) => { console.log("new data:", new TextDecoder().decode(chunk.data)) }) ``` ### Read catch-up only (no live updates) ```typescript // Read existing data only, stop when up-to-date const res = await stream({ url: "https://streams.example.com/my-stream", live: false, }) const text = await res.text() console.log("All existing data:", text) ``` ## API ### `stream(options): Promise<StreamResponse>` Creates a fetch-like streaming session: ```typescript const res = await stream<TJson>({ url: string | URL, // Stream URL headers?: HeadersRecord, // Headers (static or function-based) params?: ParamsRecord, // Query params (static or function-based) signal?: AbortSignal, // Cancellation fetch?: typeof fetch, // Custom fetch implementation backoffOptions?: BackoffOptions,// Retry backoff configuration offset?: Offset, // Starting offset (default: start of stream) live?: LiveMode, // Live mode (default: true) json?: boolean, // Force JSON mode onError?: StreamErrorHandler, // Error handler }) ``` ### `DurableStream` ```typescript class DurableStream { readonly url: string readonly contentType?: string constructor(opts: DurableStreamConstructorOptions) // Static methods static create(opts: CreateOptions): Promise<DurableStream> static connect(opts: DurableStreamOptions): Promise<DurableStream> static head(opts: DurableStreamOptions): Promise<HeadResult> static delete(opts: DurableStreamOptions): Promise<void> // Instance methods head(opts?: { signal?: AbortSignal }): Promise<HeadResult> create(opts?: CreateOptions): Promise<this> delete(opts?: { signal?: AbortSignal }): Promise<void> close(opts?: CloseOptions): Promise<CloseResult> // Close stream (EOF) append( body: BodyInit | Uint8Array | string, opts?: AppendOptions ): Promise<void> appendStream( source: AsyncIterable<Uint8Array | string>, opts?: AppendOptions ): Promise<void> // Fetch-like read API stream<TJson>(opts?: StreamOptions): Promise<StreamResponse<TJson>> } ``` ### Live Modes ```typescript // true (default): auto-select best live mode // - SSE for JSON streams, long-poll for binary // - Promise helpers (body/json/text): stop after upToDate // - Streams/subscribers: continue with live updates // false: catch-up only, stop at first upToDate const res = await stream({ url, live: false }) // "long-poll": explicit long-poll mode for live updates const res = await stream({ url, live: "long-poll" }) // "sse": explicit SSE mode for live updates const res = await stream({ url, live: "sse" }) ``` ### Binary Streams with SSE For binary content types (e.g., `application/octet-stream`), SSE mode requires the `encoding` option: ```typescript const stream = await DurableStream.create({ url: "https://streams.example.com/my-binary-stream", contentType: "application/octet-stream", }) const response = await stream.read({ live: "sse", encoding: "base64", }) response.subscribe((chunk) => { console.log(chunk.data) // Uint8Array - automatically decoded from base64 }) ``` 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. ### Headers and Params Headers and params support both static values and functions (sync or async) for dynamic values like authentication tokens. ```typescript // Static headers { headers: { Authorization: "Bearer my-token", "X-Custom-Header": "value", } } // Function-based headers (sync) { headers: { Authorization: () => `Bearer ${getCurrentToken()}`, "X-Tenant-Id": () => getCurrentTenant(), } } // Async function headers (for refreshing tokens) { headers: { Authorization: async () => { const token = await refreshToken() return `Bearer ${token}` } } } // Mix static and function headers { headers: { "X-Static": "always-the-same", Authorization: async () => `Bearer ${await getToken()}`, } } // Query params work the same way { params: { tenant: "static-tenant", region: () => getCurrentRegion(), token: async () => await getSessionToken(), } } ``` ### Error Handling ```typescript import { stream, FetchError, DurableStreamError } from "@durable-streams/client" const res = await stream({ url: "https://streams.example.com/my-stream", headers: { Authorization: "Bearer my-token", }, onError: async (error) => { if (error instanceof FetchError) { if (error.status === 401) { const newToken = await refreshAuthToken() return { headers: { Authorization: `Bearer ${newToken}` } } } } if (error instanceof DurableStreamError) { console.error(`Stream error: ${error.code}`) } return {} // Retry with same params }, }) ``` ## StreamResponse Methods The `StreamResponse` object provides multiple ways to consume stream data. All methods respect the `live` mode setting. ### Promise Helpers These methods accumulate data until the stream is up-to-date, then resolve. #### `body(): Promise<Uint8Array>` Accumulates all bytes until up-to-date. ```typescript const res = await stream({ url, live: false }) const bytes = await res.body() console.log("Total bytes:", bytes.length) // Process as needed const text = new TextDecoder().decode(bytes) ``` #### `json(): Promise<Array<TJson>>` Accumulates all JSON items until up-to-date. Only works with JSON content. ```typescript const res = await stream<{ id: number; name: string }>({ url, live: false, }) const items = await res.json() for (const item of items) { console.log(`User ${item.id}: ${item.name}`) } ``` #### `text(): Promise<string>` Accumulates all text until up-to-date. ```typescript const res = await stream({ url, live: false }) const text = await res.text() console.log("Full content:", text) ``` ### ReadableStreams Web Streams API for piping to other streams or using with streaming APIs. ReadableStreams can be consumed using either `getReader()` or `for await...of` syntax. > **Safari/iOS Compatibility**: The client ensures all returned streams are async-iterable by defining `[Symbol.asyncIterator]` on stream instances when missing. This allows `for await...of` consumption without requiring a global polyfill, while preserving `instanceof ReadableStream` behavior. > > **Derived streams**: Streams created via `.pipeThrough()` or similar transformations are NOT automatically patched. Use the exported `asAsyncIterableReadableStream()` helper: > > ```typescript > import { asAsyncIterableReadableStream } from "@durable-streams/client" > > const derived = res.bodyStream().pipeThrough(myTransform) > const iterable = asAsyncIterableReadableStream(derived) > for await (const chunk of iterable) { ... } > ``` #### `bodyStream(): ReadableStream<Uint8Array> & AsyncIterable<Uint8Array>` Raw bytes as a ReadableStream. **Using `getReader()`:** ```typescript const res = await stream({ url, live: false }) const readable = res.bodyStream() const reader = readable.getReader() while (true) { const { done, value } = await reader.read() if (done) break console.log("Received:", value.length, "bytes") } ``` **Using `for await...of`:** ```typescript const res = await stream({ url, live: false }) for await (const chunk of res.bodyStream()) { console.log("Received:", chunk.length, "bytes") } ``` **Piping to a file (Node.js):** ```typescript import { Readable } from "node:stream" import { pipeline } from "node:stream/promises" const res = await stream({ url, live: false }) await pipeline( Readable.fromWeb(res.bodyStream()), fs.createWriteStream("output.bin") ) ``` #### `jsonStream(): ReadableStream<TJson> & AsyncIterable<TJson>` Individual JSON items as a ReadableStream. **Using `getReader()`:** ```typescript const res = await stream<{ id: number }>({ url, live: false }) const readable = res.jsonStream() const reader = readable.getReader() while (true) { const { done, value } = await reader.read() if (done) break console.log("Item:", value) } ``` **Using `for await...of`:** ```typescript const res = await stream<{ id: number; name: string }>({ url, live: false }) for await (const item of res.jsonStream()) { console.log(`User ${item.id}: ${item.name}`) } ``` #### `textStream(): ReadableStream<string> & AsyncIterable<string>` Text chunks as a ReadableStream. **Using `getReader()`:** ```typescript const res = await stream({ url, live: false }) const readable = res.textStream() const reader = readable.getReader() while (true) { const { done, value } = await reader.read() if (done) break console.log("Text chunk:", value) } ``` **Using `for await...of`:** ```typescript const res = await stream({ url, live: false }) for await (const text of res.textStream()) { console.log("Text chunk:", text) } ``` **Using with Response API:** ```typescript const res = await stream({ url, live: false }) const textResponse = new Response(res.textStream()) const fullText = await textResponse.text() ``` ### Subscribers Subscribers provide callback-based consumption with backpressure. The next chunk isn't fetched until your callback's promise resolves. Returns an unsubscribe function. #### `subscribeJson(callback): () => void` Subscribe to JSON batches with metadata. Provides backpressure-aware consumption. ```typescript const res = await stream<{ event: string }>({ url, live: true }) const unsubscribe = res.subscribeJson(async (batch) => { // Process items - next batch waits until this resolves for (const item of batch.items) { await processEvent(item) } await saveCheckpoint(batch.offset) }) // Later: stop receiving updates setTimeout(() => { unsubscribe() }, 60000) ``` #### `subscribeBytes(callback): () => void` Subscribe to byte chunks with metadata. ```typescript const res = await stream({ url, live: true }) const unsubscribe = res.subscribeBytes(async (chunk) => { console.log("Received bytes:", chunk.data.length) console.log("Offset:", chunk.offset) console.log("Up to date:", chunk.upToDate) await writeToFile(chunk.data) await saveCheckpoint(chunk.offset) }) ``` #### `subscribeText(callback): () => void` Subscribe to text chunks with metadata. ```typescript const res = await stream({ url, live: true }) const unsubscribe = res.subscribeText(async (chunk) => { console.log("Text:", chunk.text) console.log("Offset:", chunk.offset) await appendToLog(chunk.text) }) ``` ### Lifecycle #### `cancel(reason?: unknown): void` Cancel the stream session. Aborts any pending requests. ```typescript const res = await stream({ url, live: true }) // Start consuming res.subscribeBytes(async (chunk) => { console.log("Chunk:", chunk) }) // Cancel after 10 seconds setTimeout(() => { res.cancel("Timeout") }, 10000) ``` #### `closed: Promise<void>` Promise that resolves when the session is complete or cancelled. ```typescript const res = await stream({ url, live: false }) // Start consuming in background const consumer = res.text() // Wait for completion await res.closed console.log("Stream fully consumed") ``` ### State Properties ```typescript const res = await stream({ url }) res.url // The stream URL res.contentType // Content-Type from response headers res.live // The live mode (true, "long-poll", "sse", or false) res.startOffset // The starting offset passed to stream() res.offset // Current offset (updates as data is consumed) res.cursor // Cursor for collapsing (if provided by server) res.upToDate // Whether we've caught up to the stream head res.streamClosed // Whether the stream is permanently closed (EOF) ``` --- ## DurableStream Methods ### Static Methods #### `DurableStream.create(opts): Promise<DurableStream>` Create a new stream on the server. ```typescript const handle = await DurableStream.create({ url: "https://streams.example.com/my-stream", headers: { Authorization: "Bearer my-token", }, contentType: "application/json", ttlSeconds: 3600, // Optional: auto-delete after 1 hour }) await handle.append('{"hello": "world"}') ``` #### `DurableStream.connect(opts): Promise<DurableStream>` Connect to an existing stream (validates it exists via HEAD). ```typescript const handle = await DurableStream.connect({ url: "https://streams.example.com/my-stream", headers: { Authorization: "Bearer my-token", }, }) console.log("Content-Type:", handle.contentType) ``` #### `DurableStream.head(opts): Promise<HeadResult>` Get stream metadata without creating a handle. ```typescript const metadata = await DurableStream.head({ url: "https://streams.example.com/my-stream", headers: { Authorization: "Bearer my-token", }, }) console.log("Offset:", metadata.offset) console.log("Content-Type:", metadata.contentType) ``` #### `DurableStream.delete(opts): Promise<void>` Delete a stream without creating a handle. ```typescript await DurableStream.delete({ url: "https://streams.example.com/my-stream", headers: { Authorization: "Bearer my-token", }, }) ``` ### Instance Methods #### `head(opts?): Promise<HeadResult>` Get metadata for this stream. ```typescript const handle = new DurableStream({ url, headers: { Authorization: `Bearer ${token}` }, }) const metadata = await handle.head() console.log("Current offset:", metadata.offset) ``` #### `create(opts?): Promise<this>` Create this stream on the server. ```typescript const handle = new DurableStream({ url, headers: { Authorization: `Bearer ${token}` }, }) await handle.create({ contentType: "text/plain", ttlSeconds: 7200, }) ``` #### `delete(opts?): Promise<void>` Delete this stream. ```typescript const handle = new DurableStream({ url, headers: { Authorization: `Bearer ${token}` }, }) await handle.delete() ``` #### `append(body, opts?): Promise<void>` Append data to the stream. By default, **automatic batching is enabled**: multiple `append()` calls made while a POST is in-flight will be batched together into a single request. This significantly improves throughput for high-frequency writes. ```typescript const handle = await DurableStream.connect({ url, headers: { Authorization: `Bearer ${token}` }, }) // Append string await handle.append("Hello, world!") // Append with sequence number for ordering await handle.append("Message 1", { seq: "writer-1-001" }) await handle.append("Message 2", { seq: "writer-1-002" }) // For JSON streams, append objects directly (serialized automatically) await handle.append({ event: "click", x: 100, y: 200 }) // Batching happens automatically - these may be sent in a single request await Promise.all([ handle.append({ event: "msg1" }), handle.append({ event: "msg2" }), handle.append({ event: "msg3" }), ]) ``` **Batching behavior:** - **JSON mode** (`contentType: "application/json"`): Multiple values are sent as a JSON array `[val1, val2, ...]` - **Byte mode**: Binary data is concatenated **Disabling batching:** If you need to ensure each append is sent immediately (e.g., for precise timing or debugging): ```typescript const handle = new DurableStream({ url, batching: false, // Disable automatic batching }) ``` #### `appendStream(source, opts?): Promise<void>` Append streaming data from an async iterable or ReadableStream. This method supports piping from any source. ```typescript const handle = await DurableStream.connect({ url, headers: { Authorization: `Bearer ${token}` }, }) // From async generator async function* generateData() { for (let i = 0; i < 100; i++) { yield `Line ${i}\n` } } await handle.appendStream(generateData()) // From ReadableStream const readable = new ReadableStream({ start(controller) { controller.enqueue("chunk 1") controller.enqueue("chunk 2") controller.close() }, }) await handle.appendStream(readable) // Pipe from a fetch response body const response = await fetch("https://example.com/data") await handle.appendStream(response.body!) ``` #### `writable(opts?): WritableStream<Uint8Array | string>` Create a WritableStream that can receive piped data. Useful for stream composition: ```typescript const handle = await DurableStream.connect({ url, auth }) // Pipe from any ReadableStream await someReadableStream.pipeTo(handle.writable()) // Pipe through a transform const readable = inputStream.pipeThrough(new TextEncoderStream()) await readable.pipeTo(handle.writable()) ``` #### `stream(opts?): Promise<StreamResponse>` Start a read session (same as standalone `stream()` function). ```typescript const handle = await DurableStream.connect({ url, headers: { Authorization: `Bearer ${token}` }, }) const res = await handle.stream<{ message: string }>({ offset: savedOffset, live: true, }) res.subscribeJson(async (batch) => { for (const item of batch.items) { console.log(item.message) } }) ``` --- ## IdempotentProducer The `IdempotentProducer` class provides Kafka-style exactly-once write semantics with automatic batching and pipelining. ### Constructor ```typescript new IdempotentProducer(stream: DurableStream, producerId: string, opts?: IdempotentProducerOptions) ``` **Parameters:** - `stream` - The DurableStream to write to - `producerId` - Stable identifier for this producer (e.g., "order-service-1") - `opts` - Optional configuration **Options:** ```typescript interface IdempotentProducerOptions { epoch?: number // Starting epoch (default: 0) autoClaim?: boolean // On 403, retry with epoch+1 (default: false) maxBatchBytes?: number // Max bytes before sending batch (default: 1MB) lingerMs?: number // Max time to wait for more messages (default: 5ms) maxInFlight?: number // Concurrent batches in flight (default: 5) signal?: AbortSignal // Cancellation signal fetch?: typeof fetch // Custom fetch implementation onError?: (error: Error) => void // Error callback for batch failures } ``` ### Methods #### `append(body): void` Append data to the stream (fire-and-forget). For JSON streams, you can pass objects directly. Returns immediately after adding to the internal batch. Errors are reported via `onError` callback. ```typescript // For JSON streams - pass objects directly producer.append({ event: "click", x: 100 }) // Or strings/bytes producer.append("message data") producer.append(new Uint8Array([1, 2, 3])) // All appends are fire-and-forget - use flush() to wait for delivery await producer.flush() ``` #### `flush(): Promise<void>` Send any pending batch immediately and wait for all in-flight batches to complete. ```typescript // Always call before shutdown await producer.flush() ``` #### `close(finalMessage?): Promise<CloseResult>` Flush pending messages and close the underlying **stream** (EOF). This is the typical way to end a producer session: 1. Flushes all pending messages 2. Optionally appends a final message atomically with close 3. Closes the stream (no further appends permitted by any producer) **Idempotent**: Safe to retry on network failures - uses producer headers for deduplication. ```typescript // Close stream (EOF) const result = await producer.close() console.log("Final offset:", result.finalOffset) // Close with final message (atomic append + close) const result = await producer.close('{"done": true}') ``` #### `detach(): Promise<void>` Stop the producer without closing the underlying stream. Use this when: - Handing off writing to another producer - Keeping the stream open for future writes - Stopping this producer but not signaling EOF to readers ```typescript await producer.detach() // Stream remains open ``` #### `restart(): Promise<void>` Increment epoch and reset sequence. Call this when restarting the producer to establish a new session. ```typescript await producer.restart() ``` ### Properties - `epoch: number` - Current epoch for this producer - `nextSeq: number` - Next sequence number to be assigned - `pendingCount: number` - Messages in the current pending batch - `inFlightCount: number` - Batches currently in flight ### Error Handling Errors are delivered via the `onError` callback since `append()` is fire-and-forget: ```typescript import { IdempotentProducer, StaleEpochError, SequenceGapError, } from "@durable-streams/client" const producer = new IdempotentProducer(stream, "my-producer", { onError: (error) => { if (error instanceof StaleEpochError) { // Another producer has a higher epoch - this producer is "fenced" console.log(`Fenced by epoch ${error.currentEpoch}`) } else if (error instanceof SequenceGapError) { // Sequence gap detected (should not happen with proper usage) console.log(`Expected seq ${error.expectedSeq}, got ${error.receivedSeq}`) } }, }) producer.append("data") // Fire-and-forget, errors go to onError await producer.flush() // Wait for all batches to complete ``` --- ## Stream Closure (EOF) Durable Streams supports permanently closing streams to signal EOF (End of File). Once closed, no further appends are permitted, but data remains fully readable. ### Writer Side #### Using DurableStream.close() ```typescript const stream = await DurableStream.connect({ url }) // Simple close (no final message) const result = await stream.close() console.log("Final offset:", result.finalOffset) // Atomic append-and-close with final message const result = await stream.close({ body: '{"status": "complete"}', }) ``` **Options:** ```typescript interface CloseOptions { body?: Uint8Array | string // Optional final message contentType?: string // Content type (must match stream) signal?: AbortSignal // Cancellation } interface CloseResult { finalOffset: Offset // The offset after the last byte } ``` **Idempotency:** - `close()` without body: Idempotent — safe to call multiple times - `close({ body })` with body: NOT idempotent — throws `StreamClosedError` if already closed. Use `IdempotentProducer.close(finalMessage)` for idempotent close-with-body. #### Using IdempotentProducer.close() For reliable close with final message (safe to retry): ```typescript const producer = new IdempotentProducer(stream, "producer-1", { autoClaim: true, }) // Write some messages producer.append('{"event": "start"}') producer.append('{"event": "data"}') // Close with final message (idempotent, safe to retry) const result = await producer.close('{"event": "end"}') ``` **Important:** `IdempotentProducer.close()` closes the **stream**, not just the producer. Use `detach()` to stop the producer without closing the stream. #### Creating Closed Streams Create a stream that's immediately closed (useful for cached responses, errors, single-shot data): ```typescript // Empty closed stream const stream = await DurableStream.create({ url: "https://streams.example.com/cached-response", contentType: "application/json", closed: true, }) // Closed stream with initial content const stream = await DurableStream.create({ url: "https://streams.example.com/error-response", contentType: "application/json", body: '{"error": "Service unavailable"}', closed: true, }) ``` ### Reader Side #### Detecting Closure The `streamClosed` property indicates when a stream is permanently closed: ```typescript // StreamResponse properties const res = await stream({ url, live: true }) console.log(res.streamClosed) // false initially // In subscribers - batch/chunk metadata includes streamClosed res.subscribeJson((batch) => { console.log("Items:", batch.items) console.log("Stream closed:", batch.streamClosed) // true when EOF reached }) // In HEAD requests const metadata = await stream.head() console.log("Stream closed:", metadata.streamClosed) ``` #### Live Mode Behavior When a stream is closed: - **Long-poll**: Returns immediately with `streamClosed: true` (no waiting) - **SSE**: Sends `streamClosed: true` in final control event, then closes connection - **Subscribers**: Receive final batch with `streamClosed: true`, then stop ```typescript const res = await stream({ url, live: true }) res.subscribeJson((batch) => { for (const item of batch.items) { process(item) } if (batch.streamClosed) { console.log("Stream complete, no more data will arrive") // Connection will close automatically } }) ``` ### Error Handling Attempting to append to a closed stream throws `StreamClosedError`: ```typescript import { StreamClosedError } from "@durable-streams/client" try { await stream.append("data") } catch (error) { if (error instanceof StreamClosedError) { console.log("Stream is closed at offset:", error.finalOffset) } } ``` --- ## Types Key types exported from the package: - `Offset` - Opaque string for stream position - `StreamResponse` - Response object from stream() (includes `streamClosed` property) - `ByteChunk` - `{ data: Uint8Array, offset: Offset, upToDate: boolean, streamClosed: boolean, cursor?: string }` - `JsonBatch<T>` - `{ items: T[], offset: Offset, upToDate: boolean, streamClosed: boolean, cursor?: string }` - `TextChunk` - `{ text: string, offset: Offset, upToDate: boolean, streamClosed: boolean, cursor?: string }` - `HeadResult` - Metadata from HEAD requests (includes `streamClosed` property) - `CloseOptions` - Options for closing a stream - `CloseResult` - Result from closing a stream (includes `finalOffset`) - `IdempotentProducer` - Exactly-once producer class - `StaleEpochError` - Thrown when producer epoch is stale (zombie fencing) - `SequenceGapError` - Thrown when sequence numbers are out of order - `StreamClosedError` - Thrown when attempting to append to a closed stream (includes `finalOffset`) - `DurableStreamError` - Protocol-level errors with codes - `FetchError` - Transport/network errors ## License Apache-2.0