UNPKG

@durable-streams/client

Version:

TypeScript client for the Durable Streams protocol

934 lines (813 loc) 24.5 kB
/** * Durable Streams TypeScript Client Types * * Following the Electric Durable Stream Protocol specification. */ import type { ReadableStreamAsyncIterable } from "./asyncIterableReadableStream" import type { BackoffOptions } from "./fetch" /** * 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. */ export type Offset = string /** * Type for values that can be provided immediately or resolved asynchronously. */ export 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) * } * ``` */ export 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. */ export type ParamsRecord = { [key: string]: string | (() => MaybePromise<string>) | undefined } // ============================================================================ // Live Mode Types // ============================================================================ /** * 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 */ export type LiveMode = boolean | `long-poll` | `sse` // ============================================================================ // Stream Options (Read API) // ============================================================================ /** * Options for the stream() function (read-only API). */ export 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. */ export 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 } // ============================================================================ // Chunk & Batch Types // ============================================================================ /** * Metadata for a JSON batch or chunk. */ export 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. */ export interface JsonBatch<T = unknown> extends JsonBatchMeta { /** * The parsed JSON items in this batch. */ items: ReadonlyArray<T> } /** * A chunk of raw bytes with metadata. */ export interface ByteChunk extends JsonBatchMeta { /** * The raw byte data. */ data: Uint8Array } /** * A chunk of text with metadata. */ export interface TextChunk extends JsonBatchMeta { /** * The text content. */ text: string } // ============================================================================ // StreamHandle Options (Read/Write API) // ============================================================================ /** * Base options for StreamHandle operations. */ export 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. */ export 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. */ export 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. */ export 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. */ export 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 */ export type LegacyLiveMode = `long-poll` | `sse` /** * Options for reading from a stream (internal iterator options). * @internal */ export 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. */ export 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. */ export interface ResponseMetadata { /** * Next offset to read from (stream-offset header). */ offset: string /** * Cursor for CDN collapsing (stream-cursor header). */ cursor?: string /** * True if stream-up-to-date header was present. */ upToDate: boolean /** * ETag for caching. */ etag?: string /** * Content type of the stream. */ contentType?: string } /** * Error codes for DurableStreamError. */ export 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. */ export 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 * } * ``` */ export type StreamErrorHandler = ( error: Error ) => void | RetryOpts | Promise<void | RetryOpts> // ============================================================================ // StreamResponse Interface // ============================================================================ /** * 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. */ export interface StreamResponse<TJson = unknown> { // --- Static session info (known after first response) --- /** * 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 // --- Response metadata (updated on each response) --- /** * 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 // --- Evolving state as data arrives --- /** * 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 // ================================= // 1) Accumulating helpers (Promise) // ================================= // Accumulate until first `upToDate`, then resolve and stop. /** * 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> // ===================== // 2) ReadableStreams // ===================== /** * 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> // ===================== // 3) Subscriber APIs // ===================== // Subscribers return Promise<void> for backpressure control. // Note: Only one consumption method can be used per StreamResponse. /** * 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 // ===================== // 4) Lifecycle // ===================== /** * 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> } // ============================================================================ // Idempotent Producer Types // ============================================================================ /** * Options for creating an IdempotentProducer. */ export 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. */ export interface IdempotentAppendResult { /** * The offset after this message was appended. */ offset: Offset /** * Whether this was a duplicate (idempotent success). */ duplicate: boolean }