@durable-streams/client
Version:
TypeScript client for the Durable Streams protocol
3,179 lines • 103 kB
JavaScript
"use strict";
//#region src/constants.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.
*/
const STREAM_OFFSET_HEADER = `Stream-Next-Offset`;
/**
* Response header for cursor (used for CDN collapsing).
* Echo this value in subsequent long-poll requests.
*/
const STREAM_CURSOR_HEADER = `Stream-Cursor`;
/**
* Presence header indicating response ends at current end of stream.
* When present (any value), indicates up-to-date.
*/
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.
*/
const STREAM_CLOSED_HEADER = `Stream-Closed`;
/**
* Request header for writer coordination sequence.
* Monotonic, lexicographic. If lower than last appended seq -> 409 Conflict.
*/
const STREAM_SEQ_HEADER = `Stream-Seq`;
/**
* Request header for stream TTL in seconds (on create).
*/
const STREAM_TTL_HEADER = `Stream-TTL`;
/**
* Request header for absolute stream expiry time (RFC3339, on create).
*/
const STREAM_EXPIRES_AT_HEADER = `Stream-Expires-At`;
/**
* Request header for producer ID (client-supplied stable identifier).
*/
const PRODUCER_ID_HEADER = `Producer-Id`;
/**
* Request/response header for producer epoch.
* Client-declared, server-validated monotonically increasing.
*/
const PRODUCER_EPOCH_HEADER = `Producer-Epoch`;
/**
* Request header for producer sequence number.
* Monotonically increasing per epoch, per-batch (not per-message).
*/
const PRODUCER_SEQ_HEADER = `Producer-Seq`;
/**
* Response header indicating expected sequence number on 409 Conflict.
*/
const PRODUCER_EXPECTED_SEQ_HEADER = `Producer-Expected-Seq`;
/**
* Response header indicating received sequence number on 409 Conflict.
*/
const PRODUCER_RECEIVED_SEQ_HEADER = `Producer-Received-Seq`;
/**
* Query parameter for starting offset.
*/
const OFFSET_QUERY_PARAM = `offset`;
/**
* Query parameter for live mode.
* Values: "long-poll", "sse"
*/
const LIVE_QUERY_PARAM = `live`;
/**
* Query parameter for echoing cursor (CDN collapsing).
*/
const CURSOR_QUERY_PARAM = `cursor`;
/**
* Response header indicating SSE data encoding (e.g., base64 for binary streams).
*/
const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`;
/**
* SSE control event field for the next offset.
* Note: Different from HTTP header name (camelCase vs Header-Case).
*/
const SSE_OFFSET_FIELD = `streamNextOffset`;
/**
* SSE control event field for cursor.
* Note: Different from HTTP header name (camelCase vs Header-Case).
*/
const SSE_CURSOR_FIELD = `streamCursor`;
/**
* SSE control event field for stream closed state.
* Note: Different from HTTP header name (camelCase vs Header-Case).
*/
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.
*/
const SSE_COMPATIBLE_CONTENT_TYPES = [`text/`, `application/json`];
/**
* Protocol query parameters that should not be set by users.
*/
const DURABLE_STREAM_PROTOCOL_QUERY_PARAMS = [
OFFSET_QUERY_PARAM,
LIVE_QUERY_PARAM,
CURSOR_QUERY_PARAM
];
//#endregion
//#region src/error.ts
/**
* Error thrown for transport/network errors.
* Following the @electric-sql/client FetchError pattern.
*/
var FetchError = class FetchError extends Error {
status;
text;
json;
headers;
constructor(status, text, json, headers, url, message) {
super(message || `HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`);
this.url = url;
this.name = `FetchError`;
this.status = status;
this.text = text;
this.json = json;
this.headers = headers;
}
static async fromResponse(response, url) {
const status = response.status;
const headers = Object.fromEntries([...response.headers.entries()]);
let text = void 0;
let json = void 0;
const contentType = response.headers.get(`content-type`);
if (!response.bodyUsed && response.body !== null) if (contentType && contentType.includes(`application/json`)) try {
json = await response.json();
} catch {
text = await response.text();
}
else text = await response.text();
return new FetchError(status, text, json, headers, url);
}
};
/**
* Error thrown when a fetch operation is aborted during backoff.
*/
var FetchBackoffAbortError = class extends Error {
constructor() {
super(`Fetch with backoff aborted`);
this.name = `FetchBackoffAbortError`;
}
};
/**
* Protocol-level error for Durable Streams operations.
* Provides structured error handling with error codes.
*/
var DurableStreamError = class DurableStreamError extends Error {
/**
* HTTP status code, if applicable.
*/
status;
/**
* Structured error code for programmatic handling.
*/
code;
/**
* Additional error details (e.g., raw response body).
*/
details;
constructor(message, code, status, details) {
super(message);
this.name = `DurableStreamError`;
this.code = code;
this.status = status;
this.details = details;
}
/**
* Create a DurableStreamError from an HTTP response.
*/
static async fromResponse(response, url) {
const status = response.status;
let details;
const contentType = response.headers.get(`content-type`);
if (!response.bodyUsed && response.body !== null) if (contentType && contentType.includes(`application/json`)) try {
details = await response.json();
} catch {
details = await response.text();
}
else details = await response.text();
const code = statusToCode(status);
const message = `Durable stream error at ${url}: ${response.statusText || status}`;
return new DurableStreamError(message, code, status, details);
}
/**
* Create a DurableStreamError from a FetchError.
*/
static fromFetchError(error) {
const code = statusToCode(error.status);
return new DurableStreamError(error.message, code, error.status, error.json ?? error.text);
}
};
/**
* Map HTTP status codes to DurableStreamErrorCode.
*/
function statusToCode(status) {
switch (status) {
case 400: return `BAD_REQUEST`;
case 401: return `UNAUTHORIZED`;
case 403: return `FORBIDDEN`;
case 404: return `NOT_FOUND`;
case 409: return `CONFLICT_SEQ`;
case 429: return `RATE_LIMITED`;
case 503: return `BUSY`;
default: return `UNKNOWN`;
}
}
/**
* Error thrown when stream URL is missing.
*/
var MissingStreamUrlError = class extends Error {
constructor() {
super(`Invalid stream options: missing required url parameter`);
this.name = `MissingStreamUrlError`;
}
};
/**
* Error thrown when attempting to append to a closed stream.
*/
var StreamClosedError = class extends DurableStreamError {
code = `STREAM_CLOSED`;
status = 409;
streamClosed = true;
/**
* The final offset of the stream, if available from the response.
*/
finalOffset;
constructor(url, finalOffset) {
super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url);
this.name = `StreamClosedError`;
this.finalOffset = finalOffset;
}
};
/**
* Error thrown when signal option is invalid.
*/
var InvalidSignalError = class extends Error {
constructor() {
super(`Invalid signal option. It must be an instance of AbortSignal.`);
this.name = `InvalidSignalError`;
}
};
//#endregion
//#region src/fetch.ts
/**
* HTTP status codes that should be retried.
*/
const HTTP_RETRY_STATUS_CODES = [429, 503];
/**
* Default backoff options.
*/
const BackoffDefaults = {
initialDelay: 100,
maxDelay: 6e4,
multiplier: 1.3,
maxRetries: Infinity
};
/**
* 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.
*/
function parseRetryAfterHeader(retryAfter) {
if (!retryAfter) return 0;
const retryAfterSec = Number(retryAfter);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) return retryAfterSec * 1e3;
const retryDate = Date.parse(retryAfter);
if (!isNaN(retryDate)) {
const deltaMs = retryDate - Date.now();
return Math.max(0, Math.min(deltaMs, 36e5));
}
return 0;
}
/**
* 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
*/
function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
const { initialDelay, maxDelay, multiplier, debug = false, onFailedAttempt, maxRetries = Infinity } = backoffOptions;
return async (...args) => {
const url = args[0];
const options = args[1];
let delay = initialDelay;
let attempt = 0;
while (true) try {
const result = await fetchClient(...args);
if (result.ok) return result;
const err = await FetchError.fromResponse(result, url.toString());
throw err;
} catch (e) {
onFailedAttempt?.();
if (options?.signal?.aborted) throw new FetchBackoffAbortError();
else if (e instanceof FetchError && !HTTP_RETRY_STATUS_CODES.includes(e.status) && e.status >= 400 && e.status < 500) throw e;
else {
attempt++;
if (attempt > maxRetries) {
if (debug) console.log(`Max retries reached (${attempt}/${maxRetries}), giving up`);
throw e;
}
const serverMinimumMs = e instanceof FetchError ? parseRetryAfterHeader(e.headers[`retry-after`]) : 0;
const jitter = Math.random() * delay;
const clientBackoffMs = Math.min(jitter, maxDelay);
const waitMs = Math.max(serverMinimumMs, clientBackoffMs);
if (debug) {
const source = serverMinimumMs > 0 ? `server+client` : `client`;
console.log(`Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`);
}
await new Promise((resolve) => setTimeout(resolve, waitMs));
delay = Math.min(delay * multiplier, maxDelay);
}
}
};
}
/**
* Status codes where we shouldn't try to read the body.
*/
const NO_BODY_STATUS_CODES = [
201,
204,
205
];
/**
* 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
*/
function createFetchWithConsumedBody(fetchClient) {
return async (...args) => {
const url = args[0];
const res = await fetchClient(...args);
try {
if (res.status < 200 || NO_BODY_STATUS_CODES.includes(res.status)) return res;
const buf = await res.arrayBuffer();
return new Response(buf, {
status: res.status,
statusText: res.statusText,
headers: res.headers
});
} catch (err) {
if (args[1]?.signal?.aborted) throw new FetchBackoffAbortError();
throw new FetchError(res.status, void 0, void 0, Object.fromEntries([...res.headers.entries()]), url.toString(), err instanceof Error ? err.message : typeof err === `string` ? err : `failed to read body`);
}
};
}
//#endregion
//#region src/asyncIterableReadableStream.ts
/**
* Check if a value has Symbol.asyncIterator defined.
*/
function hasAsyncIterator(stream$1) {
return typeof Symbol !== `undefined` && typeof Symbol.asyncIterator === `symbol` && typeof stream$1[Symbol.asyncIterator] === `function`;
}
/**
* Define [Symbol.asyncIterator] and .values() on a ReadableStream instance.
*
* Uses getReader().read() to implement spec-consistent iteration.
* On completion or early exit (break/return/throw), releases lock and cancels as appropriate.
*
* **Iterator behavior notes:**
* - `return(value?)` accepts an optional cancellation reason passed to `reader.cancel()`
* - `return()` always resolves with `{ done: true, value: undefined }` regardless of the
* input value. This matches `for await...of` semantics where the return value is ignored.
* Manual iteration users should be aware of this behavior.
*/
function defineAsyncIterator(stream$1) {
if (typeof Symbol === `undefined` || typeof Symbol.asyncIterator !== `symbol`) return;
if (typeof stream$1[Symbol.asyncIterator] === `function`) return;
const createIterator = function() {
const reader = this.getReader();
let finished = false;
let pendingReads = 0;
const iterator = {
async next() {
if (finished) return {
done: true,
value: void 0
};
pendingReads++;
try {
const { value, done } = await reader.read();
if (done) {
finished = true;
reader.releaseLock();
return {
done: true,
value: void 0
};
}
return {
done: false,
value
};
} catch (err) {
finished = true;
try {
reader.releaseLock();
} catch {}
throw err;
} finally {
pendingReads--;
}
},
async return(value) {
if (pendingReads > 0) throw new TypeError(`Cannot close a readable stream reader when it has pending read requests`);
finished = true;
const cancelPromise = reader.cancel(value);
reader.releaseLock();
await cancelPromise;
return {
done: true,
value: void 0
};
},
async throw(err) {
if (pendingReads > 0) throw new TypeError(`Cannot close a readable stream reader when it has pending read requests`);
finished = true;
const cancelPromise = reader.cancel(err);
reader.releaseLock();
await cancelPromise;
throw err;
},
[Symbol.asyncIterator]() {
return this;
}
};
return iterator;
};
try {
Object.defineProperty(stream$1, Symbol.asyncIterator, {
configurable: true,
writable: true,
value: createIterator
});
} catch {
return;
}
try {
Object.defineProperty(stream$1, `values`, {
configurable: true,
writable: true,
value: createIterator
});
} catch {}
}
/**
* 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);
* }
* ```
*/
function asAsyncIterableReadableStream(stream$1) {
if (!hasAsyncIterator(stream$1)) defineAsyncIterator(stream$1);
return stream$1;
}
//#endregion
//#region src/sse.ts
/**
* Parse SSE events from a ReadableStream<Uint8Array>.
* Yields parsed events as they arrive.
*/
async function* parseSSEStream(stream$1, signal) {
const reader = stream$1.getReader();
const decoder = new TextDecoder();
let buffer = ``;
let currentEvent = { data: [] };
try {
while (true) {
if (signal?.aborted) break;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, `\n`).replace(/\r/g, `\n`);
const lines = buffer.split(`\n`);
buffer = lines.pop() ?? ``;
for (const line of lines) if (line === ``) {
if (currentEvent.type && currentEvent.data.length > 0) {
const dataStr = currentEvent.data.join(`\n`);
if (currentEvent.type === `data`) yield {
type: `data`,
data: dataStr
};
else if (currentEvent.type === `control`) try {
const control = JSON.parse(dataStr);
yield {
type: `control`,
streamNextOffset: control.streamNextOffset,
streamCursor: control.streamCursor,
upToDate: control.upToDate,
streamClosed: control.streamClosed
};
} catch (err) {
const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
throw new DurableStreamError(`Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
}
}
currentEvent = { data: [] };
} else if (line.startsWith(`event:`)) {
const eventType = line.slice(6);
currentEvent.type = eventType.startsWith(` `) ? eventType.slice(1) : eventType;
} else if (line.startsWith(`data:`)) {
const content = line.slice(5);
currentEvent.data.push(content.startsWith(` `) ? content.slice(1) : content);
}
}
const remaining = decoder.decode();
if (remaining) buffer += remaining;
if (buffer && currentEvent.type && currentEvent.data.length > 0) {
const dataStr = currentEvent.data.join(`\n`);
if (currentEvent.type === `data`) yield {
type: `data`,
data: dataStr
};
else if (currentEvent.type === `control`) try {
const control = JSON.parse(dataStr);
yield {
type: `control`,
streamNextOffset: control.streamNextOffset,
streamCursor: control.streamCursor,
upToDate: control.upToDate,
streamClosed: control.streamClosed
};
} catch (err) {
const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
throw new DurableStreamError(`Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
}
}
} finally {
reader.releaseLock();
}
}
//#endregion
//#region src/stream-response-state.ts
/**
* Abstract base class for stream response state.
* All state transitions return new immutable state objects.
*/
var StreamResponseState = class {
shouldContinueLive(stopAfterUpToDate, liveMode) {
if (stopAfterUpToDate && this.upToDate) return false;
if (liveMode === false) return false;
if (this.streamClosed) return false;
return true;
}
};
/**
* State for long-poll mode. shouldUseSse() returns false.
*/
var LongPollState = class LongPollState extends StreamResponseState {
offset;
cursor;
upToDate;
streamClosed;
constructor(fields) {
super();
this.offset = fields.offset;
this.cursor = fields.cursor;
this.upToDate = fields.upToDate;
this.streamClosed = fields.streamClosed;
}
shouldUseSse() {
return false;
}
withResponseMetadata(update) {
return new LongPollState({
offset: update.offset ?? this.offset,
cursor: update.cursor ?? this.cursor,
upToDate: update.upToDate,
streamClosed: this.streamClosed || update.streamClosed
});
}
withSSEControl(event) {
const streamClosed = this.streamClosed || (event.streamClosed ?? false);
return new LongPollState({
offset: event.streamNextOffset,
cursor: event.streamCursor || this.cursor,
upToDate: event.streamClosed ?? false ? true : event.upToDate ?? this.upToDate,
streamClosed
});
}
pause() {
return new PausedState(this);
}
};
/**
* State for SSE mode. shouldUseSse() returns true.
* Tracks SSE connection resilience (short connection detection).
*/
var SSEState = class SSEState extends StreamResponseState {
offset;
cursor;
upToDate;
streamClosed;
consecutiveShortConnections;
connectionStartTime;
constructor(fields) {
super();
this.offset = fields.offset;
this.cursor = fields.cursor;
this.upToDate = fields.upToDate;
this.streamClosed = fields.streamClosed;
this.consecutiveShortConnections = fields.consecutiveShortConnections ?? 0;
this.connectionStartTime = fields.connectionStartTime;
}
shouldUseSse() {
return true;
}
withResponseMetadata(update) {
return new SSEState({
offset: update.offset ?? this.offset,
cursor: update.cursor ?? this.cursor,
upToDate: update.upToDate,
streamClosed: this.streamClosed || update.streamClosed,
consecutiveShortConnections: this.consecutiveShortConnections,
connectionStartTime: this.connectionStartTime
});
}
withSSEControl(event) {
const streamClosed = this.streamClosed || (event.streamClosed ?? false);
return new SSEState({
offset: event.streamNextOffset,
cursor: event.streamCursor || this.cursor,
upToDate: event.streamClosed ?? false ? true : event.upToDate ?? this.upToDate,
streamClosed,
consecutiveShortConnections: this.consecutiveShortConnections,
connectionStartTime: this.connectionStartTime
});
}
startConnection(now) {
return new SSEState({
offset: this.offset,
cursor: this.cursor,
upToDate: this.upToDate,
streamClosed: this.streamClosed,
consecutiveShortConnections: this.consecutiveShortConnections,
connectionStartTime: now
});
}
handleConnectionEnd(now, wasAborted, config) {
if (this.connectionStartTime === void 0) return {
action: `healthy`,
state: this
};
const duration = now - this.connectionStartTime;
if (duration < config.minConnectionDuration && !wasAborted) {
const newCount = this.consecutiveShortConnections + 1;
if (newCount >= config.maxShortConnections) return {
action: `fallback`,
state: new LongPollState({
offset: this.offset,
cursor: this.cursor,
upToDate: this.upToDate,
streamClosed: this.streamClosed
})
};
return {
action: `reconnect`,
state: new SSEState({
offset: this.offset,
cursor: this.cursor,
upToDate: this.upToDate,
streamClosed: this.streamClosed,
consecutiveShortConnections: newCount,
connectionStartTime: this.connectionStartTime
}),
backoffAttempt: newCount
};
}
if (duration >= config.minConnectionDuration) return {
action: `healthy`,
state: new SSEState({
offset: this.offset,
cursor: this.cursor,
upToDate: this.upToDate,
streamClosed: this.streamClosed,
consecutiveShortConnections: 0,
connectionStartTime: this.connectionStartTime
})
};
return {
action: `healthy`,
state: this
};
}
pause() {
return new PausedState(this);
}
};
/**
* Paused state wrapper. Delegates all sync field access to the inner state.
* resume() returns the wrapped state unchanged (identity preserved).
*/
var PausedState = class PausedState extends StreamResponseState {
#inner;
constructor(inner) {
super();
this.#inner = inner;
}
get offset() {
return this.#inner.offset;
}
get cursor() {
return this.#inner.cursor;
}
get upToDate() {
return this.#inner.upToDate;
}
get streamClosed() {
return this.#inner.streamClosed;
}
shouldUseSse() {
return this.#inner.shouldUseSse();
}
withResponseMetadata(update) {
const newInner = this.#inner.withResponseMetadata(update);
return new PausedState(newInner);
}
withSSEControl(event) {
const newInner = this.#inner.withSSEControl(event);
return new PausedState(newInner);
}
pause() {
return this;
}
resume() {
return {
state: this.#inner,
justResumed: true
};
}
};
//#endregion
//#region src/response.ts
/**
* Constant used as abort reason when pausing the stream due to visibility change.
*/
const PAUSE_STREAM = `PAUSE_STREAM`;
/**
* Implementation of the StreamResponse interface.
*/
var StreamResponseImpl = class {
url;
contentType;
live;
startOffset;
#headers;
#status;
#statusText;
#ok;
#isLoading;
#syncState;
#isJsonMode;
#abortController;
#fetchNext;
#startSSE;
#closedResolve;
#closedReject;
#closed;
#stopAfterUpToDate = false;
#consumptionMethod = null;
#state = `active`;
#requestAbortController;
#unsubscribeFromVisibilityChanges;
#pausePromise;
#pauseResolve;
#sseResilience;
#encoding;
#responseStream;
constructor(config) {
this.url = config.url;
this.contentType = config.contentType;
this.live = config.live;
this.startOffset = config.startOffset;
const syncFields = {
offset: config.initialOffset,
cursor: config.initialCursor,
upToDate: config.initialUpToDate,
streamClosed: config.initialStreamClosed
};
this.#syncState = config.startSSE ? new SSEState(syncFields) : new LongPollState(syncFields);
this.#headers = config.firstResponse.headers;
this.#status = config.firstResponse.status;
this.#statusText = config.firstResponse.statusText;
this.#ok = config.firstResponse.ok;
this.#isLoading = false;
this.#isJsonMode = config.isJsonMode;
this.#abortController = config.abortController;
this.#fetchNext = config.fetchNext;
this.#startSSE = config.startSSE;
this.#sseResilience = {
minConnectionDuration: config.sseResilience?.minConnectionDuration ?? 1e3,
maxShortConnections: config.sseResilience?.maxShortConnections ?? 3,
backoffBaseDelay: config.sseResilience?.backoffBaseDelay ?? 100,
backoffMaxDelay: config.sseResilience?.backoffMaxDelay ?? 5e3,
logWarnings: config.sseResilience?.logWarnings ?? true
};
this.#encoding = config.encoding;
this.#closed = new Promise((resolve, reject) => {
this.#closedResolve = resolve;
this.#closedReject = reject;
});
this.#responseStream = this.#createResponseStream(config.firstResponse);
this.#abortController.signal.addEventListener(`abort`, () => {
this.#requestAbortController?.abort(this.#abortController.signal.reason);
this.#pauseResolve?.();
this.#pausePromise = void 0;
this.#pauseResolve = void 0;
}, { once: true });
this.#subscribeToVisibilityChanges();
}
/**
* Subscribe to document visibility changes to pause/resume syncing.
* When the page is hidden, we pause to save battery and bandwidth.
* When visible again, we resume syncing.
*/
#subscribeToVisibilityChanges() {
if (typeof document === `object` && typeof document.hidden === `boolean` && typeof document.addEventListener === `function`) {
const visibilityHandler = () => {
if (document.hidden) this.#pause();
else this.#resume();
};
document.addEventListener(`visibilitychange`, visibilityHandler);
this.#unsubscribeFromVisibilityChanges = () => {
if (typeof document === `object`) document.removeEventListener(`visibilitychange`, visibilityHandler);
};
if (document.hidden) this.#pause();
}
}
/**
* Pause the stream when page becomes hidden.
* Aborts any in-flight request to free resources.
* Creates a promise that pull() will await while paused.
*/
#pause() {
if (this.#state === `active`) {
this.#state = `pause-requested`;
this.#syncState = this.#syncState.pause();
this.#pausePromise = new Promise((resolve) => {
this.#pauseResolve = resolve;
});
this.#requestAbortController?.abort(PAUSE_STREAM);
}
}
/**
* Resume the stream when page becomes visible.
* Resolves the pause promise to unblock pull().
*/
#resume() {
if (this.#state === `paused` || this.#state === `pause-requested`) {
if (this.#abortController.signal.aborted) return;
if (this.#syncState instanceof PausedState) this.#syncState = this.#syncState.resume().state;
this.#state = `active`;
this.#pauseResolve?.();
this.#pausePromise = void 0;
this.#pauseResolve = void 0;
}
}
get headers() {
return this.#headers;
}
get status() {
return this.#status;
}
get statusText() {
return this.#statusText;
}
get ok() {
return this.#ok;
}
get isLoading() {
return this.#isLoading;
}
get offset() {
return this.#syncState.offset;
}
get cursor() {
return this.#syncState.cursor;
}
get upToDate() {
return this.#syncState.upToDate;
}
get streamClosed() {
return this.#syncState.streamClosed;
}
#ensureJsonMode() {
if (!this.#isJsonMode) throw new DurableStreamError(`JSON methods are only valid for JSON-mode streams. Content-Type is "${this.contentType}" and json hint was not set.`, `BAD_REQUEST`);
}
#markClosed() {
this.#unsubscribeFromVisibilityChanges?.();
this.#closedResolve();
}
#markError(err) {
this.#unsubscribeFromVisibilityChanges?.();
this.#closedReject(err);
}
/**
* Ensure only one consumption method is used per StreamResponse.
* Throws if any consumption method was already called.
*/
#ensureNoConsumption(method) {
if (this.#consumptionMethod !== null) throw new DurableStreamError(`Cannot call ${method}() - this StreamResponse is already being consumed via ${this.#consumptionMethod}()`, `ALREADY_CONSUMED`);
this.#consumptionMethod = method;
}
/**
* Determine if we should continue with live updates based on live mode
* and whether we've received upToDate or streamClosed.
*/
#shouldContinueLive() {
return this.#syncState.shouldContinueLive(this.#stopAfterUpToDate, this.live);
}
/**
* Update state from response headers.
*/
#updateStateFromResponse(response) {
this.#syncState = this.#syncState.withResponseMetadata({
offset: response.headers.get(STREAM_OFFSET_HEADER) || void 0,
cursor: response.headers.get(STREAM_CURSOR_HEADER) || void 0,
upToDate: response.headers.has(STREAM_UP_TO_DATE_HEADER),
streamClosed: response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
});
this.#headers = response.headers;
this.#status = response.status;
this.#statusText = response.statusText;
this.#ok = response.ok;
}
/**
* Update instance state from an SSE control event.
*/
#updateStateFromSSEControl(controlEvent) {
this.#syncState = this.#syncState.withSSEControl(controlEvent);
}
#updateEncodingFromSSEResponse(response) {
this.#encoding = response.headers.get(STREAM_SSE_DATA_ENCODING_HEADER) === `base64` ? `base64` : void 0;
}
/**
* Mark the start of an SSE connection for duration tracking.
* If the state is not SSEState (e.g., auto-detected SSE from content-type),
* transitions to SSEState first.
*/
#markSSEConnectionStart() {
if (!(this.#syncState instanceof SSEState)) this.#syncState = new SSEState({
offset: this.#syncState.offset,
cursor: this.#syncState.cursor,
upToDate: this.#syncState.upToDate,
streamClosed: this.#syncState.streamClosed
});
this.#syncState = this.#syncState.startConnection(Date.now());
}
/**
* Try to reconnect SSE and return the new iterator, or null if reconnection
* is not possible or fails.
*/
async #trySSEReconnect() {
if (!this.#syncState.shouldUseSse()) return null;
if (!this.#shouldContinueLive() || !this.#startSSE) return null;
const result = this.#syncState.handleConnectionEnd(Date.now(), this.#abortController.signal.aborted, this.#sseResilience);
this.#syncState = result.state;
if (result.action === `fallback`) {
if (this.#sseResilience.logWarnings) console.warn("[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). Falling back to long polling. Your proxy must support streaming SSE responses (not buffer the complete response). Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.");
return null;
}
if (result.action === `reconnect`) {
const maxDelay = Math.min(this.#sseResilience.backoffMaxDelay, this.#sseResilience.backoffBaseDelay * Math.pow(2, result.backoffAttempt));
const delayMs = Math.floor(Math.random() * maxDelay);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
this.#markSSEConnectionStart();
this.#requestAbortController = new AbortController();
const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
this.#updateEncodingFromSSEResponse(newSSEResponse);
if (newSSEResponse.body) return parseSSEStream(newSSEResponse.body, this.#requestAbortController.signal);
return null;
}
/**
* Process SSE events from the iterator.
* Returns an object indicating the result:
* - { type: 'response', response, newIterator? } - yield this response
* - { type: 'closed' } - stream should be closed
* - { type: 'error', error } - an error occurred
* - { type: 'continue', newIterator? } - continue processing (control-only event)
*/
async #processSSEEvents(sseEventIterator) {
const { done, value: event } = await sseEventIterator.next();
if (done) {
try {
const newIterator = await this.#trySSEReconnect();
if (newIterator) return {
type: `continue`,
newIterator
};
} catch (err) {
return {
type: `error`,
error: err instanceof Error ? err : new Error(`SSE reconnection failed`)
};
}
return { type: `closed` };
}
if (event.type === `data`) return this.#processSSEDataEvent(event.data, sseEventIterator);
this.#updateStateFromSSEControl(event);
if (event.upToDate) {
const response = createSSESyntheticResponse(``, event.streamNextOffset, event.streamCursor, true, event.streamClosed ?? false, this.contentType, this.#encoding);
return {
type: `response`,
response
};
}
return { type: `continue` };
}
/**
* Process an SSE data event by waiting for its corresponding control event.
* In SSE protocol, control events come AFTER data events.
* Multiple data events may arrive before a single control event - we buffer them.
*
* For base64 mode, each data event is independently base64 encoded, so we
* collect them as an array and decode each separately.
*/
async #processSSEDataEvent(pendingData, sseEventIterator) {
const bufferedDataParts = [pendingData];
while (true) {
const { done: controlDone, value: controlEvent } = await sseEventIterator.next();
if (controlDone) {
const response = createSSESyntheticResponseFromParts(bufferedDataParts, this.offset, this.cursor, this.upToDate, this.streamClosed, this.contentType, this.#encoding, this.#isJsonMode);
try {
const newIterator = await this.#trySSEReconnect();
return {
type: `response`,
response,
newIterator: newIterator ?? void 0
};
} catch (err) {
return {
type: `error`,
error: err instanceof Error ? err : new Error(`SSE reconnection failed`)
};
}
}
if (controlEvent.type === `control`) {
this.#updateStateFromSSEControl(controlEvent);
const response = createSSESyntheticResponseFromParts(bufferedDataParts, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false, controlEvent.streamClosed ?? false, this.contentType, this.#encoding, this.#isJsonMode);
return {
type: `response`,
response
};
}
bufferedDataParts.push(controlEvent.data);
}
}
/**
* Create the core ReadableStream<Response> that yields responses.
* This is consumed once - all consumption methods use this same stream.
*
* For long-poll mode: yields actual Response objects.
* For SSE mode: yields synthetic Response objects created from SSE data events.
*/
#createResponseStream(firstResponse) {
let firstResponseYielded = false;
let sseEventIterator = null;
return new ReadableStream({
pull: async (controller) => {
try {
if (!firstResponseYielded) {
firstResponseYielded = true;
const isSSE = firstResponse.headers.get(`content-type`)?.includes(`text/event-stream`) ?? false;
if (isSSE && firstResponse.body) {
this.#markSSEConnectionStart();
this.#updateEncodingFromSSEResponse(firstResponse);
this.#requestAbortController = new AbortController();
sseEventIterator = parseSSEStream(firstResponse.body, this.#requestAbortController.signal);
} else {
controller.enqueue(firstResponse);
if (this.upToDate && !this.#shouldContinueLive()) {
this.#markClosed();
controller.close();
return;
}
return;
}
}
if (!sseEventIterator && this.upToDate && this.#startSSE && this.#shouldContinueLive()) {
if (this.#state === `pause-requested` || this.#state === `paused`) {
this.#state = `paused`;
if (this.#pausePromise) await this.#pausePromise;
if (this.#abortController.signal.aborted) {
this.#markClosed();
controller.close();
return;
}
}
this.#markSSEConnectionStart();
this.#requestAbortController = new AbortController();
const sseResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
this.#updateEncodingFromSSEResponse(sseResponse);
if (sseResponse.body) sseEventIterator = parseSSEStream(sseResponse.body, this.#requestAbortController.signal);
}
if (sseEventIterator) {
if (this.#state === `pause-requested` || this.#state === `paused`) {
this.#state = `paused`;
if (this.#pausePromise) await this.#pausePromise;
if (this.#abortController.signal.aborted) {
this.#markClosed();
controller.close();
return;
}
const newIterator = await this.#trySSEReconnect();
if (newIterator) sseEventIterator = newIterator;
else {
this.#markClosed();
controller.close();
return;
}
}
while (true) {
const result = await this.#processSSEEvents(sseEventIterator);
switch (result.type) {
case `response`:
if (result.newIterator) sseEventIterator = result.newIterator;
controller.enqueue(result.response);
return;
case `closed`:
this.#markClosed();
controller.close();
return;
case `error`:
this.#markError(result.error);
controller.error(result.error);
return;
case `continue`:
if (result.newIterator) sseEventIterator = result.newIterator;
continue;
}
}
}
if (this.#shouldContinueLive()) {
let resumingFromPause = false;
if (this.#state === `pause-requested` || this.#state === `paused`) {
this.#state = `paused`;
if (this.#pausePromise) await this.#pausePromise;
if (this.#abortController.signal.aborted) {
this.#markClosed();
controller.close();
return;
}
resumingFromPause = true;
}
if (this.#abortController.signal.aborted) {
this.#markClosed();
controller.close();
return;
}
this.#requestAbortController = new AbortController();
const response = await this.#fetchNext(this.offset, this.cursor, this.#requestAbortController.signal, this.upToDate, resumingFromPause);
this.#updateStateFromResponse(response);
controller.enqueue(response);
return;
}
this.#markClosed();
controller.close();
} catch (err) {
if (this.#requestAbortController?.signal.aborted && this.#requestAbortController.signal.reason === PAUSE_STREAM) {
if (this.#state === `pause-requested`) this.#state = `paused`;
return;
}
if (this.#abortController.signal.aborted) {
this.#markClosed();
controller.close();
} else {
this.#markError(err instanceof Error ? err : new Error(String(err)));
controller.error(err);
}
}
},
cancel: () => {
this.#abortController.abort();
this.#unsubscribeFromVisibilityChanges?.();
this.#markClosed();
}
});
}
/**
* Get the response stream reader. Can only be called once.
*/
#getResponseReader() {
return this.#responseStream.getReader();
}
async body() {
this.#ensureNoConsumption(`body`);
this.#stopAfterUpToDate = true;
const reader = this.#getResponseReader();
const blobs = [];
try {
let result = await reader.read();
while (!result.done) {
const wasUpToDate = this.upToDate;
const blob = await result.value.blob();
if (blob.size > 0) blobs.push(blob);
if (wasUpToDate) break;
result = await reader.read();
}
} finally {
reader.releaseLock();
}
this.#markClosed();
if (blobs.length === 0) return new Uint8Array(0);
if (blobs.length === 1) return new Uint8Array(await blobs[0].arrayBuffer());
const combined = new Blob(blobs);
return new Uint8Array(await combined.arrayBuffer());
}
async json() {
this.#ensureNoConsumption(`json`);
this.#ensureJsonMode();
this.#stopAfterUpToDate = true;
const reader = this.#getResponseReader();
const items = [];
try {
let result = await reader.read();
while (!result.done) {
const wasUpToDate = this.upToDate;
const text = await result.value.text();
const content = text.trim() || `[]`;
let parsed;
try {
parsed = JSON.parse(content);
} catch (err) {
const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
}
if (Array.isArray(parsed)) items.push(...parsed);
else items.push(parsed);
if (wasUpToDate) break;
result = await reader.read();
}
} finally {
reader.releaseLock();
}
this.#markClosed();
return items;
}
async text() {
this.#ensureNoConsumption(`text`);
this.#stopAfterUpToDate = true;
const reader = this.#getResponseReader();
const parts = [];
try {
let result = await reader.read();
while (!result.done) {
const wasUpToDate = this.upToDate;
const text = await result.value.text();
if (text) parts.push(text);
if (wasUpToDate) break;
result = await reader.read();
}
} finally {
reader.releaseLock();
}
this.#markClosed();
return parts.join(``);
}
/**
* Internal helper to create the body stream without consumption check.
* Used by both bodyStream() and textStream().
*/
#createBodyStreamInternal() {
const { readable, writable } = new TransformStream();
const reader = this.#getResponseReader();
const pipeBodyStream = async () => {
try {
let result = await reader.read();
while (!result.done) {
const wasUpToDate = this.upToDate;
const body = result.value.body;
if (body) await body.pipeTo(writable, {
preventClose: true,
preventAbort: true,
preventCancel: true
});
if (wasUpToDate && !this.#shouldContinueLive()) break;
result = await reader.read();
}
await writable.close();
this.#markClosed();
} catch (err) {
if (this.#abortController.signal.aborted) {
try {
await writable.close();
} catch {}
this.#markClosed();
} else {
try {
await writable.abort(err);
} catch {}
this.#markError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
reader.releaseLock();
}
};
pipeBodyStream();
return readable;
}
bodyStream() {
this.#ensureNoConsumption(`bodyStream`);
return asAsyncIterableReadableStream(this.#createBodyStreamInternal());
}
jsonStream() {
this.#ensureNoConsumption(`jsonStream`);
this.#ensureJsonMode();
const reader = this.#getResponseReader();
let pendingItems = [];
const stream$1 = new ReadableStream({
pull: async (controller) => {
if (pendingItems.length > 0) {
controller.enqueue(pendingItems.shift());
return;
}
let result = await reader.read();
while (!result.done) {
const response = result.value;
const text = await response.text();
const content = text.trim() || `[]`;
let parsed;
try {
parsed = JSON.parse(content);
} catch (err) {
const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
}
pendingItems = Array.isArray(parsed) ? parsed : [parsed];
if (pendingItems.length > 0) {
controller.enqueue(pendingItems.shift());
return;
}
result = await reader.read();
}
this.#markClosed();
controller.close();
return;
},
cancel: () => {
reader.releaseLock();
this.cancel();
}
});
return asAsyncIterableReadableStream(stream$1);
}
textStream() {
this.#ensureNoConsumption(`textStream`);
const decoder = new TextDecoder();
const stream$1 = this.#createBodyStreamInternal().pipeThrough(new TransformStream({
transform(chunk, controller) {
controller.enqueue(decoder.decode(chunk, { stream: true }));
},
flush(controller) {
const remaining = decoder.decode();
if (remaining) controller.enqueue(remaining);
}
}));
return asAsyncIterableReadableStream(stream$1);
}
subscribeJson(subscriber) {
this.#ensureNoConsumption(`subscribeJson`);
this.#ensureJsonMode();
const abortController = new AbortController();
const reader = this.#getResponseReader();
const consumeJsonSubscription = async () => {
try {
let result = await reader.read();
while (!result.done) {
if (abortController.signal.aborted) break;
const response = result.value;
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
const text = await response.text();
const content = text.trim() || `[]`;
let parsed;
try {
parsed = JSON.parse(content);
} catch (err) {
const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
}
const items = Array.isArray(parsed) ? parsed : [parsed];
await subscriber({
items,
offset,
cursor,
upToDate,
streamClosed
});
result = await reader.read();
}
this.#markClosed();
} catch (e) {
const isAborted = abortController.signal.aborted;
const isBodyError = e instanceof TypeError && String(e).includes(`Body`);
if (!isAborted && !isBodyError) this.#markError(e instanceof Error ? e : new Error(String(e)));
else this.#markClosed();
} finally {
reader.releaseLock();
}
};
consumeJsonSubscription();
return () => {
abortController.abort();
this.cancel();
};
}
subscribeBytes(subscriber) {
this.#ensureNoConsumption(`subscribeBytes`);
const abortController = new AbortController();
const reader = this.#getResponseReader();
const consumeBytesSubscription = async () => {
try {
let result = await reader.read();
while (!result.done) {
if (abortController.signal.aborted) break;
const response = result.value;
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
const buffer = await response.arrayBuffer();
await subscriber({
data: new Uint8Array(buffer),
offset,
cursor,
upToDate,
streamClosed
});
result = await reader.read();
}
this.#markClosed();
} catch (e) {
const isAborted = abortController.signal.aborted;
const isBodyError = e instanceof TypeError && String(e).includes(`Body`);
if (!isAborted && !isBodyError) this.#markError(e instanceof Error ? e : new Error(String(e)));
else this.#markClosed();
} finally {
reader.releaseLock();
}
};
consumeBytesSubscription();
return () => {
abortController.abort();
this.cancel();
};
}
subscribeText(subscriber) {
this.#ensureNoConsumption(`subscribeText`);
const abortController = new AbortController();
const reader = this.#getResponseReader();
const consumeTextSubscription = async () => {
try {
let result = await reader.read();
while (!result.done) {
if (abortController.signal.aborted) break;
const response = result.value;
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
const text = await response.text();
await subscriber({
text,
offset,
cursor,
upToDate,
streamClosed
});
result = await reader.read();
}
this.#markClosed();
} catch (e) {
const isAborted = abortController.signal.aborted;
const isBodyError = e instanceof TypeError && String(e).includes(`Body`);
if (!isAborted && !isBodyError) this.#markError(e instanceof Error ? e : new Error(String(e)));
else this.#markClosed();
} finally {
reader.releaseLock();
}
};
consumeTextSubscription();
return () => {
abortController.abort();
this.cancel();
};
}
cancel(reason) {
this.#abortController.abort(reason);
this.#unsubscribeFromVisibilityChanges?.();
this.#markClosed();
}
get closed() {
return this.#closed;
}
};
/**
* Extract stream metadata from Response headers.
* Falls back to the provided defaults when headers are absent.
*/
function getMetadataFromResponse(response, fallbackOffset, fallbackCursor, fallbackStreamClosed) {
const offset = response.headers.get(STREAM_OFFSET_HEADER);
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
return {
offset: offset ?? fallbackOffset,
cursor: cursor ?? fallbackCursor,
upToDate,
streamClosed: streamClosed || fallbackStreamClosed
};
}
/**
* Decode base64 string to Uint8Array.
* Per protocol: concatenate data lines, remove \n and \r, then decode.
*/
function decodeBase64(base64Str) {
const cleaned = base64Str.replace(/[\n\r]/g, ``);
if (cleaned.length === 0) return new Uint8Array(0);
if (cleaned.length % 4 !== 0) throw new DurableStreamError(`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`, `PARSE_ERROR`);
try {
if (typeof Buffer !== `undefined`) return new Uint8Array(Buffer.from(cleaned, `base64`));
else {
const binaryStr = atob(cleaned);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
return bytes;
}
} catch (err) {
throw new DurableStreamError(`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`, `PARSE_ERROR`);
}
}
/**
* Create a synthetic Response from SSE data with proper headers.
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
*/
function createSSESyntheticResponse(data, offset, cursor, upToDate, streamClosed, contentType, encoding) {
return createSSESyntheticResponseFromParts([data], offset, cursor, upToDate, streamClosed, contentType, encoding);
}
/**
* Create a synthetic Response from multiple SSE data parts.
* For base64 mode, each part is independently encoded, so we decode each
* separately and concatenate the binary results.
* For text mode, parts are simply concatenated as strings.
*/
function createSSESyntheticResponseFromParts(dataParts, offset, cursor, upToDate, streamClosed, contentType, encoding, isJsonMode) {
const headers = {
"content-type": contentType ?? `application/json`,
[STREAM_OFFSET_HEADER]: String(offset)
};
if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
if (streamClosed) headers[STREAM_CLOSED_HEADER] = `true`;
let body;
if (encoding === `base64`) {
const decodedParts = dataParts.filter((part) => part.length > 0).map((part) => decodeBase64(part));
if (decodedParts.length === 0) body = new ArrayBuffer(0);
else if (decodedParts.length === 1) {
const decoded = decodedParts[0];
body = decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength);
} else {
const totalLength = decodedParts.reduce((sum, part) => sum + part.length, 0);
const combined = new Uint8Array(totalLength);
let offset$1 = 0;
for (const part of decodedParts) {
combined.set(part, offset$1);
offset$1 += part.length;
}
body = combined.buffer;
}
} else if (isJsonMode) {
const mergedParts = [];
for (const part of dataParts) {
const trimmed = part.trim();
if (trimmed.length === 0) continue;
if (trimmed.startsWith(`[`) && trimmed.endsWith(`]`)) {
const inner = trimmed.slice(1, -1).trim();
if (inner.length > 0) mergedParts.push(inner);
} else mergedParts.push(trimmed);
}
body = `[${mergedParts.join(`,`)}]`;
} else body = dataParts.join(``);
return new Response(body, {
status: 200,
headers
});
}
//#endregion
//#region src/utils.ts
/**
* Resolve headers from HeadersRecord (supports async functions).
* Unified implementation used by both stream() and DurableStream.
*/
async function resolveHeaders(headers) {
const resolved = {};
if (!headers) return resolved;
for (const [key, value] of Object.entries(headers)) if (typeof value === `function`) resolved[key] = await value();
else resolved[key] = value;
return resolved;
}
/**
* Handle error responses from the server.
* Throws appropriate DurableStreamError based on status code.
*/
async function handleErrorResponse(response, url, context) {
const status = response.status;
if (status === 404) throw new DurableStreamError(`Stream not found: ${url}`, `NOT_FOUND`, 404);
if (status === 409) {
const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER);
if (streamClosedHeader?.toLowerCase() === `true`) {
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
throw new StreamClosedError(url, finalOffset);
}
const message = context?.operation === `create` ? `Stream already exists: ${url}` : `Sequence conflict: seq is lower than last appended`;
const code = context?.operation === `create` ? `CONFLICT_EXISTS` : `CONFLICT_SEQ`;
throw new DurableStreamError(message, code, 409);
}
if (status === 400) throw new DurableStreamError(`Bad request (possibly content-type mismatch)`, `BAD_REQUEST`, 400);
throw await DurableStreamError.fromResponse(response, url);
}
/**
* Resolve params from ParamsRecord (supports async functions).
*/
async function resolveParams(params) {
const resolved = {};
if (!params) return resolved;
for (const [key, value] of Object.entries(params)) if (value !== void 0) if (typeof value === `function`) resolved[key] = await value();
else resolved[key] = value;
return resolved;
}
const warnedOrigins = new Set();
/**
* Safely read NODE_ENV without triggering "process is not defined" errors.
* Works in both browser and Node.js environments.
*/
function getNodeEnvSafely() {
if (typeof process === `undefined`) return void 0;
return process.env?.NODE_ENV;
}
/**
* Check if we're in a browser environment.
*/
function isBrowserEnvironment() {
return typeof globalThis.window !== `undefined`;
}
/**
* Get window.location.href safely, returning undefined if not available.
*/
function getWindowLocationHref() {
if (typeof globalThis.window !== `undefined` && typeof globalThis.window.location !== `undefined`) return globalThis.window.location.href;
return void 0;
}
/**
* Resolve a URL string, handling relative URLs in browser environments.
* Returns undefined if the URL cannot be parsed.
*/
function resolveUrlMaybe(urlString) {
try {
return new URL(urlString);
} catch {
const base = getWindowLocationHref();
if (base) try {
return new URL(urlString, base);
} catch {
return void 0;
}
return void 0;
}
}
/**
* 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)
*/
function warnIfUsingHttpInBrowser(url, warnOnHttp) {
if (warnOnHttp === false) return;
const nodeEnv = getNodeEnvSafely();
if (nodeEnv === `test`) return;
if (!isBrowserEnvironment() || typeof console === `undefined` || typeof console.warn !== `function`) return;
const urlStr = url instanceof URL ? url.toString() : url;
const parsedUrl = resolveUrlMaybe(urlStr);
if (!parsedUrl) return;
if (parsedUrl.protocol === `http:`) {
if (!warnedOrigins.has(parsedUrl.origin)) {
warnedOrigins.add(parsedUrl.origin);
console.warn("[DurableStream] Using HTTP (not HTTPS) typically limits browsers to ~6 concurrent connections per origin under HTTP/1.1. This can cause slow streams and app freezes with multiple active streams. Use HTTPS for HTTP/2 support. See https://electric-sql.com/r/electric-http2 for more information.");
}
}
}
/**
* Reset the HTTP warning state. Only exported for testing purposes.
* @internal
*/
function _resetHttpWarningForTesting() {
warnedOrigins.clear();
}
//#endregion
//#region src/stream-api.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)
* }
* })
* ```
*/
async function stream(options) {
if (!options.url) throw new DurableStreamError(`Invalid stream options: missing required url parameter`, `BAD_REQUEST`);
let currentHeaders = options.headers;
let currentParams = options.params;
while (true) try {
return await streamInternal({
...options,
headers: currentHeaders,
params: currentParams
});
} catch (err) {
if (options.onError) {
const retryOpts = await options.onError(err instanceof Error ? err : new Error(String(err)));
if (retryOpts === void 0) throw err;
if (retryOpts.params) currentParams = {
...currentParams,
...retryOpts.params
};
if (retryOpts.headers) currentHeaders = {
...currentHeaders,
...retryOpts.headers
};
continue;
}
throw err;
}
}
/**
* Internal implementation of stream that doesn't handle onError retries.
*/
async function streamInternal(options) {
const url = options.url instanceof URL ? options.url.toString() : options.url;
warnIfUsingHttpInBrowser(url, options.warnOnHttp);
const fetchUrl = new URL(url);
const startOffset = options.offset ?? `-1`;
fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, startOffset);
const live = options.live ?? true;
const params = await resolveParams(options.params);
for (const [key, value] of Object.entries(params)) fetchUrl.searchParams.set(key, value);
const headers = await resolveHeaders(options.headers);
const abortController = new AbortController();
if (options.signal) options.signal.addEventListener(`abort`, () => abortController.abort(options.signal?.reason), { once: true });
const baseFetchClient = options.fetch ?? ((...args) => fetch(...args));
const backoffOptions = options.backoffOptions ?? BackoffDefaults;
const fetchClient = createFetchWithBackoff(baseFetchClient, backoffOptions);
let firstResponse;
try {
firstResponse = await fetchClient(fetchUrl.toString(), {
method: `GET`,
headers,
signal: abortController.signal
});
} catch (err) {
if (err instanceof FetchBackoffAbortError) throw new DurableStreamError(`Stream request was aborted`, `UNKNOWN`);
throw err;
}
const contentType = firstResponse.headers.get(`content-type`) ?? void 0;
const initialOffset = firstResponse.headers.get(STREAM_OFFSET_HEADER) ?? startOffset;
const initialCursor = firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? void 0;
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER);
const initialStreamClosed = firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
const isJsonMode = options.json === true || (contentType?.includes(`application/json`) ?? false);
const sseDataEncoding = firstResponse.headers.get(STREAM_SSE_DATA_ENCODING_HEADER);
const encoding = sseDataEncoding === `base64` ? `base64` : void 0;
const fetchNext = async (offset, cursor, signal, upToDate, resumingFromPause) => {
const nextUrl = new URL(url);
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
if (upToDate && !resumingFromPause) {
if (live === true || live === `long-poll`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`);
}
if (cursor) nextUrl.searchParams.set(`cursor`, cursor);
const nextParams = await resolveParams(options.params);
for (const [key, value] of Object.entries(nextParams)) nextUrl.searchParams.set(key, value);
const nextHeaders = await resolveHeaders(options.headers);
const response = await fetchClient(nextUrl.toString(), {
method: `GET`,
headers: nextHeaders,
signal
});
if (!response.ok) await handleErrorResponse(response, url);
return response;
};
const startSSE = live === `sse` ? async (offset, cursor, signal) => {
const sseUrl = new URL(url);
sseUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
sseUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
if (cursor) sseUrl.searchParams.set(`cursor`, cursor);
const sseParams = await resolveParams(options.params);
for (const [key, value] of Object.entries(sseParams)) sseUrl.searchParams.set(key, value);
const sseHeaders = await resolveHeaders(options.headers);
const response = await fetchClient(sseUrl.toString(), {
method: `GET`,
headers: sseHeaders,
signal
});
if (!response.ok) await handleErrorResponse(response, url);
return response;
} : void 0;
return new StreamResponseImpl({
url,
contentType,
live,
startOffset,
isJsonMode,
initialOffset,
initialCursor,
initialUpToDate,
initialStreamClosed,
firstResponse,
abortController,
fetchNext,
startSSE,
sseResilience: options.sseResilience,
encoding
});
}
//#endregion
//#region src/async-queue.ts
var AsyncQueue = class {
#worker;
#concurrency;
#running = 0;
#queue = [];
#head = 0;
#drainResolvers = [];
constructor(worker, concurrency) {
if (!(concurrency >= 1)) throw new Error(`concurrency must be >= 1`);
this.#worker = worker;
this.#concurrency = concurrency;
}
push(task) {
return new Promise((resolve, reject) => {
this.#queue.push({
task,
resolve,
reject
});
this.#processNext();
});
}
/**
* Number of tasks waiting to run.
*/
length() {
return this.#queue.length - this.#head;
}
/**
* Number of tasks currently running.
*/
running() {
return this.#running;
}
/**
* True when nothing is queued or running.
*/
idle() {
return this.#head >= this.#queue.length && this.#running === 0;
}
/**
* Resolves when every queued and in-flight task has completed.
* Resolves immediately if the queue is already idle.
*/
async drained() {
if (this.idle()) return;
return new Promise((resolve) => {
this.#drainResolvers.push(resolve);
});
}
#processNext() {
while (this.#running < this.#concurrency && this.#head < this.#queue.length) {
const item = this.#queue[this.#head];
this.#queue[this.#head] = void 0;
this.#head++;
if (this.#head > 1024 && this.#head > this.#queue.length >>> 1) {
this.#queue = this.#queue.slice(this.#head);
this.#head = 0;
}
this.#running++;
this.#worker(item.task).then((result) => {
this.#running--;
item.resolve(result);
this.#checkDrained();
this.#processNext();
}, (error) => {
this.#running--;
item.reject(error);
this.#checkDrained();
this.#processNext();
});
}
}
#checkDrained() {
if (this.idle()) {
const resolvers = this.#drainResolvers;
this.#drainResolvers = [];
for (const r of resolvers) r();
}
}
};
//#endregion
//#region src/idempotent-producer.ts
/**
* Error thrown when a producer's epoch is stale (zombie fencing).
*/
var StaleEpochError = class extends Error {
/**
* The current epoch on the server.
*/
currentEpoch;
constructor(currentEpoch) {
super(`Producer epoch is stale. Current server epoch: ${currentEpoch}. Call restart() or create a new producer with a higher epoch.`);
this.name = `StaleEpochError`;
this.currentEpoch = currentEpoch;
}
};
/**
* 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).
*/
var SequenceGapError = class extends Error {
expectedSeq;
receivedSeq;
constructor(expectedSeq, receivedSeq) {
super(`Producer sequence gap: expected ${expectedSeq}, received ${receivedSeq}`);
this.name = `SequenceGapError`;
this.expectedSeq = expectedSeq;
this.receivedSeq = receivedSeq;
}
};
/**
* Normalize content-type by extracting the media type (before any semicolon).
*/
function normalizeContentType$1(contentType) {
if (!contentType) return ``;
return contentType.split(`;`)[0].trim().toLowerCase();
}
/**
* 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();
* ```
*/
var IdempotentProducer = class {
#stream;
#producerId;
#epoch;
#nextSeq = 0;
#autoClaim;
#maxBatchBytes;
#lingerMs;
#fetchClient;
#headers;
#signal;
#onError;
#pendingBatch = [];
#batchBytes = 0;
#lingerTimeout = null;
#queue;
#maxInFlight;
#deferredEnqueues = new Set();
#closed = false;
#closeResult = null;
#pendingFinalMessage;
#lastSuccessfulOffset;
#epochClaimed;
#seqState = new Map();
/**
* 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$1, producerId, opts) {
const epoch = opts?.epoch ?? 0;
const maxBatchBytes = opts?.maxBatchBytes ?? 1024 * 1024;
const maxInFlight = opts?.maxInFlight ?? 5;
const lingerMs = opts?.lingerMs ?? 5;
if (epoch < 0) throw new Error(`epoch must be >= 0`);
if (maxBatchBytes <= 0) throw new Error(`maxBatchBytes must be > 0`);
if (maxInFlight <= 0) throw new Error(`maxInFlight must be > 0`);
if (lingerMs < 0) throw new Error(`lingerMs must be >= 0`);
this.#stream = stream$1;
this.#producerId = producerId;
this.#epoch = epoch;
this.#autoClaim = opts?.autoClaim ?? false;
this.#maxBatchBytes = maxBatchBytes;
this.#lingerMs = lingerMs;
this.#signal = opts?.signal;
this.#headers = opts?.headers;
this.#onError = opts?.onError;
this.#fetchClient = opts?.fetch ?? ((...args) => fetch(...args));
this.#maxInFlight = maxInFlight;
this.#epochClaimed = !this.#autoClaim;
this.#queue = new AsyncQueue(this.#batchWorker.bind(this), this.#maxInFlight);
if (this.#signal) this.#signal.addEventListener(`abort`, () => {
this.#rejectPendingBatch(new DurableStreamError(`Producer aborted`, `ALREADY_CLOSED`, void 0, void 0));
}, { once: true });
}
/**
* 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) {
if (this.#closed) throw new DurableStreamError(`Producer is closed`, `ALREADY_CLOSED`, void 0, void 0);
let bytes;
if (typeof body === `string`) bytes = new TextEncoder().encode(body);
else if (body instanceof Uint8Array) bytes = body;
else throw new DurableStreamError(`append() requires string or Uint8Array. For objects, use JSON.stringify().`, `BAD_REQUEST`, 400, void 0);
this.#pendingBatch.push({ body: bytes });
this.#batchBytes += bytes.length;
if (this.#batchBytes >= this.#maxBatchBytes) this.#enqueuePendingBatch();
else if (!this.#lingerTimeout) this.#lingerTimeout = setTimeout(() => {
this.#lingerTimeout = null;
if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
}, this.#lingerMs);
}
/**
* Send any pending batch immediately and wait for all in-flight batches.
*
* Call this before shutdown to ensure all messages are delivered.
*/
async flush() {
if (this.#lingerTimeout) {
clearTimeout(this.#lingerTimeout);
this.#lingerTimeout = null;
}
if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
do {
await this.#queue.drained();
await Promise.all(this.#deferredEnqueues);
} while (this.#deferredEnqueues.size > 0 || this.inFlightCount > 0);
}
/**
* 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.
*/
async detach() {
if (this.#closed) return;
this.#closed = true;
try {
await this.flush();
} catch {}
}
/**
* 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
*/
async close(finalMessage) {
if (this.#closed) {
if (this.#closeResult) return this.#closeResult;
await this.flush();
const result$1 = await this.#doClose(this.#pendingFinalMessage);
this.#closeResult = result$1;
return result$1;
}
this.#closed = true;
this.#pendingFinalMessage = finalMessage;
await this.flush();
const result = await this.#doClose(finalMessage);
this.#closeResult = result;
return result;
}
/**
* Actually close the stream with optional final message.
* Uses producer headers for idempotency.
*/
async #doClose(finalMessage) {
const contentType = this.#stream.contentType ?? `application/octet-stream`;
const isJson = normalizeContentType$1(contentType) === `application/json`;
let body;
if (finalMessage !== void 0) {
const bodyBytes = typeof finalMessage === `string` ? new TextEncoder().encode(finalMessage) : finalMessage;
if (isJson) {
const jsonStr = new TextDecoder().decode(bodyBytes);
body = `[${jsonStr}]`;
} else body = bodyBytes;
}
const seqForThisRequest = this.#nextSeq;
const headers = await this.#buildHeaders({
"content-type": contentType,
[PRODUCER_ID_HEADER]: this.#producerId,
[PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
[PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
[STREAM_CLOSED_HEADER]: `true`
});
const response = await this.#fetchClient(this.#stream.url, {
method: `POST`,
headers,
body,
signal: this.#signal
});
if (response.status === 204) {
this.#nextSeq = seqForThisRequest + 1;
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
this.#recordSuccessfulOffset(finalOffset);
return { finalOffset };
}
if (response.status === 200) {
this.#nextSeq = seqForThisRequest + 1;
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
this.#recordSuccessfulOffset(finalOffset);
return { finalOffset };
}
if (response.status === 403) {
const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : this.#epoch;
if (this.#autoClaim) {
const newEpoch = currentEpoch + 1;
this.#epoch = newEpoch;
this.#nextSeq = 0;
return this.#doClose(finalMessage);
}
throw new StaleEpochError(currentEpoch);
}
const error = await FetchError.fromResponse(response, this.#stream.url);
throw error;
}
/**
* Increment epoch and reset sequence.
*
* Call this when restarting the producer to establish a new session.
* Flushes any pending messages first.
*/
async restart() {
await this.flush();
this.#epoch++;
this.#nextSeq = 0;
}
/**
* Current epoch for this producer.
*/
get epoch() {
return this.#epoch;
}
/**
* Next sequence number to be assigned.
*/
get nextSeq() {
return this.#nextSeq;
}
/**
* Number of messages in the current pending batch.
*/
get pendingCount() {
return this.#pendingBatch.length;
}
/**
* Number of batches currently in flight.
*/
get inFlightCount() {
return this.#queue.length() + this.#queue.running();
}
/**
* The greatest non-empty stream offset returned by a successful producer
* append or close request.
*/
get lastSuccessfulOffset() {
return this.#lastSuccessfulOffset;
}
/**
* Enqueue the current pending batch for processing.
*/
#enqueuePendingBatch() {
if (this.#pendingBatch.length === 0) return;
const batch = this.#pendingBatch;
this.#pendingBatch = [];
this.#batchBytes = 0;
if (this.#autoClaim && !this.#epochClaimed && this.inFlightCount > 0) {
const deferred = this.#queue.drained().then(() => {
this.#pushBatch(batch);
}).finally(() => {
this.#deferredEnqueues.delete(deferred);
});
this.#deferredEnqueues.add(deferred);
deferred.catch(() => {});
} else this.#pushBatch(batch);
}
#pushBatch(batch) {
const seq = this.#nextSeq;
this.#nextSeq++;
this.#queue.push({
batch,
seq
}).catch(() => {});
}
/**
* Batch worker - processes one batch task.
*/
async #batchWorker(task) {
const { batch, seq } = task;
const epoch = this.#epoch;
try {
const result = await this.#doSendBatch(batch, seq, epoch);
this.#recordSuccessfulOffset(result.offset);
if (!this.#epochClaimed) this.#epochClaimed = true;
this.#signalSeqComplete(epoch, seq, void 0);
} catch (error) {
this.#signalSeqComplete(epoch, seq, error);
if (this.#onError) this.#onError(error);
throw error;
}
}
#recordSuccessfulOffset(offset) {
if (offset && (!this.#lastSuccessfulOffset || offset > this.#lastSuccessfulOffset)) this.#lastSuccessfulOffset = offset;
}
/**
* Signal that a sequence has completed (success or failure).
*/
#signalSeqComplete(epoch, seq, error) {
let epochMap = this.#seqState.get(epoch);
if (!epochMap) {
epochMap = new Map();
this.#seqState.set(epoch, epochMap);
}
const state = epochMap.get(seq);
if (state) {
state.resolved = true;
state.error = error;
for (const waiter of state.waiters) waiter(error);
state.waiters = [];
} else epochMap.set(seq, {
resolved: true,
error,
waiters: []
});
const cleanupThreshold = seq - this.#maxInFlight * 3;
if (cleanupThreshold > 0) {
for (const oldSeq of epochMap.keys()) if (oldSeq < cleanupThreshold) epochMap.delete(oldSeq);
}
}
/**
* Wait for a specific sequence to complete.
* Returns immediately if already completed.
* Throws if the sequence failed.
*/
#waitForSeq(epoch, seq) {
let epochMap = this.#seqState.get(epoch);
if (!epochMap) {
epochMap = new Map();
this.#seqState.set(epoch, epochMap);
}
const state = epochMap.get(seq);
if (state?.resolved) {
if (state.error) return Promise.reject(state.error);
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const waiter = (err) => {
if (err) reject(err);
else resolve();
};
if (state) state.waiters.push(waiter);
else epochMap.set(seq, {
resolved: false,
waiters: [waiter]
});
});
}
/**
* Actually send the batch to the server.
* Handles auto-claim retry on 403 (stale epoch) if autoClaim is enabled.
* Does NOT implement general retry/backoff for network errors or 5xx responses.
*/
async #doSendBatch(batch, seq, epoch) {
const contentType = this.#stream.contentType ?? `application/octet-stream`;
const isJson = normalizeContentType$1(contentType) === `application/json`;
let batchedBody;
if (isJson) {
const jsonStrings = batch.map((e) => new TextDecoder().decode(e.body));
batchedBody = `[${jsonStrings.join(`,`)}]`;
} else {
const totalSize = batch.reduce((sum, e) => sum + e.body.length, 0);
const concatenated = new Uint8Array(totalSize);
let offset = 0;
for (const entry of batch) {
concatenated.set(entry.body, offset);
offset += entry.body.length;
}
batchedBody = concatenated;
}
const url = this.#stream.url;
const headers = await this.#buildHeaders({
"content-type": contentType,
[PRODUCER_ID_HEADER]: this.#producerId,
[PRODUCER_EPOCH_HEADER]: epoch.toString(),
[PRODUCER_SEQ_HEADER]: seq.toString()
});
const response = await this.#fetchClient(url, {
method: `POST`,
headers,
body: batchedBody,
signal: this.#signal
});
if (response.status === 204) return {
offset: ``,
duplicate: true
};
if (response.status === 200) {
const resultOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
return {
offset: resultOffset,
duplicate: false
};
}
if (response.status === 403) {
const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : epoch;
if (this.#autoClaim) {
const newEpoch = currentEpoch + 1;
this.#epoch = newEpoch;
this.#nextSeq = 1;
return this.#doSendBatch(batch, 0, newEpoch);
}
throw new StaleEpochError(currentEpoch);
}
if (response.status === 409) {
const expectedSeqStr = response.headers.get(PRODUCER_EXPECTED_SEQ_HEADER);
const expectedSeq = expectedSeqStr ? parseInt(expectedSeqStr, 10) : 0;
if (expectedSeq < seq) {
const waitPromises = [];
for (let s = expectedSeq; s < seq; s++) waitPromises.push(this.#waitForSeq(epoch, s));
await Promise.all(waitPromises);
return this.#doSendBatch(batch, seq, epoch);
}
const receivedSeqStr = response.headers.get(PRODUCER_RECEIVED_SEQ_HEADER);
const receivedSeq = receivedSeqStr ? parseInt(receivedSeqStr, 10) : seq;
throw new SequenceGapError(expectedSeq, receivedSeq);
}
if (response.status === 400) {
const error$1 = await DurableStreamError.fromResponse(response, url);
throw error$1;
}
const error = await FetchError.fromResponse(response, url);
throw error;
}
async #buildHeaders(protocolHeaders) {
const streamHeaders = await this.#stream.resolveHeaders();
const producerHeaders = await resolveHeaders(this.#headers);
return {
...streamHeaders,
...producerHeaders,
...protocolHeaders
};
}
/**
* Clear pending batch and report error.
*/
#rejectPendingBatch(error) {
if (this.#onError && this.#pendingBatch.length > 0) this.#onError(error);
this.#pendingBatch = [];
this.#batchBytes = 0;
if (this.#lingerTimeout) {
clearTimeout(this.#lingerTimeout);
this.#lingerTimeout = null;
}
}
};
//#endregion
//#region src/stream.ts
/**
* Normalize content-type by extracting the media type (before any semicolon).
* Handles cases like "application/json; charset=utf-8".
*/
function normalizeContentType(contentType) {
if (!contentType) return ``;
return contentType.split(`;`)[0].trim().toLowerCase();
}
/**
* Check if a value is a Promise or Promise-like (thenable).
*/
function isPromiseLike(value) {
return value != null && typeof value.then === `function`;
}
/**
* 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"
* });
*
* // Single write
* 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);
* }
* });
* ```
*/
var DurableStream = class DurableStream {
/**
* The URL of the durable stream.
*/
url;
/**
* The content type of the stream (populated after connect/head/read).
*/
contentType;
#options;
#fetchClient;
#baseFetchClient;
#onError;
#batchingEnabled;
#queue;
#buffer = [];
/**
* Create a cold handle to a stream.
* No network IO is performed by the constructor.
*/
constructor(opts) {
validateOptions(opts);
const urlStr = opts.url instanceof URL ? opts.url.toString() : opts.url;
this.url = urlStr;
this.#options = {
...opts,
url: urlStr
};
this.#onError = opts.onError;
if (opts.contentType) this.contentType = opts.contentType;
this.#batchingEnabled = opts.batching !== false;
if (this.#batchingEnabled) this.#queue = new AsyncQueue(this.#batchWorker.bind(this), 1);
this.#baseFetchClient = opts.fetch ?? ((...args) => fetch(...args));
const backOffOpts = { ...opts.backoffOptions ?? BackoffDefaults };
const fetchWithBackoffClient = createFetchWithBackoff(this.#baseFetchClient, backOffOpts);
this.#fetchClient = createFetchWithConsumedBody(fetchWithBackoffClient);
}
/**
* Create a new stream (create-only PUT) and return a handle.
* Fails with DurableStreamError(code="CONFLICT_EXISTS") if it already exists.
*/
static async create(opts) {
const stream$1 = new DurableStream(opts);
await stream$1.create({
contentType: opts.contentType,
ttlSeconds: opts.ttlSeconds,
expiresAt: opts.expiresAt,
body: opts.body,
closed: opts.closed
});
return stream$1;
}
/**
* 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 async connect(opts) {
const stream$1 = new DurableStream(opts);
await stream$1.head();
return stream$1;
}
/**
* HEAD metadata for a stream without creating a handle.
*/
static async head(opts) {
const stream$1 = new DurableStream(opts);
return stream$1.head();
}
/**
* Delete a stream without creating a handle.
*/
static async delete(opts) {
const stream$1 = new DurableStream(opts);
return stream$1.delete();
}
/**
* HEAD metadata for this stream.
*/
async head(opts) {
const { requestHeaders, fetchUrl } = await this.#buildRequest();
const response = await this.#baseFetchClient(fetchUrl.toString(), {
method: `HEAD`,
headers: requestHeaders,
signal: opts?.signal ?? this.#options.signal
});
if (!response.ok) {
if (response.status === 404) return { exists: false };
await handleErrorResponse(response, this.url);
}
const contentType = response.headers.get(`content-type`) ?? void 0;
const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
const etag = response.headers.get(`etag`) ?? void 0;
const cacheControl = response.headers.get(`cache-control`) ?? void 0;
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
if (contentType) this.contentType = contentType;
return {
exists: true,
contentType,
offset,
etag,
cacheControl,
streamClosed
};
}
/**
* Create this stream (create-only PUT) using the URL/auth from the handle.
*/
async create(opts) {
const { requestHeaders, fetchUrl } = await this.#buildRequest();
const contentType = opts?.contentType ?? this.#options.contentType;
if (contentType) requestHeaders[`content-type`] = contentType;
if (opts?.ttlSeconds !== void 0) requestHeaders[STREAM_TTL_HEADER] = String(opts.ttlSeconds);
if (opts?.expiresAt) requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt;
if (opts?.closed) requestHeaders[STREAM_CLOSED_HEADER] = `true`;
const body = encodeBody(opts?.body);
const response = await this.#fetchClient(fetchUrl.toString(), {
method: `PUT`,
headers: requestHeaders,
body,
signal: this.#options.signal
});
if (!response.ok) await handleErrorResponse(response, this.url, { operation: `create` });
const responseContentType = response.headers.get(`content-type`);
if (responseContentType) this.contentType = responseContentType;
else if (contentType) this.contentType = contentType;
return this;
}
/**
* Delete this stream.
*/
async delete(opts) {
const { requestHeaders, fetchUrl } = await this.#buildRequest();
const response = await this.#fetchClient(fetchUrl.toString(), {
method: `DELETE`,
headers: requestHeaders,
signal: opts?.signal ?? this.#options.signal
});
if (!response.ok) await handleErrorResponse(response, this.url);
}
/**
* 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
*/
async close(opts) {
const { requestHeaders, fetchUrl } = await this.#buildRequest();
const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
if (contentType) requestHeaders[`content-type`] = contentType;
requestHeaders[STREAM_CLOSED_HEADER] = `true`;
let body;
if (opts?.body !== void 0) {
const isJson = normalizeContentType(contentType) === `application/json`;
if (isJson) {
const bodyStr = typeof opts.body === `string` ? opts.body : new TextDecoder().decode(opts.body);
body = `[${bodyStr}]`;
} else body = typeof opts.body === `string` ? opts.body : opts.body;
}
const response = await this.#fetchClient(fetchUrl.toString(), {
method: `POST`,
headers: requestHeaders,
body,
signal: opts?.signal ?? this.#options.signal
});
if (response.status === 409) {
const isClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
if (isClosed) {
const finalOffset$1 = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
throw new StreamClosedError(this.url, finalOffset$1);
}
}
if (!response.ok) await handleErrorResponse(response, this.url);
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
return { finalOffset };
}
/**
* Append a single payload to the stream.
*
* Batching: when batching is enabled (default), append() calls that overlap
* in time (e.g. fired without awaiting each one) are coalesced into a
* single POST while a prior POST is in flight. If every call is awaited
* before the next is issued, no batching happens — each call becomes its
* own roundtrip. For tight loops driving an async iterable (e.g. LLM
* token streams), prefer `appendStream()` / `writable()` which pipe the
* source over a single POST, or fire `append()` calls without awaiting
* each one and await the last promise (and `close()`) at the end.
*
* - `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 (single write)
* 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());
*
* // High-frequency writes from an async iterable - fire-and-track-last
* let last: Promise<void> = Promise.resolve();
* for await (const chunk of source) {
* last = stream.append(JSON.stringify(chunk));
* }
* await last;
* await stream.close();
* ```
*/
async append(body, opts) {
const resolvedBody = isPromiseLike(body) ? await body : body;
if (this.#batchingEnabled && this.#queue) return this.#appendWithBatching(resolvedBody, opts);
return this.#appendDirect(resolvedBody, opts);
}
/**
* Direct append without batching (used when batching is disabled).
*/
async #appendDirect(body, opts) {
const { requestHeaders, fetchUrl } = await this.#buildRequest();
const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
if (contentType) requestHeaders[`content-type`] = contentType;
if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
const isJson = normalizeContentType(contentType) === `application/json`;
let encodedBody;
if (isJson) {
const bodyStr = typeof body === `string` ? body : new TextDecoder().decode(body);
encodedBody = `[${bodyStr}]`;
} else if (typeof body === `string`) encodedBody = body;
else encodedBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
const response = await this.#fetchClient(fetchUrl.toString(), {
method: `POST`,
headers: requestHeaders,
body: encodedBody,
signal: opts?.signal ?? this.#options.signal
});
if (!response.ok) await handleErrorResponse(response, this.url);
}
/**
* Append with batching - buffers messages and sends them in batches.
*/
async #appendWithBatching(body, opts) {
return new Promise((resolve, reject) => {
this.#buffer.push({
data: body,
seq: opts?.seq,
contentType: opts?.contentType,
signal: opts?.signal,
resolve,
reject
});
if (this.#queue.idle()) {
const batch = this.#buffer.splice(0);
this.#queue.push(batch).catch((err) => {
for (const msg of batch) msg.reject(err);
});
}
});
}
/**
* Batch worker - processes batches of messages.
*/
async #batchWorker(batch) {
try {
await this.#sendBatch(batch);
for (const msg of batch) msg.resolve();
if (this.#buffer.length > 0) {
const nextBatch = this.#buffer.splice(0);
this.#queue.push(nextBatch).catch((err) => {
for (const msg of nextBatch) msg.reject(err);
});
}
} catch (error) {
for (const msg of batch) msg.reject(error);
for (const msg of this.#buffer) msg.reject(error);
this.#buffer = [];
throw error;
}
}
/**
* Send a batch of messages as a single POST request.
*/
async #sendBatch(batch) {
if (batch.length === 0) return;
const { requestHeaders, fetchUrl } = await this.#buildRequest();
const contentType = batch[0]?.contentType ?? this.#options.contentType ?? this.contentType;
if (contentType) requestHeaders[`content-type`] = contentType;
let highestSeq;
for (let i = batch.length - 1; i >= 0; i--) if (batch[i].seq !== void 0) {
highestSeq = batch[i].seq;
break;
}
if (highestSeq) requestHeaders[STREAM_SEQ_HEADER] = highestSeq;
const isJson = normalizeContentType(contentType) === `application/json`;
let batchedBody;
if (isJson) {
const jsonStrings = batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data));
batchedBody = `[${jsonStrings.join(`,`)}]`;
} else {
const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array);
const hasString = batch.some((m) => typeof m.data === `string`);
if (hasUint8Array && !hasString) {
const chunks = batch.map((m) => m.data);
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
batchedBody = combined;
} else if (hasString && !hasUint8Array) batchedBody = batch.map((m) => m.data).join(``);
else {
const encoder = new TextEncoder();
const chunks = batch.map((m) => typeof m.data === `string` ? encoder.encode(m.data) : m.data);
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
batchedBody = combined;
}
}
const signals = [];
if (this.#options.signal) signals.push(this.#options.signal);
for (const msg of batch) if (msg.signal) signals.push(msg.signal);
const combinedSignal = signals.length > 0 ? AbortSignal.any(signals) : void 0;
const response = await this.#fetchClient(fetchUrl.toString(), {
method: `POST`,
headers: requestHeaders,
body: batchedBody,
signal: combinedSignal
});
if (!response.ok) await handleErrorResponse(response, this.url);
}
/**
* 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!);
* ```
*/
async appendStream(source, opts) {
const { requestHeaders, fetchUrl } = await this.#buildRequest();
const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
if (contentType) requestHeaders[`content-type`] = contentType;
if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
const body = toReadableStream(source);
const response = await this.#fetchClient(fetchUrl.toString(), {
method: `POST`,
headers: requestHeaders,
body,
duplex: `half`,
signal: opts?.signal ?? this.#options.signal
});
if (!response.ok) await handleErrorResponse(response, this.url);
}
/**
* 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) {
const producerId = opts?.producerId ?? `writable-${crypto.randomUUID().slice(0, 8)}`;
let writeError = null;
const producer = new IdempotentProducer(this, producerId, {
autoClaim: true,
headers: opts?.headers,
lingerMs: opts?.lingerMs,
maxBatchBytes: opts?.maxBatchBytes,
onError: (error) => {
if (!writeError) writeError = error;
opts?.onError?.(error);
},
signal: opts?.signal ?? this.#options.signal
});
return new WritableStream({
write(chunk) {
producer.append(chunk);
},
async close() {
await producer.close();
if (writeError) throw writeError;
},
abort(_reason) {
producer.detach().catch((err) => {
opts?.onError?.(err);
});
}
});
}
/**
* 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);
* }
* });
* ```
*/
async stream(options) {
const mergedHeaders = {
...this.#options.headers,
...options?.headers
};
const mergedParams = {
...this.#options.params,
...options?.params
};
return stream({
url: this.url,
headers: mergedHeaders,
params: mergedParams,
signal: options?.signal ?? this.#options.signal,
fetch: this.#options.fetch,
backoffOptions: this.#options.backoffOptions,
offset: options?.offset,
live: options?.live,
json: options?.json,
onError: options?.onError ?? this.#onError,
warnOnHttp: options?.warnOnHttp ?? this.#options.warnOnHttp
});
}
/**
* Resolve the stream's configured headers.
* Used by IdempotentProducer to merge auth headers into its requests.
* @internal
*/
async resolveHeaders() {
return resolveHeaders(this.#options.headers);
}
/**
* Build request headers and URL.
*/
async #buildRequest() {
const requestHeaders = await resolveHeaders(this.#options.headers);
const fetchUrl = new URL(this.url);
const params = await resolveParams(this.#options.params);
for (const [key, value] of Object.entries(params)) fetchUrl.searchParams.set(key, value);
return {
requestHeaders,
fetchUrl
};
}
};
/**
* Encode a body value to the appropriate format.
* Strings are encoded as UTF-8.
* Objects are JSON-serialized.
*/
function encodeBody(body) {
if (body === void 0) return void 0;
if (typeof body === `string`) return new TextEncoder().encode(body);
if (body instanceof Uint8Array) return body;
if (body instanceof Blob || body instanceof FormData || body instanceof ReadableStream || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return body;
return new TextEncoder().encode(JSON.stringify(body));
}
/**
* Convert an async iterable to a ReadableStream.
*/
function toReadableStream(source) {
if (source instanceof ReadableStream) return source.pipeThrough(new TransformStream({ transform(chunk, controller) {
if (typeof chunk === `string`) controller.enqueue(new TextEncoder().encode(chunk));
else controller.enqueue(chunk);
} }));
const encoder = new TextEncoder();
const iterator = source[Symbol.asyncIterator]();
return new ReadableStream({
async pull(controller) {
try {
const { done, value } = await iterator.next();
if (done) controller.close();
else if (typeof value === `string`) controller.enqueue(encoder.encode(value));
else controller.enqueue(value);
} catch (e) {
controller.error(e);
}
},
cancel() {
iterator.return?.();
}
});
}
/**
* Validate stream options.
*/
function validateOptions(options) {
if (!options.url) throw new MissingStreamUrlError();
if (options.signal && !(options.signal instanceof AbortSignal)) throw new InvalidSignalError();
warnIfUsingHttpInBrowser(options.url, options.warnOnHttp);
}
//#endregion
exports.BackoffDefaults = BackoffDefaults
exports.CURSOR_QUERY_PARAM = CURSOR_QUERY_PARAM
exports.DURABLE_STREAM_PROTOCOL_QUERY_PARAMS = DURABLE_STREAM_PROTOCOL_QUERY_PARAMS
exports.DurableStream = DurableStream
exports.DurableStreamError = DurableStreamError
exports.FetchBackoffAbortError = FetchBackoffAbortError
exports.FetchError = FetchError
exports.IdempotentProducer = IdempotentProducer
exports.InvalidSignalError = InvalidSignalError
exports.LIVE_QUERY_PARAM = LIVE_QUERY_PARAM
exports.MissingStreamUrlError = MissingStreamUrlError
exports.OFFSET_QUERY_PARAM = OFFSET_QUERY_PARAM
exports.PRODUCER_EPOCH_HEADER = PRODUCER_EPOCH_HEADER
exports.PRODUCER_EXPECTED_SEQ_HEADER = PRODUCER_EXPECTED_SEQ_HEADER
exports.PRODUCER_ID_HEADER = PRODUCER_ID_HEADER
exports.PRODUCER_RECEIVED_SEQ_HEADER = PRODUCER_RECEIVED_SEQ_HEADER
exports.PRODUCER_SEQ_HEADER = PRODUCER_SEQ_HEADER
exports.SSE_CLOSED_FIELD = SSE_CLOSED_FIELD
exports.SSE_COMPATIBLE_CONTENT_TYPES = SSE_COMPATIBLE_CONTENT_TYPES
exports.SSE_CURSOR_FIELD = SSE_CURSOR_FIELD
exports.SSE_OFFSET_FIELD = SSE_OFFSET_FIELD
exports.STREAM_CLOSED_HEADER = STREAM_CLOSED_HEADER
exports.STREAM_CURSOR_HEADER = STREAM_CURSOR_HEADER
exports.STREAM_EXPIRES_AT_HEADER = STREAM_EXPIRES_AT_HEADER
exports.STREAM_OFFSET_HEADER = STREAM_OFFSET_HEADER
exports.STREAM_SEQ_HEADER = STREAM_SEQ_HEADER
exports.STREAM_TTL_HEADER = STREAM_TTL_HEADER
exports.STREAM_UP_TO_DATE_HEADER = STREAM_UP_TO_DATE_HEADER
exports.SequenceGapError = SequenceGapError
exports.StaleEpochError = StaleEpochError
exports.StreamClosedError = StreamClosedError
exports._resetHttpWarningForTesting = _resetHttpWarningForTesting
exports.asAsyncIterableReadableStream = asAsyncIterableReadableStream
exports.createFetchWithBackoff = createFetchWithBackoff
exports.createFetchWithConsumedBody = createFetchWithConsumedBody
exports.stream = stream
exports.warnIfUsingHttpInBrowser = warnIfUsingHttpInBrowser