@durable-streams/client
Version:
TypeScript client for the Durable Streams protocol
1,477 lines (1,314 loc) • 45.7 kB
text/typescript
/**
* StreamResponse - A streaming session for reading from a durable stream.
*
* Represents a live session with fixed `url`, `offset`, and `live` parameters.
* Supports multiple consumption styles: Promise helpers, ReadableStreams, and Subscribers.
*/
import { asAsyncIterableReadableStream } from "./asyncIterableReadableStream"
import {
STREAM_CLOSED_HEADER,
STREAM_CURSOR_HEADER,
STREAM_OFFSET_HEADER,
STREAM_UP_TO_DATE_HEADER,
} from "./constants"
import { DurableStreamError } from "./error"
import { parseSSEStream } from "./sse"
import { LongPollState, PausedState, SSEState } from "./stream-response-state"
import type { ReadableStreamAsyncIterable } from "./asyncIterableReadableStream"
import type { SSEControlEvent, SSEEvent } from "./sse"
import type { StreamResponseState } from "./stream-response-state"
import type {
ByteChunk,
StreamResponse as IStreamResponse,
JsonBatch,
LiveMode,
Offset,
SSEResilienceOptions,
TextChunk,
} from "./types"
/**
* Constant used as abort reason when pausing the stream due to visibility change.
*/
const PAUSE_STREAM = `PAUSE_STREAM`
/**
* State machine for visibility-based pause/resume.
*/
type StreamState = `active` | `pause-requested` | `paused`
/**
* Internal configuration for creating a StreamResponse.
*/
export interface StreamResponseConfig {
/** The stream URL */
url: string
/** Content type from the first response */
contentType?: string
/** Live mode for this session */
live: LiveMode
/** Starting offset */
startOffset: Offset
/** Whether to treat as JSON (hint or content-type) */
isJsonMode: boolean
/** Initial offset from first response headers */
initialOffset: Offset
/** Initial cursor from first response headers */
initialCursor?: string
/** Initial upToDate from first response headers */
initialUpToDate: boolean
/** Initial streamClosed from first response headers */
initialStreamClosed: boolean
/** The held first Response object */
firstResponse: Response
/** Abort controller for the session */
abortController: AbortController
/** Function to fetch the next chunk (for long-poll) */
fetchNext: (
offset: Offset,
cursor: string | undefined,
signal: AbortSignal,
resumingFromPause?: boolean
) => Promise<Response>
/** Function to start SSE connection and return a Response with SSE body */
startSSE?: (
offset: Offset,
cursor: string | undefined,
signal: AbortSignal
) => Promise<Response>
/** SSE resilience options */
sseResilience?: SSEResilienceOptions
/** Encoding for SSE data events */
encoding?: `base64`
}
/**
* Implementation of the StreamResponse interface.
*/
export class StreamResponseImpl<
TJson = unknown,
> implements IStreamResponse<TJson> {
// --- Static session info ---
readonly url: string
readonly contentType?: string
readonly live: LiveMode
readonly startOffset: Offset
// --- Response metadata (updated on each response) ---
#headers: Headers
#status: number
#statusText: string
#ok: boolean
#isLoading: boolean
// --- Evolving state (immutable state machine) ---
#syncState: StreamResponseState
// --- Internal state ---
#isJsonMode: boolean
#abortController: AbortController
#fetchNext: StreamResponseConfig[`fetchNext`]
#startSSE?: StreamResponseConfig[`startSSE`]
#closedResolve!: () => void
#closedReject!: (err: Error) => void
#closed: Promise<void>
#stopAfterUpToDate = false
#consumptionMethod: string | null = null
// --- Visibility/Pause State ---
#state: StreamState = `active`
#requestAbortController?: AbortController
#unsubscribeFromVisibilityChanges?: () => void
#pausePromise?: Promise<void>
#pauseResolve?: () => void
// --- SSE Resilience Config ---
#sseResilience: Required<SSEResilienceOptions>
// --- SSE Encoding State ---
#encoding?: `base64`
// Core primitive: a ReadableStream of Response objects
#responseStream: ReadableStream<Response>
constructor(config: StreamResponseConfig) {
this.url = config.url
this.contentType = config.contentType
this.live = config.live
this.startOffset = config.startOffset
// Initialize immutable state machine — SSEState if SSE is available,
// LongPollState otherwise. The type encodes whether SSE has fallen back.
const syncFields = {
offset: config.initialOffset,
cursor: config.initialCursor,
upToDate: config.initialUpToDate,
streamClosed: config.initialStreamClosed,
}
this.#syncState = config.startSSE
? new SSEState(syncFields)
: new LongPollState(syncFields)
// Initialize response metadata from first response
this.#headers = config.firstResponse.headers
this.#status = config.firstResponse.status
this.#statusText = config.firstResponse.statusText
this.#ok = config.firstResponse.ok
// isLoading is false because stream() already awaited the first response
// before creating this StreamResponse. By the time user has this object,
// the initial request has completed.
this.#isLoading = false
this.#isJsonMode = config.isJsonMode
this.#abortController = config.abortController
this.#fetchNext = config.fetchNext
this.#startSSE = config.startSSE
// Initialize SSE resilience options with defaults
this.#sseResilience = {
minConnectionDuration:
config.sseResilience?.minConnectionDuration ?? 1000,
maxShortConnections: config.sseResilience?.maxShortConnections ?? 3,
backoffBaseDelay: config.sseResilience?.backoffBaseDelay ?? 100,
backoffMaxDelay: config.sseResilience?.backoffMaxDelay ?? 5000,
logWarnings: config.sseResilience?.logWarnings ?? true,
}
// Initialize SSE encoding
this.#encoding = config.encoding
this.#closed = new Promise((resolve, reject) => {
this.#closedResolve = resolve
this.#closedReject = reject
})
// Create the core response stream
this.#responseStream = this.#createResponseStream(config.firstResponse)
// Install single abort listener that propagates to current request controller
// and unblocks any paused pull() (avoids accumulating one listener per request)
this.#abortController.signal.addEventListener(
`abort`,
() => {
this.#requestAbortController?.abort(this.#abortController.signal.reason)
// Unblock pull() if paused, so it can see the abort and close
this.#pauseResolve?.()
this.#pausePromise = undefined
this.#pauseResolve = undefined
},
{ once: true }
)
// Subscribe to visibility changes for pause/resume (browser only)
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(): void {
// Only subscribe in browser environments
if (
typeof document === `object` &&
typeof document.hidden === `boolean` &&
typeof document.addEventListener === `function`
) {
const visibilityHandler = (): void => {
if (document.hidden) {
this.#pause()
} else {
this.#resume()
}
}
document.addEventListener(`visibilitychange`, visibilityHandler)
// Store cleanup function to remove the event listener
// Check document still exists (may be undefined in tests after cleanup)
this.#unsubscribeFromVisibilityChanges = () => {
if (typeof document === `object`) {
document.removeEventListener(`visibilitychange`, visibilityHandler)
}
}
// Check initial state - page might already be hidden when stream starts
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(): void {
if (this.#state === `active`) {
this.#state = `pause-requested`
// Wrap state in PausedState to preserve it across pause/resume
this.#syncState = this.#syncState.pause()
// Create promise that pull() will await
this.#pausePromise = new Promise((resolve) => {
this.#pauseResolve = resolve
})
// Abort current request if any
this.#requestAbortController?.abort(PAUSE_STREAM)
}
}
/**
* Resume the stream when page becomes visible.
* Resolves the pause promise to unblock pull().
*/
#resume(): void {
if (this.#state === `paused` || this.#state === `pause-requested`) {
// Don't resume if the user's signal is already aborted
if (this.#abortController.signal.aborted) {
return
}
// Unwrap PausedState to restore the inner state
if (this.#syncState instanceof PausedState) {
this.#syncState = this.#syncState.resume().state
}
// Transition to active and resolve the pause promise
this.#state = `active`
this.#pauseResolve?.()
this.#pausePromise = undefined
this.#pauseResolve = undefined
}
}
// --- Response metadata getters ---
get headers(): Headers {
return this.#headers
}
get status(): number {
return this.#status
}
get statusText(): string {
return this.#statusText
}
get ok(): boolean {
return this.#ok
}
get isLoading(): boolean {
return this.#isLoading
}
// --- Evolving state getters (delegated to state machine) ---
get offset(): Offset {
return this.#syncState.offset
}
get cursor(): string | undefined {
return this.#syncState.cursor
}
get upToDate(): boolean {
return this.#syncState.upToDate
}
get streamClosed(): boolean {
return this.#syncState.streamClosed
}
// =================================
// Internal helpers
// =================================
#ensureJsonMode(): void {
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(): void {
this.#unsubscribeFromVisibilityChanges?.()
this.#closedResolve()
}
#markError(err: Error): void {
this.#unsubscribeFromVisibilityChanges?.()
this.#closedReject(err)
}
/**
* Ensure only one consumption method is used per StreamResponse.
* Throws if any consumption method was already called.
*/
#ensureNoConsumption(method: string): void {
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(): boolean {
return this.#syncState.shouldContinueLive(
this.#stopAfterUpToDate,
this.live
)
}
/**
* Update state from response headers.
*/
#updateStateFromResponse(response: Response): void {
// Immutable state transition
this.#syncState = this.#syncState.withResponseMetadata({
offset: response.headers.get(STREAM_OFFSET_HEADER) || undefined,
cursor: response.headers.get(STREAM_CURSOR_HEADER) || undefined,
upToDate: response.headers.has(STREAM_UP_TO_DATE_HEADER),
streamClosed:
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`,
})
// Update response metadata to reflect latest server response
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: SSEControlEvent): void {
this.#syncState = this.#syncState.withSSEControl(controlEvent)
}
/**
* 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(): void {
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 as SSEState).startConnection(Date.now())
}
/**
* Try to reconnect SSE and return the new iterator, or null if reconnection
* is not possible or fails.
*/
async #trySSEReconnect(): Promise<AsyncGenerator<
SSEEvent,
void,
undefined
> | null> {
// Check if we should fall back to long-poll (state type encodes this)
if (!this.#syncState.shouldUseSse()) {
return null // Will cause fallback to long-poll
}
if (!this.#shouldContinueLive() || !this.#startSSE) {
return null
}
// Pure state transition: check connection duration, manage counters
const result = (this.#syncState as SSEState).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 // Fallback to long-poll was triggered
}
if (result.action === `reconnect`) {
// Host applies jitter/delay — state machine only returns backoffAttempt
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))
}
// Track new connection start
this.#markSSEConnectionStart()
// Create new per-request abort controller for this SSE connection
this.#requestAbortController = new AbortController()
const newSSEResponse = await this.#startSSE(
this.offset,
this.cursor,
this.#requestAbortController.signal
)
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: AsyncGenerator<SSEEvent, void, undefined>
): Promise<
| {
type: `response`
response: Response
newIterator?: AsyncGenerator<SSEEvent, void, undefined>
}
| { type: `closed` }
| { type: `error`; error: Error }
| {
type: `continue`
newIterator?: AsyncGenerator<SSEEvent, void, undefined>
}
> {
const { done, value: event } = await sseEventIterator.next()
if (done) {
// SSE stream ended - try to reconnect
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`) {
// Wait for the subsequent control event to get correct offset/cursor/upToDate
return this.#processSSEDataEvent(event.data, sseEventIterator)
}
// Control event without preceding data - update state
this.#updateStateFromSSEControl(event)
// If upToDate is signaled, yield an empty response so subscribers receive the signal
// This is important for empty streams and for subscribers waiting for catch-up completion
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: string,
sseEventIterator: AsyncGenerator<SSEEvent, void, undefined>
): Promise<
| {
type: `response`
response: Response
newIterator?: AsyncGenerator<SSEEvent, void, undefined>
}
| { type: `error`; error: Error }
> {
// Buffer to accumulate data from multiple consecutive data events
// For base64 mode, we collect as array since each event is independently encoded
const bufferedDataParts: Array<string> = [pendingData]
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
const { done: controlDone, value: controlEvent } =
await sseEventIterator.next()
if (controlDone) {
// Stream ended without control event - yield buffered data with current state
const response = createSSESyntheticResponseFromParts(
bufferedDataParts,
this.offset,
this.cursor,
this.upToDate,
this.streamClosed,
this.contentType,
this.#encoding,
this.#isJsonMode
)
// Try to reconnect
try {
const newIterator = await this.#trySSEReconnect()
return {
type: `response`,
response,
newIterator: newIterator ?? undefined,
}
} catch (err) {
return {
type: `error`,
error:
err instanceof Error ? err : new Error(`SSE reconnection failed`),
}
}
}
if (controlEvent.type === `control`) {
// Update state and create response with correct metadata
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 }
}
// Got another data event before control - buffer it
// Server sends multiple data events followed by one control event
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: Response): ReadableStream<Response> {
let firstResponseYielded = false
let sseEventIterator: AsyncGenerator<SSEEvent, void, undefined> | null =
null
return new ReadableStream<Response>({
pull: async (controller) => {
try {
// First, yield the held first response (for non-SSE modes)
// For SSE mode, the first response IS the SSE stream, so we start parsing it
if (!firstResponseYielded) {
firstResponseYielded = true
// Check if this is an SSE response
const isSSE =
firstResponse.headers
.get(`content-type`)
?.includes(`text/event-stream`) ?? false
if (isSSE && firstResponse.body) {
// Track SSE connection start for resilience monitoring
this.#markSSEConnectionStart()
// Create per-request abort controller for SSE connection
this.#requestAbortController = new AbortController()
// Start parsing SSE events
sseEventIterator = parseSSEStream(
firstResponse.body,
this.#requestAbortController.signal
)
// Fall through to SSE processing below
} else {
// Regular response - enqueue it
controller.enqueue(firstResponse)
// If upToDate and not continuing live, we're done
if (this.upToDate && !this.#shouldContinueLive()) {
this.#markClosed()
controller.close()
return
}
return
}
}
// SSE mode: process events from the SSE stream
if (sseEventIterator) {
// Check for pause state before processing SSE events
if (this.#state === `pause-requested` || this.#state === `paused`) {
this.#state = `paused`
if (this.#pausePromise) {
await this.#pausePromise
}
// After resume, check if we should still continue
if (this.#abortController.signal.aborted) {
this.#markClosed()
controller.close()
return
}
// Reconnect SSE after resume
const newIterator = await this.#trySSEReconnect()
if (newIterator) {
sseEventIterator = newIterator
} else {
// Could not reconnect - close the stream
this.#markClosed()
controller.close()
return
}
}
// Keep reading events until we get data or stream ends
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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
}
}
}
// Long-poll mode: continue with live updates if needed
if (this.#shouldContinueLive()) {
// Determine if we're resuming from pause — local variable replaces
// the old #justResumedFromPause one-shot field. If we enter the pause
// branch and wake up without abort, we just resumed.
let resumingFromPause = false
if (this.#state === `pause-requested` || this.#state === `paused`) {
this.#state = `paused`
if (this.#pausePromise) {
await this.#pausePromise
}
// After resume, check if we should still continue
if (this.#abortController.signal.aborted) {
this.#markClosed()
controller.close()
return
}
resumingFromPause = true
}
if (this.#abortController.signal.aborted) {
this.#markClosed()
controller.close()
return
}
// Create a new AbortController for this request (so we can abort on pause)
this.#requestAbortController = new AbortController()
const response = await this.#fetchNext(
this.offset,
this.cursor,
this.#requestAbortController.signal,
resumingFromPause
)
this.#updateStateFromResponse(response)
controller.enqueue(response)
// Let the next pull() decide whether to close based on upToDate
return
}
// No more data
this.#markClosed()
controller.close()
} catch (err) {
// Check if this was a pause-triggered abort
// Treat PAUSE_STREAM aborts as benign regardless of current state
// (handles race where resume() was called before abort completed)
if (
this.#requestAbortController?.signal.aborted &&
this.#requestAbortController.signal.reason === PAUSE_STREAM
) {
// Only transition to paused if we're still in pause-requested state
if (this.#state === `pause-requested`) {
this.#state = `paused`
}
// Return - either we're paused, or already resumed and next pull will proceed
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(): ReadableStreamDefaultReader<Response> {
return this.#responseStream.getReader()
}
// =================================
// 1) Accumulating helpers (Promise)
// =================================
async body(): Promise<Uint8Array> {
this.#ensureNoConsumption(`body`)
this.#stopAfterUpToDate = true
const reader = this.#getResponseReader()
const blobs: Array<Blob> = []
try {
let result = await reader.read()
while (!result.done) {
// Capture upToDate BEFORE consuming body (to avoid race with prefetch)
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<T = TJson>(): Promise<Array<T>> {
this.#ensureNoConsumption(`json`)
this.#ensureJsonMode()
this.#stopAfterUpToDate = true
const reader = this.#getResponseReader()
const items: Array<T> = []
try {
let result = await reader.read()
while (!result.done) {
// Capture upToDate BEFORE parsing (to avoid race with prefetch)
const wasUpToDate = this.upToDate
// Get response text first (handles empty responses gracefully)
const text = await result.value.text()
const content = text.trim() || `[]` // Default to empty array if no content or whitespace
let parsed: T | Array<T>
try {
parsed = JSON.parse(content) as T | Array<T>
} 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)
}
// Check if THIS response had upToDate set when we started reading it
if (wasUpToDate) break
result = await reader.read()
}
} finally {
reader.releaseLock()
}
this.#markClosed()
return items
}
async text(): Promise<string> {
this.#ensureNoConsumption(`text`)
this.#stopAfterUpToDate = true
const reader = this.#getResponseReader()
const parts: Array<string> = []
try {
let result = await reader.read()
while (!result.done) {
// Capture upToDate BEFORE consuming text (to avoid race with prefetch)
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(``)
}
// =====================
// 2) ReadableStreams
// =====================
/**
* Internal helper to create the body stream without consumption check.
* Used by both bodyStream() and textStream().
*/
#createBodyStreamInternal(): ReadableStream<Uint8Array> {
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
const reader = this.#getResponseReader()
const pipeBodyStream = async (): Promise<void> => {
try {
let result = await reader.read()
while (!result.done) {
// Capture upToDate BEFORE consuming body (to avoid race with prefetch)
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 {
// Ignore close errors on abort
}
this.#markClosed()
} else {
try {
await writable.abort(err)
} catch {
// Ignore abort errors
}
this.#markError(err instanceof Error ? err : new Error(String(err)))
}
} finally {
reader.releaseLock()
}
}
pipeBodyStream()
return readable
}
bodyStream(): ReadableStreamAsyncIterable<Uint8Array> {
this.#ensureNoConsumption(`bodyStream`)
return asAsyncIterableReadableStream(this.#createBodyStreamInternal())
}
jsonStream(): ReadableStreamAsyncIterable<TJson> {
this.#ensureNoConsumption(`jsonStream`)
this.#ensureJsonMode()
const reader = this.#getResponseReader()
let pendingItems: Array<TJson> = []
const stream = new ReadableStream<TJson>({
pull: async (controller) => {
// Drain pending items first
if (pendingItems.length > 0) {
controller.enqueue(pendingItems.shift())
return
}
// Keep reading until we can enqueue at least one item.
// This avoids stalling when a response contains an empty JSON array.
let result = await reader.read()
while (!result.done) {
const response = result.value
// Parse JSON and flatten arrays (handle empty responses gracefully)
const text = await response.text()
const content = text.trim() || `[]` // Default to empty array if no content or whitespace
let parsed: TJson | Array<TJson>
try {
parsed = JSON.parse(content) as TJson | Array<TJson>
} 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
}
// Empty JSON batch; read the next response.
result = await reader.read()
}
this.#markClosed()
controller.close()
return
},
cancel: () => {
reader.releaseLock()
this.cancel()
},
})
return asAsyncIterableReadableStream(stream)
}
textStream(): ReadableStreamAsyncIterable<string> {
this.#ensureNoConsumption(`textStream`)
const decoder = new TextDecoder()
const stream = this.#createBodyStreamInternal().pipeThrough(
new TransformStream<Uint8Array, string>({
transform(chunk, controller) {
controller.enqueue(decoder.decode(chunk, { stream: true }))
},
flush(controller) {
const remaining = decoder.decode()
if (remaining) {
controller.enqueue(remaining)
}
},
})
)
return asAsyncIterableReadableStream(stream)
}
// =====================
// 3) Subscriber APIs
// =====================
subscribeJson<T = TJson>(
subscriber: (batch: JsonBatch<T>) => void | Promise<void>
): () => void {
this.#ensureNoConsumption(`subscribeJson`)
this.#ensureJsonMode()
const abortController = new AbortController()
const reader = this.#getResponseReader()
const consumeJsonSubscription = async (): Promise<void> => {
try {
let result = await reader.read()
while (!result.done) {
if (abortController.signal.aborted) break
// Get metadata from Response headers (not from `this` which may be stale)
const response = result.value
const { offset, cursor, upToDate, streamClosed } =
getMetadataFromResponse(
response,
this.offset,
this.cursor,
this.streamClosed
)
// Get response text first (handles empty responses gracefully)
const text = await response.text()
const content = text.trim() || `[]` // Default to empty array if no content or whitespace
let parsed: T | Array<T>
try {
parsed = JSON.parse(content) as T | Array<T>
} 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 callback (handles both sync and async)
await subscriber({
items,
offset,
cursor,
upToDate,
streamClosed,
})
result = await reader.read()
}
this.#markClosed()
} catch (e) {
// Ignore abort-related and body-consumed errors
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: (chunk: ByteChunk) => void | Promise<void>
): () => void {
this.#ensureNoConsumption(`subscribeBytes`)
const abortController = new AbortController()
const reader = this.#getResponseReader()
const consumeBytesSubscription = async (): Promise<void> => {
try {
let result = await reader.read()
while (!result.done) {
if (abortController.signal.aborted) break
// Get metadata from Response headers (not from `this` which may be stale)
const response = result.value
const { offset, cursor, upToDate, streamClosed } =
getMetadataFromResponse(
response,
this.offset,
this.cursor,
this.streamClosed
)
const buffer = await response.arrayBuffer()
// Await callback (handles both sync and async)
await subscriber({
data: new Uint8Array(buffer),
offset,
cursor,
upToDate,
streamClosed,
})
result = await reader.read()
}
this.#markClosed()
} catch (e) {
// Ignore abort-related and body-consumed errors
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: (chunk: TextChunk) => void | Promise<void>
): () => void {
this.#ensureNoConsumption(`subscribeText`)
const abortController = new AbortController()
const reader = this.#getResponseReader()
const consumeTextSubscription = async (): Promise<void> => {
try {
let result = await reader.read()
while (!result.done) {
if (abortController.signal.aborted) break
// Get metadata from Response headers (not from `this` which may be stale)
const response = result.value
const { offset, cursor, upToDate, streamClosed } =
getMetadataFromResponse(
response,
this.offset,
this.cursor,
this.streamClosed
)
const text = await response.text()
// Await callback (handles both sync and async)
await subscriber({
text,
offset,
cursor,
upToDate,
streamClosed,
})
result = await reader.read()
}
this.#markClosed()
} catch (e) {
// Ignore abort-related and body-consumed errors
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()
}
}
// =====================
// 4) Lifecycle
// =====================
cancel(reason?: unknown): void {
this.#abortController.abort(reason)
this.#unsubscribeFromVisibilityChanges?.()
this.#markClosed()
}
get closed(): Promise<void> {
return this.#closed
}
}
// =================================
// Pure helper functions
// =================================
/**
* Extract stream metadata from Response headers.
* Falls back to the provided defaults when headers are absent.
*/
function getMetadataFromResponse(
response: Response,
fallbackOffset: Offset,
fallbackCursor: string | undefined,
fallbackStreamClosed: boolean
): {
offset: Offset
cursor: string | undefined
upToDate: boolean
streamClosed: boolean
} {
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: string): Uint8Array {
// Remove all newlines and carriage returns per protocol
const cleaned = base64Str.replace(/[\n\r]/g, ``)
// Empty string is valid
if (cleaned.length === 0) {
return new Uint8Array(0)
}
// Validate length is multiple of 4
if (cleaned.length % 4 !== 0) {
throw new DurableStreamError(
`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`,
`PARSE_ERROR`
)
}
try {
// Prefer Buffer (native C++ in Node) over atob (requires JS charCodeAt loop)
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: string,
offset: Offset,
cursor: string | undefined,
upToDate: boolean,
streamClosed: boolean,
contentType: string | undefined,
encoding: `base64` | undefined
): Response {
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: Array<string>,
offset: Offset,
cursor: string | undefined,
upToDate: boolean,
streamClosed: boolean,
contentType: string | undefined,
encoding: `base64` | undefined,
isJsonMode?: boolean
): Response {
const headers: Record<string, string> = {
"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`
}
// Decode base64 if encoding is used
let body: BodyInit
if (encoding === `base64`) {
// Each data part is independently base64 encoded, decode each separately
const decodedParts = dataParts
.filter((part) => part.length > 0)
.map((part) => decodeBase64(part))
if (decodedParts.length === 0) {
// No data - return empty body
body = new ArrayBuffer(0)
} else if (decodedParts.length === 1) {
// Single part - use directly
const decoded = decodedParts[0]!
body = decoded.buffer.slice(
decoded.byteOffset,
decoded.byteOffset + decoded.byteLength
) as ArrayBuffer
} else {
// Multiple parts - concatenate binary data
const totalLength = decodedParts.reduce(
(sum, part) => sum + part.length,
0
)
const combined = new Uint8Array(totalLength)
let offset = 0
for (const part of decodedParts) {
combined.set(part, offset)
offset += part.length
}
body = combined.buffer
}
} else if (isJsonMode) {
const mergedParts: Array<string> = []
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 })
}