@durable-streams/client
Version:
TypeScript client for the Durable Streams protocol
1,493 lines (1,482 loc) • 46.5 kB
text/typescript
//#region src/asyncIterableReadableStream.d.ts
/**
* Async iterable polyfill for ReadableStream.
*
* Safari/iOS may not implement ReadableStream.prototype[Symbol.asyncIterator],
* preventing `for await...of` consumption. This module provides a soft polyfill
* that defines [Symbol.asyncIterator] on individual stream instances when missing,
* without patching the global prototype.
*
* The returned stream is still the original ReadableStream instance (not wrapped),
* so `instanceof ReadableStream` continues to work correctly.
*
* **Note on derived streams**: Streams created via `.pipeThrough()` or similar
* transformations will NOT be automatically patched. Use the exported
* `asAsyncIterableReadableStream()` helper to patch derived streams:
*
* ```typescript
* import { asAsyncIterableReadableStream } from "@durable-streams/client"
*
* const derived = res.bodyStream().pipeThrough(myTransform)
* const iterable = asAsyncIterableReadableStream(derived)
* for await (const chunk of iterable) { ... }
* ```
*/
/**
* A ReadableStream that is guaranteed to be async-iterable.
*
* This intersection type ensures TypeScript knows the stream can be consumed
* via `for await...of` syntax.
*/
type ReadableStreamAsyncIterable<T> = ReadableStream<T> & AsyncIterable<T>;
/**
* Ensure a ReadableStream is async-iterable.
*
* If the stream already has [Symbol.asyncIterator] defined (native or polyfilled),
* it is returned as-is. Otherwise, [Symbol.asyncIterator] is defined on the
* stream instance (not the prototype).
*
* The returned value is the same ReadableStream instance, so:
* - `stream instanceof ReadableStream` remains true
* - Any code relying on native branding/internal slots continues to work
*
* @example
* ```typescript
* const stream = someApiReturningReadableStream();
* const iterableStream = asAsyncIterableReadableStream(stream);
*
* // Now works on Safari/iOS:
* for await (const chunk of iterableStream) {
* console.log(chunk);
* }
* ```
*/
declare function asAsyncIterableReadableStream<T>(stream: ReadableStream<T>): ReadableStreamAsyncIterable<T>;
//#endregion
//#region src/fetch.d.ts
/**
* Options for configuring exponential backoff retry behavior.
*/
interface BackoffOptions {
/**
* Initial delay before retrying in milliseconds.
*/
initialDelay: number;
/**
* Maximum retry delay in milliseconds.
* After reaching this, delay stays constant.
*/
maxDelay: number;
/**
* Multiplier for exponential backoff.
*/
multiplier: number;
/**
* Callback invoked on each failed attempt.
*/
onFailedAttempt?: () => void;
/**
* Enable debug logging.
*/
debug?: boolean;
/**
* Maximum number of retry attempts before giving up.
* Set to Infinity for indefinite retries (useful for offline scenarios).
*/
maxRetries?: number;
}
/**
* Default backoff options.
*/
declare const BackoffDefaults: BackoffOptions;
/**
* Parse Retry-After header value and return delay in milliseconds.
* Supports both delta-seconds format and HTTP-date format.
* Returns 0 if header is not present or invalid.
*/
/**
* Creates a fetch client that retries failed requests with exponential backoff.
*
* @param fetchClient - The base fetch client to wrap
* @param backoffOptions - Options for retry behavior
* @returns A fetch function with automatic retry
*/
declare function createFetchWithBackoff(fetchClient: typeof fetch, backoffOptions?: BackoffOptions): typeof fetch;
/**
* Creates a fetch client that ensures the response body is fully consumed.
* This prevents issues with connection pooling when bodies aren't read.
*
* Uses arrayBuffer() instead of text() to preserve binary data integrity.
*
* @param fetchClient - The base fetch client to wrap
* @returns A fetch function that consumes response bodies
*/
declare function createFetchWithConsumedBody(fetchClient: typeof fetch): typeof fetch;
//#endregion
//#region src/types.d.ts
/**
* Chains an AbortController to an optional source signal.
* If the source signal is aborted, the provided controller will also abort.
*/
/**
* Offset string - opaque to the client.
* Format: "<read-seq>_<byte-offset>"
*
* **Special value**: `-1` means "start of stream" - use this to read from the beginning.
*
* Always use the returned `offset` field from reads/follows as the next `offset` you pass in.
*/
type Offset = string;
/**
* Type for values that can be provided immediately or resolved asynchronously.
*/
type MaybePromise<T> = T | Promise<T>;
/**
* Headers record where values can be static strings or async functions.
* Following the @electric-sql/client pattern for dynamic headers.
*
* **Important**: Functions are called **for each request**, not once per session.
* In live mode with long-polling, the same function may be called many times
* to fetch fresh values (e.g., refreshed auth tokens) for each poll.
*
* @example
* ```typescript
* headers: {
* Authorization: `Bearer ${token}`, // Static - same for all requests
* 'X-Tenant-Id': () => getCurrentTenant(), // Called per-request
* 'X-Auth': async () => await refreshToken() // Called per-request (can refresh)
* }
* ```
*/
type HeadersRecord = {
[key: string]: string | (() => MaybePromise<string>);
};
/**
* Params record where values can be static or async functions.
* Following the @electric-sql/client pattern for dynamic params.
*
* **Important**: Functions are called **for each request**, not once per session.
* In live mode, the same function may be called multiple times to fetch
* fresh parameter values for each poll.
*/
type ParamsRecord = {
[key: string]: string | (() => MaybePromise<string>) | undefined;
};
/**
* Live mode for reading from a stream.
* - false: Catch-up only, stop at first `upToDate`
* - true: Auto-select best mode (SSE for JSON streams, long-poll for binary)
* - "long-poll": Explicit long-poll mode for live updates
* - "sse": Explicit server-sent events for live updates
*/
type LiveMode = boolean | `long-poll` | `sse`;
/**
* Options for the stream() function (read-only API).
*/
interface StreamOptions {
/**
* The full URL to the durable stream.
* E.g., "https://streams.example.com/my-account/chat/room-1"
*/
url: string | URL;
/**
* HTTP headers to include in requests.
* Values can be strings or functions (sync or async) that return strings.
*
* **Important**: Functions are evaluated **per-request** (not per-session).
* In live mode, functions are called for each poll, allowing fresh values
* like refreshed auth tokens.
*
* @example
* ```typescript
* headers: {
* Authorization: `Bearer ${token}`, // Static
* 'X-Tenant-Id': () => getCurrentTenant(), // Evaluated per-request
* 'X-Auth': async () => await refreshToken() // Evaluated per-request
* }
* ```
*/
headers?: HeadersRecord;
/**
* Query parameters to include in requests.
* Values can be strings or functions (sync or async) that return strings.
*
* **Important**: Functions are evaluated **per-request** (not per-session).
*/
params?: ParamsRecord;
/**
* AbortSignal for cancellation.
*/
signal?: AbortSignal;
/**
* Custom fetch implementation (for auth layers, proxies, etc.).
* Defaults to globalThis.fetch.
*/
fetch?: typeof globalThis.fetch;
/**
* Backoff options for retry behavior.
* Defaults to exponential backoff with jitter.
*/
backoffOptions?: BackoffOptions;
/**
* Starting offset (query param ?offset=...).
* If omitted, defaults to "-1" (start of stream).
* You can also explicitly pass "-1" to read from the beginning.
*/
offset?: Offset;
/**
* Live mode behavior:
* - false: Catch-up only, stop at first `upToDate`
* - true (default): Auto-select best mode (SSE for JSON, long-poll for binary)
* - "long-poll": Explicit long-poll mode for live updates
* - "sse": Explicit server-sent events for live updates
*/
live?: LiveMode;
/**
* Hint: treat content as JSON even if Content-Type doesn't say so.
*/
json?: boolean;
/**
* Error handler for recoverable errors (following Electric client pattern).
*/
onError?: StreamErrorHandler;
/**
* SSE resilience options.
* When SSE connections fail repeatedly, the client can automatically
* fall back to long-polling mode.
*/
sseResilience?: SSEResilienceOptions;
/**
* Whether to warn when using HTTP (not HTTPS) URLs in browser environments.
* HTTP limits browsers to 6 concurrent connections (HTTP/1.1), which can
* cause slow streams and app freezes with multiple active streams.
*
* @default true
*/
warnOnHttp?: boolean;
}
/**
* Options for SSE connection resilience.
*/
interface SSEResilienceOptions {
/**
* Minimum expected SSE connection duration in milliseconds.
* Connections shorter than this are considered "short" and may indicate
* proxy buffering or server misconfiguration.
* @default 1000
*/
minConnectionDuration?: number;
/**
* Maximum number of consecutive short connections before falling back to long-poll.
* @default 3
*/
maxShortConnections?: number;
/**
* Base delay for exponential backoff between short connection retries (ms).
* @default 100
*/
backoffBaseDelay?: number;
/**
* Maximum delay cap for exponential backoff (ms).
* @default 5000
*/
backoffMaxDelay?: number;
/**
* Whether to log warnings when falling back to long-poll.
* @default true
*/
logWarnings?: boolean;
}
/**
* Metadata for a JSON batch or chunk.
*/
interface JsonBatchMeta {
/**
* Last Stream-Next-Offset for this batch.
*/
offset: Offset;
/**
* True if this batch ends at the current end of the stream.
*/
upToDate: boolean;
/**
* Last Stream-Cursor / streamCursor, if present.
*/
cursor?: string;
/**
* Whether the stream is closed and this batch contains the final data.
* When true, no more data will ever be appended to the stream.
*/
streamClosed: boolean;
}
/**
* A batch of parsed JSON items with metadata.
*/
interface JsonBatch<T = unknown> extends JsonBatchMeta {
/**
* The parsed JSON items in this batch.
*/
items: ReadonlyArray<T>;
}
/**
* A chunk of raw bytes with metadata.
*/
interface ByteChunk extends JsonBatchMeta {
/**
* The raw byte data.
*/
data: Uint8Array;
}
/**
* A chunk of text with metadata.
*/
interface TextChunk extends JsonBatchMeta {
/**
* The text content.
*/
text: string;
}
/**
* Base options for StreamHandle operations.
*/
interface StreamHandleOptions {
/**
* The full URL to the durable stream.
* E.g., "https://streams.example.com/my-account/chat/room-1"
*/
url: string | URL;
/**
* HTTP headers to include in requests.
* Values can be strings or functions (sync or async) that return strings.
*
* Functions are evaluated **per-request** (not per-session).
*/
headers?: HeadersRecord;
/**
* Query parameters to include in requests.
* Values can be strings or functions (sync or async) that return strings.
*
* Functions are evaluated **per-request** (not per-session).
*/
params?: ParamsRecord;
/**
* Custom fetch implementation.
* Defaults to globalThis.fetch.
*/
fetch?: typeof globalThis.fetch;
/**
* Default AbortSignal for operations.
*/
signal?: AbortSignal;
/**
* The content type for the stream.
*/
contentType?: string;
/**
* Error handler for recoverable errors.
*/
onError?: StreamErrorHandler;
/**
* Enable automatic batching for append() calls.
* When true, multiple append() calls made while a POST is in-flight
* will be batched together into a single request.
*
* @default true
*/
batching?: boolean;
/**
* Whether to warn when using HTTP (not HTTPS) URLs in browser environments.
* HTTP limits browsers to 6 concurrent connections (HTTP/1.1), which can
* cause slow streams and app freezes with multiple active streams.
*
* @default true
*/
warnOnHttp?: boolean;
}
/**
* Options for creating a new stream.
*/
interface CreateOptions extends StreamHandleOptions {
/**
* Time-to-live in seconds (relative TTL).
*/
ttlSeconds?: number;
/**
* Absolute expiry time (RFC3339 format).
*/
expiresAt?: string;
/**
* Initial body to append on creation.
*/
body?: BodyInit | Uint8Array | string;
/**
* Enable automatic batching for append() calls.
* When true, multiple append() calls made while a POST is in-flight
* will be batched together into a single request.
*
* @default true
*/
batching?: boolean;
/**
* If true, create the stream in the closed state.
* Any body provided becomes the complete and final content.
*
* Useful for:
* - Cached responses
* - Placeholder errors
* - Pre-computed results
* - Single-message streams that are immediately complete
*/
closed?: boolean;
}
/**
* Options for appending data to a stream.
*/
interface AppendOptions {
/**
* Writer coordination sequence (stream-seq header).
* Monotonic, lexicographic sequence for coordinating multiple writers.
* If lower than last appended seq, server returns 409 Conflict.
* Not related to read offsets.
*/
seq?: string;
/**
* Content type for this append.
* Must match the stream's content type.
*/
contentType?: string;
/**
* AbortSignal for this operation.
*/
signal?: AbortSignal;
/**
* Producer ID for idempotent writes.
* Client-supplied stable identifier (e.g., "order-service-1").
* Must be provided together with producerEpoch and producerSeq.
*/
producerId?: string;
/**
* Producer epoch for idempotent writes.
* Client-declared, server-validated monotonically increasing.
* Increment on producer restart.
*/
producerEpoch?: number;
/**
* Producer sequence for idempotent writes.
* Monotonically increasing per epoch, per-batch.
*/
producerSeq?: number;
}
/**
* Result of a close operation.
*/
interface CloseResult {
/**
* The final offset of the stream.
* This is the offset after the last byte (including any final message).
* Returned via the `Stream-Next-Offset` header.
*/
finalOffset: Offset;
}
/**
* Options for closing a stream.
*/
interface CloseOptions {
/**
* Optional final message to append atomically with close.
* For JSON streams, pass a pre-serialized JSON string.
* Strings are UTF-8 encoded.
*/
body?: Uint8Array | string;
/**
* Content type for the final message.
* Defaults to the stream's content type. Must match if provided.
*/
contentType?: string;
/**
* AbortSignal for this operation.
*/
signal?: AbortSignal;
}
/**
* Legacy live mode type (internal use only).
* @internal
*/
type LegacyLiveMode = `long-poll` | `sse`;
/**
* Options for reading from a stream (internal iterator options).
* @internal
*/
interface ReadOptions {
/**
* Starting offset, passed as ?offset=...
* If omitted, defaults to "-1" (start of stream).
*/
offset?: Offset;
/**
* Live mode behavior:
* - undefined/true (default): Catch-up then auto-select SSE or long-poll for live updates
* - false: Only catch-up, stop after up-to-date (no live updates)
* - "long-poll": Use long-polling for live updates
* - "sse": Use SSE for live updates (throws if unsupported)
*/
live?: boolean | LegacyLiveMode;
/**
* Override cursor for the request.
* By default, the client echoes the last stream-cursor value.
*/
cursor?: string;
/**
* AbortSignal for this operation.
*/
signal?: AbortSignal;
}
/**
* Result from a HEAD request on a stream.
*/
interface HeadResult {
/**
* Whether the stream exists.
*/
exists: true;
/**
* The stream's content type.
*/
contentType?: string;
/**
* The tail offset (next offset after current end of stream).
* Provided by server as stream-offset header on HEAD.
*/
offset?: Offset;
/**
* ETag for the stream (format: {internal_stream_id}:{end_offset}).
*/
etag?: string;
/**
* Cache-Control header value.
*/
cacheControl?: string;
/**
* Whether the stream is closed.
* When true, no further appends are permitted.
*/
streamClosed: boolean;
}
/**
* Metadata extracted from a stream response.
* Contains headers and control information from the stream server.
*/
/**
* Error codes for DurableStreamError.
*/
type DurableStreamErrorCode = `NOT_FOUND` | `CONFLICT_SEQ` | `CONFLICT_EXISTS` | `BAD_REQUEST` | `BUSY` | `SSE_NOT_SUPPORTED` | `UNAUTHORIZED` | `FORBIDDEN` | `RATE_LIMITED` | `ALREADY_CONSUMED` | `ALREADY_CLOSED` | `PARSE_ERROR` | `STREAM_CLOSED` | `UNKNOWN`;
/**
* Options returned from onError handler to retry with modified params/headers.
* Following the Electric client pattern.
*/
type RetryOpts = {
params?: ParamsRecord;
headers?: HeadersRecord;
};
/**
* Error handler callback type.
*
* Called when a recoverable error occurs during streaming.
*
* **Return value behavior** (following Electric client pattern):
* - Return `{}` (empty object) → Retry immediately with same params/headers
* - Return `{ params }` → Retry with merged params (existing params preserved)
* - Return `{ headers }` → Retry with merged headers (existing headers preserved)
* - Return `void` or `undefined` → Stop stream and propagate the error
* - Return `null` → INVALID (will cause error - use `{}` instead)
*
* **Important**: To retry, you MUST return an object (can be empty `{}`).
* Returning nothing (`void`), explicitly returning `undefined`, or omitting
* a return statement all stop the stream. Do NOT return `null`.
*
* Note: Automatic retries with exponential backoff are already applied
* for 5xx server errors, network errors, and 429 rate limits before
* this handler is called.
*
* @example
* ```typescript
* // Retry on any error (returns empty object)
* onError: (error) => ({})
*
* // Refresh auth token on 401, propagate other errors
* onError: async (error) => {
* if (error instanceof FetchError && error.status === 401) {
* const newToken = await refreshAuthToken()
* return { headers: { Authorization: `Bearer ${newToken}` } }
* }
* // Implicitly returns undefined - error will propagate
* }
*
* // Conditionally retry with explicit propagation
* onError: (error) => {
* if (shouldRetry(error)) {
* return {} // Retry
* }
* return undefined // Explicitly propagate error
* }
* ```
*/
type StreamErrorHandler = (error: Error) => void | RetryOpts | Promise<void | RetryOpts>;
/**
* A streaming session returned by stream() or DurableStream.stream().
*
* Represents a live session with fixed `url`, `offset`, and `live` parameters.
* Supports multiple consumption styles: Promise helpers, ReadableStreams,
* and Subscribers.
*
* @typeParam TJson - The type of JSON items in the stream.
*/
interface StreamResponse<TJson = unknown> {
/**
* The stream URL.
*/
readonly url: string;
/**
* The stream's content type (from first response).
*/
readonly contentType?: string;
/**
* The live mode for this session.
*/
readonly live: LiveMode;
/**
* The starting offset for this session.
*/
readonly startOffset: Offset;
/**
* HTTP response headers from the most recent server response.
* Updated on each long-poll/SSE response.
*/
readonly headers: Headers;
/**
* HTTP status code from the most recent server response.
* Updated on each long-poll/SSE response.
*/
readonly status: number;
/**
* HTTP status text from the most recent server response.
* Updated on each long-poll/SSE response.
*/
readonly statusText: string;
/**
* Whether the most recent response was successful (status 200-299).
* Always true for active streams (errors are thrown).
*/
readonly ok: boolean;
/**
* Whether the stream is waiting for initial data.
*
* Note: Always false in current implementation because stream() awaits
* the first response before returning. A future async iterator API
* could expose this as true during initial connection.
*/
readonly isLoading: boolean;
/**
* The next offset to read from (Stream-Next-Offset header).
*
* **Important**: This value advances **after data is delivered to the consumer**,
* not just after fetching from the server. The offset represents the position
* in the stream that follows the data most recently provided to your consumption
* method (body(), json(), bodyStream(), subscriber callback, etc.).
*
* Use this for resuming reads after a disconnect or saving checkpoints.
*/
readonly offset: Offset;
/**
* Stream cursor for CDN collapsing (stream-cursor header).
*
* Updated after each chunk is delivered to the consumer.
*/
readonly cursor?: string;
/**
* Whether we've reached the current end of the stream (stream-up-to-date header).
*
* Updated after each chunk is delivered to the consumer.
*/
readonly upToDate: boolean;
/**
* Whether the stream is closed (EOF).
*
* When true, no more data will ever be appended to the stream.
* This is updated after each chunk is delivered to the consumer.
*
* In live mode, when streamClosed becomes true:
* - Long-poll requests return immediately (no waiting)
* - SSE connections are closed by the server
* - Clients stop reconnecting automatically
*/
readonly streamClosed: boolean;
/**
* Accumulate raw bytes until first `upToDate` batch, then resolve.
* When used with `live: true`, signals the session to stop after upToDate.
*/
body: () => Promise<Uint8Array>;
/**
* Accumulate JSON *items* across batches into a single array, resolve at `upToDate`.
* Only valid in JSON-mode; throws otherwise.
* When used with `live: true`, signals the session to stop after upToDate.
*/
json: <T = TJson>() => Promise<Array<T>>;
/**
* Accumulate text chunks into a single string, resolve at `upToDate`.
* When used with `live: true`, signals the session to stop after upToDate.
*/
text: () => Promise<string>;
/**
* Raw bytes as a ReadableStream<Uint8Array>.
*
* The returned stream is guaranteed to be async-iterable, so you can use
* `for await...of` syntax even on Safari/iOS which may lack native support.
*/
bodyStream: () => ReadableStreamAsyncIterable<Uint8Array>;
/**
* Individual JSON items (flattened) as a ReadableStream<TJson>.
* Built on jsonBatches().
*
* The returned stream is guaranteed to be async-iterable, so you can use
* `for await...of` syntax even on Safari/iOS which may lack native support.
*/
jsonStream: () => ReadableStreamAsyncIterable<TJson>;
/**
* Text chunks as ReadableStream<string>.
*
* The returned stream is guaranteed to be async-iterable, so you can use
* `for await...of` syntax even on Safari/iOS which may lack native support.
*/
textStream: () => ReadableStreamAsyncIterable<string>;
/**
* Subscribe to JSON batches as they arrive.
* Returns unsubscribe function.
*
* The subscriber can be sync or async. If async, backpressure is applied
* (the next batch waits for the previous callback to complete).
*/
subscribeJson: <T = TJson>(subscriber: (batch: JsonBatch<T>) => void | Promise<void>) => () => void;
/**
* Subscribe to raw byte chunks as they arrive.
* Returns unsubscribe function.
*
* The subscriber can be sync or async. If async, backpressure is applied
* (the next chunk waits for the previous callback to complete).
*/
subscribeBytes: (subscriber: (chunk: ByteChunk) => void | Promise<void>) => () => void;
/**
* Subscribe to text chunks as they arrive.
* Returns unsubscribe function.
*
* The subscriber can be sync or async. If async, backpressure is applied
* (the next chunk waits for the previous callback to complete).
*/
subscribeText: (subscriber: (chunk: TextChunk) => void | Promise<void>) => () => void;
/**
* Cancel the underlying session (abort HTTP, close SSE, stop long-polls).
*/
cancel: (reason?: unknown) => void;
/**
* Resolves when the session has fully closed:
* - `live:false` and up-to-date reached,
* - manual cancellation,
* - terminal error.
*/
readonly closed: Promise<void>;
}
/**
* Options for creating an IdempotentProducer.
*/
interface IdempotentProducerOptions {
/**
* Starting epoch (default: 0).
* Increment this on producer restart.
*/
epoch?: number;
/**
* On 403 Forbidden (stale epoch), automatically retry with epoch+1.
* Useful for serverless/ephemeral producers.
* @default false
*/
autoClaim?: boolean;
/**
* Maximum bytes before sending a batch.
* @default 1048576 (1MB)
*/
maxBatchBytes?: number;
/**
* Maximum time to wait for more messages before sending batch (ms).
* @default 5
*/
lingerMs?: number;
/**
* Maximum number of concurrent batches in flight.
* Higher values improve throughput at the cost of more memory.
* @default 5
*/
maxInFlight?: number;
/**
* Custom fetch implementation.
*/
fetch?: typeof globalThis.fetch;
/**
* AbortSignal for the producer lifecycle.
*/
signal?: AbortSignal;
/**
* Callback for batch errors in fire-and-forget mode.
* Since append() returns immediately, errors are reported via this callback.
* @param error - The error that occurred
*/
onError?: (error: Error) => void;
}
/**
* Result of an append operation from IdempotentProducer.
*/
interface IdempotentAppendResult {
/**
* The offset after this message was appended.
*/
offset: Offset;
/**
* Whether this was a duplicate (idempotent success).
*/
duplicate: boolean;
} //#endregion
//#region src/stream-api.d.ts
/**
* Create a streaming session to read from a durable stream.
*
* This is a fetch-like API:
* - The promise resolves after the first network request succeeds
* - It rejects for auth/404/other protocol errors
* - Returns a StreamResponse for consuming the data
*
* @example
* ```typescript
* // Catch-up JSON:
* const res = await stream<{ message: string }>({
* url,
* auth,
* offset: "0",
* live: false,
* })
* const items = await res.json()
*
* // Live JSON:
* const live = await stream<{ message: string }>({
* url,
* auth,
* offset: savedOffset,
* live: true,
* })
* live.subscribeJson(async (batch) => {
* for (const item of batch.items) {
* handle(item)
* }
* })
* ```
*/
declare function stream<TJson = unknown>(options: StreamOptions): Promise<StreamResponse<TJson>>;
//#endregion
//#region src/stream.d.ts
/**
* Options for DurableStream constructor.
*/
interface DurableStreamOptions extends StreamHandleOptions {
/**
* Additional query parameters to include in requests.
*/
params?: {
[key: string]: string | (() => MaybePromise<string>) | undefined;
};
/**
* Backoff options for retry behavior.
*/
backoffOptions?: BackoffOptions;
/**
* Enable automatic batching for append() calls.
* When true, multiple append() calls made while a POST is in-flight
* will be batched together into a single request.
*
* @default true
*/
batching?: boolean;
}
/**
* A handle to a remote durable stream for read/write operations.
*
* This is a lightweight, reusable handle - not a persistent connection.
* It does not automatically start reading or listening.
* Create sessions as needed via stream().
*
* @example
* ```typescript
* // Create a new stream
* const stream = await DurableStream.create({
* url: "https://streams.example.com/my-stream",
* headers: { Authorization: "Bearer my-token" },
* contentType: "application/json"
* });
*
* // Write data
* await stream.append(JSON.stringify({ message: "hello" }));
*
* // Read with the new API
* const res = await stream.stream<{ message: string }>();
* res.subscribeJson(async (batch) => {
* for (const item of batch.items) {
* console.log(item.message);
* }
* });
* ```
*/
declare class DurableStream {
#private;
/**
* The URL of the durable stream.
*/
readonly url: string;
/**
* The content type of the stream (populated after connect/head/read).
*/
contentType?: string;
/**
* Create a cold handle to a stream.
* No network IO is performed by the constructor.
*/
constructor(opts: DurableStreamOptions);
/**
* Create a new stream (create-only PUT) and return a handle.
* Fails with DurableStreamError(code="CONFLICT_EXISTS") if it already exists.
*/
static create(opts: CreateOptions): Promise<DurableStream>;
/**
* Validate that a stream exists and fetch metadata via HEAD.
* Returns a handle with contentType populated (if sent by server).
*
* **Important**: This only performs a HEAD request for validation - it does
* NOT open a session or start reading data. To read from the stream, call
* `stream()` on the returned handle.
*
* @example
* ```typescript
* // Validate stream exists before reading
* const handle = await DurableStream.connect({ url })
* const res = await handle.stream() // Now actually read
* ```
*/
static connect(opts: DurableStreamOptions): Promise<DurableStream>;
/**
* HEAD metadata for a stream without creating a handle.
*/
static head(opts: DurableStreamOptions): Promise<HeadResult>;
/**
* Delete a stream without creating a handle.
*/
static delete(opts: DurableStreamOptions): Promise<void>;
/**
* HEAD metadata for this stream.
*/
head(opts?: {
signal?: AbortSignal;
}): Promise<HeadResult>;
/**
* Create this stream (create-only PUT) using the URL/auth from the handle.
*/
create(opts?: Omit<CreateOptions, keyof StreamOptions>): Promise<this>;
/**
* Delete this stream.
*/
delete(opts?: {
signal?: AbortSignal;
}): Promise<void>;
/**
* Close the stream, optionally with a final message.
*
* After closing:
* - No further appends are permitted (server returns 409)
* - Readers can observe the closed state and treat it as EOF
* - The stream's data remains fully readable
*
* Closing is:
* - **Durable**: The closed state is persisted
* - **Monotonic**: Once closed, a stream cannot be reopened
*
* **Idempotency:**
* - `close()` without body: Idempotent — safe to call multiple times
* - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
* if stream is already closed (use `IdempotentProducer.close()` for
* idempotent close-with-body semantics)
*
* @returns CloseResult with the final offset
* @throws StreamClosedError if called with body on an already-closed stream
*/
close(opts?: CloseOptions): Promise<CloseResult>;
/**
* Append a single payload to the stream.
*
* When batching is enabled (default), 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.
*
* - `body` must be string or Uint8Array.
* - For JSON streams, pass pre-serialized JSON strings.
* - `body` may also be a Promise that resolves to string or Uint8Array.
* - Strings are encoded as UTF-8.
* - `seq` (if provided) is sent as stream-seq (writer coordination).
*
* @example
* ```typescript
* // JSON stream - pass pre-serialized JSON
* await stream.append(JSON.stringify({ message: "hello" }));
*
* // Byte stream
* await stream.append("raw text data");
* await stream.append(new Uint8Array([1, 2, 3]));
*
* // Promise value - awaited before buffering
* await stream.append(fetchData());
* ```
*/
append(body: Uint8Array | string | Promise<Uint8Array | string>, opts?: AppendOptions): Promise<void>;
/**
* Append a streaming body to the stream.
*
* Supports piping from any ReadableStream or async iterable:
* - `source` yields Uint8Array or string chunks.
* - Strings are encoded as UTF-8; no delimiters are added.
* - Internally uses chunked transfer or HTTP/2 streaming.
*
* @example
* ```typescript
* // Pipe from a ReadableStream
* const readable = new ReadableStream({
* start(controller) {
* controller.enqueue("chunk 1");
* controller.enqueue("chunk 2");
* controller.close();
* }
* });
* await stream.appendStream(readable);
*
* // Pipe from an async generator
* async function* generate() {
* yield "line 1\n";
* yield "line 2\n";
* }
* await stream.appendStream(generate());
*
* // Pipe from fetch response body
* const response = await fetch("https://example.com/data");
* await stream.appendStream(response.body!);
* ```
*/
appendStream(source: ReadableStream<Uint8Array | string> | AsyncIterable<Uint8Array | string>, opts?: AppendOptions): Promise<void>;
/**
* Create a writable stream that pipes data to this durable stream.
*
* Returns a WritableStream that can be used with `pipeTo()` or
* `pipeThrough()` from any ReadableStream source.
*
* Uses IdempotentProducer internally for:
* - Automatic batching (controlled by lingerMs, maxBatchBytes)
* - Exactly-once delivery semantics
* - Streaming writes (doesn't buffer entire content in memory)
*
* @example
* ```typescript
* // Pipe from fetch response
* const response = await fetch("https://example.com/data");
* await response.body!.pipeTo(stream.writable());
*
* // Pipe through a transform
* const readable = someStream.pipeThrough(new TextEncoderStream());
* await readable.pipeTo(stream.writable());
*
* // With custom producer options
* await source.pipeTo(stream.writable({
* producerId: "my-producer",
* lingerMs: 10,
* maxBatchBytes: 64 * 1024,
* }));
* ```
*/
writable(opts?: Pick<IdempotentProducerOptions, `lingerMs` | `maxBatchBytes` | `onError`> & {
producerId?: string;
signal?: AbortSignal;
}): WritableStream<Uint8Array | string>;
/**
* Start a fetch-like streaming session against this handle's URL/headers/params.
* The first request is made inside this method; it resolves when we have
* a valid first response, or rejects on errors.
*
* Call-specific headers and params are merged with handle-level ones,
* with call-specific values taking precedence.
*
* @example
* ```typescript
* const handle = await DurableStream.connect({
* url,
* headers: { Authorization: `Bearer ${token}` }
* });
* const res = await handle.stream<{ message: string }>();
*
* // Accumulate all JSON items
* const items = await res.json();
*
* // Or stream live with ReadableStream
* const reader = res.jsonStream().getReader();
* let result = await reader.read();
* while (!result.done) {
* console.log(result.value);
* result = await reader.read();
* }
*
* // Or use subscriber for backpressure-aware consumption
* res.subscribeJson(async (batch) => {
* for (const item of batch.items) {
* console.log(item);
* }
* });
* ```
*/
stream<TJson = unknown>(options?: Omit<StreamOptions, `url`>): Promise<StreamResponse<TJson>>;
}
//#endregion
//#region src/utils.d.ts
/**
* Warn if using HTTP (not HTTPS) URL in a browser environment.
* HTTP typically limits browsers to ~6 concurrent connections per origin under HTTP/1.1,
* which can cause slow streams and app freezes with multiple active streams.
*
* Features:
* - Warns only once per origin to prevent log spam
* - Handles relative URLs by resolving against window.location.href
* - Safe to call in Node.js environments (no-op)
* - Skips warning during tests (NODE_ENV=test)
*/
declare function warnIfUsingHttpInBrowser(url: string | URL, warnOnHttp?: boolean): void;
/**
* Reset the HTTP warning state. Only exported for testing purposes.
* @internal
*/
declare function _resetHttpWarningForTesting(): void;
//#endregion
//#region src/idempotent-producer.d.ts
/**
* Error thrown when a producer's epoch is stale (zombie fencing).
*/
declare class StaleEpochError extends Error {
/**
* The current epoch on the server.
*/
readonly currentEpoch: number;
constructor(currentEpoch: number);
}
/**
* Error thrown when an unrecoverable sequence gap is detected.
*
* With maxInFlight > 1, HTTP requests can arrive out of order at the server,
* causing temporary 409 responses. The client automatically handles these
* by waiting for earlier sequences to complete, then retrying.
*
* This error is only thrown when the gap cannot be resolved (e.g., the
* expected sequence is >= our sequence, indicating a true protocol violation).
*/
declare class SequenceGapError extends Error {
readonly expectedSeq: number;
readonly receivedSeq: number;
constructor(expectedSeq: number, receivedSeq: number);
}
/**
* An idempotent producer for exactly-once writes to a durable stream.
*
* Features:
* - Fire-and-forget: append() returns immediately, batches in background
* - Exactly-once: server deduplicates using (producerId, epoch, seq)
* - Batching: multiple appends batched into single HTTP request
* - Pipelining: up to maxInFlight concurrent batches
* - Zombie fencing: stale producers rejected via epoch validation
*
* @example
* ```typescript
* const stream = new DurableStream({ url: "https://..." });
* const producer = new IdempotentProducer(stream, "order-service-1", {
* epoch: 0,
* autoClaim: true,
* });
*
* // Fire-and-forget writes (synchronous, returns immediately)
* producer.append("message 1");
* producer.append("message 2");
*
* // Ensure all messages are delivered before shutdown
* await producer.flush();
* await producer.close();
* ```
*/
declare class IdempotentProducer {
#private;
/**
* Create an idempotent producer for a stream.
*
* @param stream - The DurableStream to write to
* @param producerId - Stable identifier for this producer (e.g., "order-service-1")
* @param opts - Producer options
*/
constructor(stream: DurableStream, producerId: string, opts?: IdempotentProducerOptions);
/**
* Append data to the stream.
*
* This is fire-and-forget: returns immediately after adding to the batch.
* The message is batched and sent when:
* - maxBatchBytes is reached
* - lingerMs elapses
* - flush() is called
*
* Errors are reported via onError callback if configured. Use flush() to
* wait for all pending messages to be sent.
*
* For JSON streams, pass pre-serialized JSON strings.
* For byte streams, pass string or Uint8Array.
*
* @param body - Data to append (string or Uint8Array)
*
* @example
* ```typescript
* // JSON stream
* producer.append(JSON.stringify({ message: "hello" }));
*
* // Byte stream
* producer.append("raw text data");
* producer.append(new Uint8Array([1, 2, 3]));
* ```
*/
append(body: Uint8Array | string): void;
/**
* Send any pending batch immediately and wait for all in-flight batches.
*
* Call this before shutdown to ensure all messages are delivered.
*/
flush(): Promise<void>;
/**
* Stop the producer without closing the underlying stream.
*
* Use this when you want to:
* - Hand off writing to another producer
* - Keep the stream open for future writes
* - Stop this producer but not signal EOF to readers
*
* Flushes any pending messages before detaching.
* After calling detach(), further append() calls will throw.
*/
detach(): Promise<void>;
/**
* Flush pending messages and close the underlying stream (EOF).
*
* This is the typical way to end a producer session. It:
* 1. Flushes all pending messages
* 2. Optionally appends a final message
* 3. Closes the stream (no further appends permitted)
*
* **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
* idempotent even with a final message because it uses producer headers
* for deduplication. Safe to retry on network failures.
*
* @param finalMessage - Optional final message to append atomically with close
* @returns CloseResult with the final offset
*/
close(finalMessage?: Uint8Array | string): Promise<CloseResult>;
/**
* Increment epoch and reset sequence.
*
* Call this when restarting the producer to establish a new session.
* Flushes any pending messages first.
*/
restart(): Promise<void>;
/**
* Current epoch for this producer.
*/
get epoch(): number;
/**
* Next sequence number to be assigned.
*/
get nextSeq(): number;
/**
* Number of messages in the current pending batch.
*/
get pendingCount(): number;
/**
* Number of batches currently in flight.
*/
get inFlightCount(): number;
}
//#endregion
//#region src/error.d.ts
/**
* Error thrown for transport/network errors.
* Following the @electric-sql/client FetchError pattern.
*/
declare class FetchError extends Error {
url: string;
status: number;
text?: string;
json?: object;
headers: Record<string, string>;
constructor(status: number, text: string | undefined, json: object | undefined, headers: Record<string, string>, url: string, message?: string);
static fromResponse(response: Response, url: string): Promise<FetchError>;
}
/**
* Error thrown when a fetch operation is aborted during backoff.
*/
declare class FetchBackoffAbortError extends Error {
constructor();
}
/**
* Protocol-level error for Durable Streams operations.
* Provides structured error handling with error codes.
*/
declare class DurableStreamError extends Error {
/**
* HTTP status code, if applicable.
*/
status?: number;
/**
* Structured error code for programmatic handling.
*/
code: DurableStreamErrorCode;
/**
* Additional error details (e.g., raw response body).
*/
details?: unknown;
constructor(message: string, code: DurableStreamErrorCode, status?: number, details?: unknown);
/**
* Create a DurableStreamError from an HTTP response.
*/
static fromResponse(response: Response, url: string): Promise<DurableStreamError>;
/**
* Create a DurableStreamError from a FetchError.
*/
static fromFetchError(error: FetchError): DurableStreamError;
}
/**
* Error thrown when stream URL is missing.
*/
declare class MissingStreamUrlError extends Error {
constructor();
}
/**
* Error thrown when attempting to append to a closed stream.
*/
declare class StreamClosedError extends DurableStreamError {
readonly code: "STREAM_CLOSED";
readonly status = 409;
readonly streamClosed: true;
/**
* The final offset of the stream, if available from the response.
*/
readonly finalOffset?: string;
constructor(url?: string, finalOffset?: string);
}
/**
* Error thrown when signal option is invalid.
*/
declare class InvalidSignalError extends Error {
constructor();
}
//#endregion
//#region src/constants.d.ts
/**
* Durable Streams Protocol Constants
*
* Header and query parameter names following the Electric Durable Stream Protocol.
*/
/**
* Response header containing the next offset to read from.
* Offsets are opaque tokens - clients MUST NOT interpret the format.
*/
declare const STREAM_OFFSET_HEADER = "Stream-Next-Offset";
/**
* Response header for cursor (used for CDN collapsing).
* Echo this value in subsequent long-poll requests.
*/
declare const STREAM_CURSOR_HEADER = "Stream-Cursor";
/**
* Presence header indicating response ends at current end of stream.
* When present (any value), indicates up-to-date.
*/
declare const STREAM_UP_TO_DATE_HEADER = "Stream-Up-To-Date";
/**
* Response/request header indicating stream is closed (EOF).
* When present with value "true", the stream is permanently closed.
*/
declare const STREAM_CLOSED_HEADER = "Stream-Closed";
/**
* Request header for writer coordination sequence.
* Monotonic, lexicographic. If lower than last appended seq -> 409 Conflict.
*/
declare const STREAM_SEQ_HEADER = "Stream-Seq";
/**
* Request header for stream TTL in seconds (on create).
*/
declare const STREAM_TTL_HEADER = "Stream-TTL";
/**
* Request header for absolute stream expiry time (RFC3339, on create).
*/
declare const STREAM_EXPIRES_AT_HEADER = "Stream-Expires-At";
/**
* Request header for producer ID (client-supplied stable identifier).
*/
declare const PRODUCER_ID_HEADER = "Producer-Id";
/**
* Request/response header for producer epoch.
* Client-declared, server-validated monotonically increasing.
*/
declare const PRODUCER_EPOCH_HEADER = "Producer-Epoch";
/**
* Request header for producer sequence number.
* Monotonically increasing per epoch, per-batch (not per-message).
*/
declare const PRODUCER_SEQ_HEADER = "Producer-Seq";
/**
* Response header indicating expected sequence number on 409 Conflict.
*/
declare const PRODUCER_EXPECTED_SEQ_HEADER = "Producer-Expected-Seq";
/**
* Response header indicating received sequence number on 409 Conflict.
*/
declare const PRODUCER_RECEIVED_SEQ_HEADER = "Producer-Received-Seq";
/**
* Query parameter for starting offset.
*/
declare const OFFSET_QUERY_PARAM = "offset";
/**
* Query parameter for live mode.
* Values: "long-poll", "sse"
*/
declare const LIVE_QUERY_PARAM = "live";
/**
* Query parameter for echoing cursor (CDN collapsing).
*/
declare const CURSOR_QUERY_PARAM = "cursor";
/**
* Response header indicating SSE data encoding (e.g., base64 for binary streams).
*/
/**
* SSE control event field for stream closed state.
* Note: Different from HTTP header name (camelCase vs Header-Case).
*/
declare const SSE_CLOSED_FIELD = "streamClosed";
/**
* Content types that are natively compatible with SSE (UTF-8 text).
* Binary content types are also supported via automatic base64 encoding.
*/
declare const SSE_COMPATIBLE_CONTENT_TYPES: ReadonlyArray<string>;
/**
* Protocol query parameters that should not be set by users.
*/
declare const DURABLE_STREAM_PROTOCOL_QUERY_PARAMS: Array<string>;
//#endregion
export { AppendOptions, BackoffDefaults, BackoffOptions, ByteChunk, CURSOR_QUERY_PARAM, CloseOptions, CloseResult, CreateOptions, DURABLE_STREAM_PROTOCOL_QUERY_PARAMS, DurableStream, DurableStreamError, DurableStreamErrorCode, DurableStreamOptions, FetchBackoffAbortError, FetchError, HeadResult, HeadersRecord, IdempotentAppendResult, IdempotentProducer, IdempotentProducerOptions, InvalidSignalError, JsonBatch, JsonBatchMeta, LIVE_QUERY_PARAM, LegacyLiveMode, LiveMode, MaybePromise, MissingStreamUrlError, OFFSET_QUERY_PARAM, Offset, PRODUCER_EPOCH_HEADER, PRODUCER_EXPECTED_SEQ_HEADER, PRODUCER_ID_HEADER, PRODUCER_RECEIVED_SEQ_HEADER, PRODUCER_SEQ_HEADER, ParamsRecord, ReadOptions, ReadableStreamAsyncIterable, RetryOpts, SSEResilienceOptions, SSE_CLOSED_FIELD, SSE_COMPATIBLE_CONTENT_TYPES, STREAM_CLOSED_HEADER, STREAM_CURSOR_HEADER, STREAM_EXPIRES_AT_HEADER, STREAM_OFFSET_HEADER, STREAM_SEQ_HEADER, STREAM_TTL_HEADER, STREAM_UP_TO_DATE_HEADER, SequenceGapError, StaleEpochError, StreamClosedError, StreamErrorHandler, StreamHandleOptions, StreamOptions, StreamResponse, TextChunk, _resetHttpWarningForTesting, asAsyncIterableReadableStream, createFetchWithBackoff, createFetchWithConsumedBody, stream, warnIfUsingHttpInBrowser };