@durable-streams/client 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/intent.js +6 -0
- package/dist/index.cjs +364 -168
- package/dist/index.js +364 -168
- package/package.json +10 -3
- package/skills/getting-started/SKILL.md +223 -0
- package/skills/go-to-production/SKILL.md +243 -0
- package/skills/reading-streams/SKILL.md +247 -0
- package/skills/reading-streams/references/stream-response-methods.md +133 -0
- package/skills/server-deployment/SKILL.md +211 -0
- package/skills/writing-data/SKILL.md +311 -0
- package/src/response.ts +332 -303
- package/src/stream-response-state.ts +306 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit state machine for StreamResponseImpl.
|
|
3
|
+
*
|
|
4
|
+
* Every transition returns a new state — no mutation.
|
|
5
|
+
*
|
|
6
|
+
* Hierarchy:
|
|
7
|
+
* StreamResponseState (abstract)
|
|
8
|
+
* ├── LongPollState shouldUseSse() → false
|
|
9
|
+
* ├── SSEState shouldUseSse() → true
|
|
10
|
+
* └── PausedState delegates to wrapped inner state
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SSEControlEvent } from "./sse"
|
|
14
|
+
import type { LiveMode, Offset, SSEResilienceOptions } from "./types"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shared sync fields across all state types.
|
|
18
|
+
*/
|
|
19
|
+
export interface SyncFields {
|
|
20
|
+
readonly offset: Offset
|
|
21
|
+
readonly cursor: string | undefined
|
|
22
|
+
readonly upToDate: boolean
|
|
23
|
+
readonly streamClosed: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extracted metadata from an HTTP response for state transitions.
|
|
28
|
+
* undefined values mean "not present in response, preserve current value".
|
|
29
|
+
*/
|
|
30
|
+
export interface ResponseMetadataUpdate {
|
|
31
|
+
readonly offset?: string
|
|
32
|
+
readonly cursor?: string
|
|
33
|
+
readonly upToDate: boolean
|
|
34
|
+
readonly streamClosed: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Result of SSEState.handleConnectionEnd().
|
|
39
|
+
*/
|
|
40
|
+
export type SSEConnectionEndResult =
|
|
41
|
+
| {
|
|
42
|
+
readonly action: `reconnect`
|
|
43
|
+
readonly state: SSEState
|
|
44
|
+
readonly backoffAttempt: number
|
|
45
|
+
}
|
|
46
|
+
| { readonly action: `fallback`; readonly state: LongPollState }
|
|
47
|
+
| { readonly action: `healthy`; readonly state: SSEState }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Abstract base class for stream response state.
|
|
51
|
+
* All state transitions return new immutable state objects.
|
|
52
|
+
*/
|
|
53
|
+
export abstract class StreamResponseState implements SyncFields {
|
|
54
|
+
abstract readonly offset: Offset
|
|
55
|
+
abstract readonly cursor: string | undefined
|
|
56
|
+
abstract readonly upToDate: boolean
|
|
57
|
+
abstract readonly streamClosed: boolean
|
|
58
|
+
|
|
59
|
+
abstract shouldUseSse(): boolean
|
|
60
|
+
abstract withResponseMetadata(
|
|
61
|
+
update: ResponseMetadataUpdate
|
|
62
|
+
): StreamResponseState
|
|
63
|
+
abstract withSSEControl(event: SSEControlEvent): StreamResponseState
|
|
64
|
+
abstract pause(): StreamResponseState
|
|
65
|
+
|
|
66
|
+
shouldContinueLive(stopAfterUpToDate: boolean, liveMode: LiveMode): boolean {
|
|
67
|
+
if (stopAfterUpToDate && this.upToDate) return false
|
|
68
|
+
if (liveMode === false) return false
|
|
69
|
+
if (this.streamClosed) return false
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* State for long-poll mode. shouldUseSse() returns false.
|
|
76
|
+
*/
|
|
77
|
+
export class LongPollState extends StreamResponseState {
|
|
78
|
+
readonly offset: Offset
|
|
79
|
+
readonly cursor: string | undefined
|
|
80
|
+
readonly upToDate: boolean
|
|
81
|
+
readonly streamClosed: boolean
|
|
82
|
+
|
|
83
|
+
constructor(fields: SyncFields) {
|
|
84
|
+
super()
|
|
85
|
+
this.offset = fields.offset
|
|
86
|
+
this.cursor = fields.cursor
|
|
87
|
+
this.upToDate = fields.upToDate
|
|
88
|
+
this.streamClosed = fields.streamClosed
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
shouldUseSse(): boolean {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
withResponseMetadata(update: ResponseMetadataUpdate): LongPollState {
|
|
96
|
+
return new LongPollState({
|
|
97
|
+
offset: update.offset ?? this.offset,
|
|
98
|
+
cursor: update.cursor ?? this.cursor,
|
|
99
|
+
upToDate: update.upToDate,
|
|
100
|
+
streamClosed: this.streamClosed || update.streamClosed,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
withSSEControl(event: SSEControlEvent): LongPollState {
|
|
105
|
+
const streamClosed = this.streamClosed || (event.streamClosed ?? false)
|
|
106
|
+
return new LongPollState({
|
|
107
|
+
offset: event.streamNextOffset,
|
|
108
|
+
cursor: event.streamCursor || this.cursor,
|
|
109
|
+
upToDate:
|
|
110
|
+
(event.streamClosed ?? false)
|
|
111
|
+
? true
|
|
112
|
+
: (event.upToDate ?? this.upToDate),
|
|
113
|
+
streamClosed,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pause(): PausedState {
|
|
118
|
+
return new PausedState(this)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* State for SSE mode. shouldUseSse() returns true.
|
|
124
|
+
* Tracks SSE connection resilience (short connection detection).
|
|
125
|
+
*/
|
|
126
|
+
export class SSEState extends StreamResponseState {
|
|
127
|
+
readonly offset: Offset
|
|
128
|
+
readonly cursor: string | undefined
|
|
129
|
+
readonly upToDate: boolean
|
|
130
|
+
readonly streamClosed: boolean
|
|
131
|
+
readonly consecutiveShortConnections: number
|
|
132
|
+
readonly connectionStartTime: number | undefined
|
|
133
|
+
|
|
134
|
+
constructor(
|
|
135
|
+
fields: SyncFields & {
|
|
136
|
+
consecutiveShortConnections?: number
|
|
137
|
+
connectionStartTime?: number
|
|
138
|
+
}
|
|
139
|
+
) {
|
|
140
|
+
super()
|
|
141
|
+
this.offset = fields.offset
|
|
142
|
+
this.cursor = fields.cursor
|
|
143
|
+
this.upToDate = fields.upToDate
|
|
144
|
+
this.streamClosed = fields.streamClosed
|
|
145
|
+
this.consecutiveShortConnections = fields.consecutiveShortConnections ?? 0
|
|
146
|
+
this.connectionStartTime = fields.connectionStartTime
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
shouldUseSse(): boolean {
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
withResponseMetadata(update: ResponseMetadataUpdate): SSEState {
|
|
154
|
+
return new SSEState({
|
|
155
|
+
offset: update.offset ?? this.offset,
|
|
156
|
+
cursor: update.cursor ?? this.cursor,
|
|
157
|
+
upToDate: update.upToDate,
|
|
158
|
+
streamClosed: this.streamClosed || update.streamClosed,
|
|
159
|
+
consecutiveShortConnections: this.consecutiveShortConnections,
|
|
160
|
+
connectionStartTime: this.connectionStartTime,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
withSSEControl(event: SSEControlEvent): SSEState {
|
|
165
|
+
const streamClosed = this.streamClosed || (event.streamClosed ?? false)
|
|
166
|
+
return new SSEState({
|
|
167
|
+
offset: event.streamNextOffset,
|
|
168
|
+
cursor: event.streamCursor || this.cursor,
|
|
169
|
+
upToDate:
|
|
170
|
+
(event.streamClosed ?? false)
|
|
171
|
+
? true
|
|
172
|
+
: (event.upToDate ?? this.upToDate),
|
|
173
|
+
streamClosed,
|
|
174
|
+
consecutiveShortConnections: this.consecutiveShortConnections,
|
|
175
|
+
connectionStartTime: this.connectionStartTime,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
startConnection(now: number): SSEState {
|
|
180
|
+
return new SSEState({
|
|
181
|
+
offset: this.offset,
|
|
182
|
+
cursor: this.cursor,
|
|
183
|
+
upToDate: this.upToDate,
|
|
184
|
+
streamClosed: this.streamClosed,
|
|
185
|
+
consecutiveShortConnections: this.consecutiveShortConnections,
|
|
186
|
+
connectionStartTime: now,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
handleConnectionEnd(
|
|
191
|
+
now: number,
|
|
192
|
+
wasAborted: boolean,
|
|
193
|
+
config: Required<SSEResilienceOptions>
|
|
194
|
+
): SSEConnectionEndResult {
|
|
195
|
+
if (this.connectionStartTime === undefined) {
|
|
196
|
+
return { action: `healthy`, state: this }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const duration = now - this.connectionStartTime
|
|
200
|
+
|
|
201
|
+
if (duration < config.minConnectionDuration && !wasAborted) {
|
|
202
|
+
// Connection was too short — likely proxy buffering or misconfiguration
|
|
203
|
+
const newCount = this.consecutiveShortConnections + 1
|
|
204
|
+
|
|
205
|
+
if (newCount >= config.maxShortConnections) {
|
|
206
|
+
// Threshold reached → permanent fallback to long-poll
|
|
207
|
+
return {
|
|
208
|
+
action: `fallback`,
|
|
209
|
+
state: new LongPollState({
|
|
210
|
+
offset: this.offset,
|
|
211
|
+
cursor: this.cursor,
|
|
212
|
+
upToDate: this.upToDate,
|
|
213
|
+
streamClosed: this.streamClosed,
|
|
214
|
+
}),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Reconnect with backoff
|
|
219
|
+
return {
|
|
220
|
+
action: `reconnect`,
|
|
221
|
+
state: new SSEState({
|
|
222
|
+
offset: this.offset,
|
|
223
|
+
cursor: this.cursor,
|
|
224
|
+
upToDate: this.upToDate,
|
|
225
|
+
streamClosed: this.streamClosed,
|
|
226
|
+
consecutiveShortConnections: newCount,
|
|
227
|
+
connectionStartTime: this.connectionStartTime,
|
|
228
|
+
}),
|
|
229
|
+
backoffAttempt: newCount,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (duration >= config.minConnectionDuration) {
|
|
234
|
+
// Healthy connection — reset counter
|
|
235
|
+
return {
|
|
236
|
+
action: `healthy`,
|
|
237
|
+
state: new SSEState({
|
|
238
|
+
offset: this.offset,
|
|
239
|
+
cursor: this.cursor,
|
|
240
|
+
upToDate: this.upToDate,
|
|
241
|
+
streamClosed: this.streamClosed,
|
|
242
|
+
consecutiveShortConnections: 0,
|
|
243
|
+
connectionStartTime: this.connectionStartTime,
|
|
244
|
+
}),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Aborted connection — don't change counter
|
|
249
|
+
return { action: `healthy`, state: this }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
pause(): PausedState {
|
|
253
|
+
return new PausedState(this)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Paused state wrapper. Delegates all sync field access to the inner state.
|
|
259
|
+
* resume() returns the wrapped state unchanged (identity preserved).
|
|
260
|
+
*/
|
|
261
|
+
export class PausedState extends StreamResponseState {
|
|
262
|
+
readonly #inner: LongPollState | SSEState
|
|
263
|
+
|
|
264
|
+
constructor(inner: LongPollState | SSEState) {
|
|
265
|
+
super()
|
|
266
|
+
this.#inner = inner
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
get offset(): Offset {
|
|
270
|
+
return this.#inner.offset
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
get cursor(): string | undefined {
|
|
274
|
+
return this.#inner.cursor
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get upToDate(): boolean {
|
|
278
|
+
return this.#inner.upToDate
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
get streamClosed(): boolean {
|
|
282
|
+
return this.#inner.streamClosed
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
shouldUseSse(): boolean {
|
|
286
|
+
return this.#inner.shouldUseSse()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
withResponseMetadata(update: ResponseMetadataUpdate): PausedState {
|
|
290
|
+
const newInner = this.#inner.withResponseMetadata(update)
|
|
291
|
+
return new PausedState(newInner)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
withSSEControl(event: SSEControlEvent): PausedState {
|
|
295
|
+
const newInner = this.#inner.withSSEControl(event)
|
|
296
|
+
return new PausedState(newInner)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
pause(): PausedState {
|
|
300
|
+
return this
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
resume(): { state: LongPollState | SSEState; justResumed: true } {
|
|
304
|
+
return { state: this.#inner, justResumed: true }
|
|
305
|
+
}
|
|
306
|
+
}
|