@durable-streams/client
Version:
TypeScript client for the Durable Streams protocol
307 lines (268 loc) • 8.63 kB
text/typescript
/**
* Explicit state machine for StreamResponseImpl.
*
* Every transition returns a new state — no mutation.
*
* Hierarchy:
* StreamResponseState (abstract)
* ├── LongPollState shouldUseSse() → false
* ├── SSEState shouldUseSse() → true
* └── PausedState delegates to wrapped inner state
*/
import type { SSEControlEvent } from "./sse"
import type { LiveMode, Offset, SSEResilienceOptions } from "./types"
/**
* Shared sync fields across all state types.
*/
export interface SyncFields {
readonly offset: Offset
readonly cursor: string | undefined
readonly upToDate: boolean
readonly streamClosed: boolean
}
/**
* Extracted metadata from an HTTP response for state transitions.
* undefined values mean "not present in response, preserve current value".
*/
export interface ResponseMetadataUpdate {
readonly offset?: string
readonly cursor?: string
readonly upToDate: boolean
readonly streamClosed: boolean
}
/**
* Result of SSEState.handleConnectionEnd().
*/
export type SSEConnectionEndResult =
| {
readonly action: `reconnect`
readonly state: SSEState
readonly backoffAttempt: number
}
| { readonly action: `fallback`; readonly state: LongPollState }
| { readonly action: `healthy`; readonly state: SSEState }
/**
* Abstract base class for stream response state.
* All state transitions return new immutable state objects.
*/
export abstract class StreamResponseState implements SyncFields {
abstract readonly offset: Offset
abstract readonly cursor: string | undefined
abstract readonly upToDate: boolean
abstract readonly streamClosed: boolean
abstract shouldUseSse(): boolean
abstract withResponseMetadata(
update: ResponseMetadataUpdate
): StreamResponseState
abstract withSSEControl(event: SSEControlEvent): StreamResponseState
abstract pause(): StreamResponseState
shouldContinueLive(stopAfterUpToDate: boolean, liveMode: LiveMode): boolean {
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.
*/
export class LongPollState extends StreamResponseState {
readonly offset: Offset
readonly cursor: string | undefined
readonly upToDate: boolean
readonly streamClosed: boolean
constructor(fields: SyncFields) {
super()
this.offset = fields.offset
this.cursor = fields.cursor
this.upToDate = fields.upToDate
this.streamClosed = fields.streamClosed
}
shouldUseSse(): boolean {
return false
}
withResponseMetadata(update: ResponseMetadataUpdate): LongPollState {
return new LongPollState({
offset: update.offset ?? this.offset,
cursor: update.cursor ?? this.cursor,
upToDate: update.upToDate,
streamClosed: this.streamClosed || update.streamClosed,
})
}
withSSEControl(event: SSEControlEvent): LongPollState {
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(): PausedState {
return new PausedState(this)
}
}
/**
* State for SSE mode. shouldUseSse() returns true.
* Tracks SSE connection resilience (short connection detection).
*/
export class SSEState extends StreamResponseState {
readonly offset: Offset
readonly cursor: string | undefined
readonly upToDate: boolean
readonly streamClosed: boolean
readonly consecutiveShortConnections: number
readonly connectionStartTime: number | undefined
constructor(
fields: SyncFields & {
consecutiveShortConnections?: number
connectionStartTime?: number
}
) {
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(): boolean {
return true
}
withResponseMetadata(update: ResponseMetadataUpdate): SSEState {
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: SSEControlEvent): SSEState {
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: number): SSEState {
return new SSEState({
offset: this.offset,
cursor: this.cursor,
upToDate: this.upToDate,
streamClosed: this.streamClosed,
consecutiveShortConnections: this.consecutiveShortConnections,
connectionStartTime: now,
})
}
handleConnectionEnd(
now: number,
wasAborted: boolean,
config: Required<SSEResilienceOptions>
): SSEConnectionEndResult {
if (this.connectionStartTime === undefined) {
return { action: `healthy`, state: this }
}
const duration = now - this.connectionStartTime
if (duration < config.minConnectionDuration && !wasAborted) {
// Connection was too short — likely proxy buffering or misconfiguration
const newCount = this.consecutiveShortConnections + 1
if (newCount >= config.maxShortConnections) {
// Threshold reached → permanent fallback to long-poll
return {
action: `fallback`,
state: new LongPollState({
offset: this.offset,
cursor: this.cursor,
upToDate: this.upToDate,
streamClosed: this.streamClosed,
}),
}
}
// Reconnect with backoff
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) {
// Healthy connection — reset counter
return {
action: `healthy`,
state: new SSEState({
offset: this.offset,
cursor: this.cursor,
upToDate: this.upToDate,
streamClosed: this.streamClosed,
consecutiveShortConnections: 0,
connectionStartTime: this.connectionStartTime,
}),
}
}
// Aborted connection — don't change counter
return { action: `healthy`, state: this }
}
pause(): PausedState {
return new PausedState(this)
}
}
/**
* Paused state wrapper. Delegates all sync field access to the inner state.
* resume() returns the wrapped state unchanged (identity preserved).
*/
export class PausedState extends StreamResponseState {
readonly #inner: LongPollState | SSEState
constructor(inner: LongPollState | SSEState) {
super()
this.#inner = inner
}
get offset(): Offset {
return this.#inner.offset
}
get cursor(): string | undefined {
return this.#inner.cursor
}
get upToDate(): boolean {
return this.#inner.upToDate
}
get streamClosed(): boolean {
return this.#inner.streamClosed
}
shouldUseSse(): boolean {
return this.#inner.shouldUseSse()
}
withResponseMetadata(update: ResponseMetadataUpdate): PausedState {
const newInner = this.#inner.withResponseMetadata(update)
return new PausedState(newInner)
}
withSSEControl(event: SSEControlEvent): PausedState {
const newInner = this.#inner.withSSEControl(event)
return new PausedState(newInner)
}
pause(): PausedState {
return this
}
resume(): { state: LongPollState | SSEState; justResumed: true } {
return { state: this.#inner, justResumed: true }
}
}