@durable-streams/client 0.2.0 → 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/README.md +201 -8
- package/bin/intent.js +6 -0
- package/dist/index.cjs +624 -135
- package/dist/index.d.cts +139 -9
- package/dist/index.d.ts +139 -9
- package/dist/index.js +622 -136
- 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/constants.ts +19 -2
- package/src/error.ts +20 -0
- package/src/idempotent-producer.ts +144 -5
- package/src/index.ts +7 -0
- package/src/response.ts +376 -188
- package/src/sse.ts +10 -1
- package/src/stream-api.ts +13 -0
- package/src/stream-response-state.ts +306 -0
- package/src/stream.ts +147 -26
- package/src/types.ts +73 -0
- package/src/utils.ts +10 -1
package/src/sse.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface SSEControlEvent {
|
|
|
22
22
|
streamNextOffset: Offset
|
|
23
23
|
streamCursor?: string
|
|
24
24
|
upToDate?: boolean
|
|
25
|
+
streamClosed?: boolean
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export type SSEEvent = SSEDataEvent | SSEControlEvent
|
|
@@ -73,12 +74,14 @@ export async function* parseSSEStream(
|
|
|
73
74
|
streamNextOffset: Offset
|
|
74
75
|
streamCursor?: string
|
|
75
76
|
upToDate?: boolean
|
|
77
|
+
streamClosed?: boolean
|
|
76
78
|
}
|
|
77
79
|
yield {
|
|
78
80
|
type: `control`,
|
|
79
81
|
streamNextOffset: control.streamNextOffset,
|
|
80
82
|
streamCursor: control.streamCursor,
|
|
81
83
|
upToDate: control.upToDate,
|
|
84
|
+
streamClosed: control.streamClosed,
|
|
82
85
|
}
|
|
83
86
|
} catch (err) {
|
|
84
87
|
// Control events contain critical offset data - don't silently ignore
|
|
@@ -94,7 +97,11 @@ export async function* parseSSEStream(
|
|
|
94
97
|
}
|
|
95
98
|
currentEvent = { data: [] }
|
|
96
99
|
} else if (line.startsWith(`event:`)) {
|
|
97
|
-
|
|
100
|
+
// Per SSE spec, strip only one optional space after "event:"
|
|
101
|
+
const eventType = line.slice(6)
|
|
102
|
+
currentEvent.type = eventType.startsWith(` `)
|
|
103
|
+
? eventType.slice(1)
|
|
104
|
+
: eventType
|
|
98
105
|
} else if (line.startsWith(`data:`)) {
|
|
99
106
|
// Per SSE spec, strip the optional space after "data:"
|
|
100
107
|
const content = line.slice(5)
|
|
@@ -123,12 +130,14 @@ export async function* parseSSEStream(
|
|
|
123
130
|
streamNextOffset: Offset
|
|
124
131
|
streamCursor?: string
|
|
125
132
|
upToDate?: boolean
|
|
133
|
+
streamClosed?: boolean
|
|
126
134
|
}
|
|
127
135
|
yield {
|
|
128
136
|
type: `control`,
|
|
129
137
|
streamNextOffset: control.streamNextOffset,
|
|
130
138
|
streamCursor: control.streamCursor,
|
|
131
139
|
upToDate: control.upToDate,
|
|
140
|
+
streamClosed: control.streamClosed,
|
|
132
141
|
}
|
|
133
142
|
} catch (err) {
|
|
134
143
|
const preview =
|
package/src/stream-api.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
import {
|
|
8
8
|
LIVE_QUERY_PARAM,
|
|
9
9
|
OFFSET_QUERY_PARAM,
|
|
10
|
+
STREAM_CLOSED_HEADER,
|
|
10
11
|
STREAM_CURSOR_HEADER,
|
|
11
12
|
STREAM_OFFSET_HEADER,
|
|
13
|
+
STREAM_SSE_DATA_ENCODING_HEADER,
|
|
12
14
|
STREAM_UP_TO_DATE_HEADER,
|
|
13
15
|
} from "./constants"
|
|
14
16
|
import { DurableStreamError, FetchBackoffAbortError } from "./error"
|
|
@@ -190,12 +192,21 @@ async function streamInternal<TJson = unknown>(
|
|
|
190
192
|
const initialCursor =
|
|
191
193
|
firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? undefined
|
|
192
194
|
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER)
|
|
195
|
+
const initialStreamClosed =
|
|
196
|
+
firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
193
197
|
|
|
194
198
|
// Determine if JSON mode
|
|
195
199
|
const isJsonMode =
|
|
196
200
|
options.json === true ||
|
|
197
201
|
(contentType?.includes(`application/json`) ?? false)
|
|
198
202
|
|
|
203
|
+
// Detect SSE data encoding from response header (server auto-sets for binary streams)
|
|
204
|
+
const sseDataEncoding = firstResponse.headers.get(
|
|
205
|
+
STREAM_SSE_DATA_ENCODING_HEADER
|
|
206
|
+
)
|
|
207
|
+
const encoding =
|
|
208
|
+
sseDataEncoding === `base64` ? (`base64` as const) : undefined
|
|
209
|
+
|
|
199
210
|
// Create the fetch function for subsequent requests
|
|
200
211
|
const fetchNext = async (
|
|
201
212
|
offset: Offset,
|
|
@@ -288,10 +299,12 @@ async function streamInternal<TJson = unknown>(
|
|
|
288
299
|
initialOffset,
|
|
289
300
|
initialCursor,
|
|
290
301
|
initialUpToDate,
|
|
302
|
+
initialStreamClosed,
|
|
291
303
|
firstResponse,
|
|
292
304
|
abortController,
|
|
293
305
|
fetchNext,
|
|
294
306
|
startSSE,
|
|
295
307
|
sseResilience: options.sseResilience,
|
|
308
|
+
encoding,
|
|
296
309
|
})
|
|
297
310
|
}
|
|
@@ -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
|
+
}
|
package/src/stream.ts
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import fastq from "fastq"
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
DurableStreamError,
|
|
11
10
|
InvalidSignalError,
|
|
12
11
|
MissingStreamUrlError,
|
|
12
|
+
StreamClosedError,
|
|
13
13
|
} from "./error"
|
|
14
14
|
import { IdempotentProducer } from "./idempotent-producer"
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
STREAM_CLOSED_HEADER,
|
|
17
17
|
STREAM_EXPIRES_AT_HEADER,
|
|
18
18
|
STREAM_OFFSET_HEADER,
|
|
19
19
|
STREAM_SEQ_HEADER,
|
|
@@ -35,6 +35,8 @@ import type { BackoffOptions } from "./fetch"
|
|
|
35
35
|
import type { queueAsPromised } from "fastq"
|
|
36
36
|
import type {
|
|
37
37
|
AppendOptions,
|
|
38
|
+
CloseOptions,
|
|
39
|
+
CloseResult,
|
|
38
40
|
CreateOptions,
|
|
39
41
|
HeadResult,
|
|
40
42
|
HeadersRecord,
|
|
@@ -204,6 +206,7 @@ export class DurableStream {
|
|
|
204
206
|
ttlSeconds: opts.ttlSeconds,
|
|
205
207
|
expiresAt: opts.expiresAt,
|
|
206
208
|
body: opts.body,
|
|
209
|
+
closed: opts.closed,
|
|
207
210
|
})
|
|
208
211
|
return stream
|
|
209
212
|
}
|
|
@@ -269,6 +272,8 @@ export class DurableStream {
|
|
|
269
272
|
const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? undefined
|
|
270
273
|
const etag = response.headers.get(`etag`) ?? undefined
|
|
271
274
|
const cacheControl = response.headers.get(`cache-control`) ?? undefined
|
|
275
|
+
const streamClosed =
|
|
276
|
+
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
272
277
|
|
|
273
278
|
// Update instance contentType
|
|
274
279
|
if (contentType) {
|
|
@@ -281,6 +286,7 @@ export class DurableStream {
|
|
|
281
286
|
offset,
|
|
282
287
|
etag,
|
|
283
288
|
cacheControl,
|
|
289
|
+
streamClosed,
|
|
284
290
|
}
|
|
285
291
|
}
|
|
286
292
|
|
|
@@ -300,6 +306,9 @@ export class DurableStream {
|
|
|
300
306
|
if (opts?.expiresAt) {
|
|
301
307
|
requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt
|
|
302
308
|
}
|
|
309
|
+
if (opts?.closed) {
|
|
310
|
+
requestHeaders[STREAM_CLOSED_HEADER] = `true`
|
|
311
|
+
}
|
|
303
312
|
|
|
304
313
|
const body = encodeBody(opts?.body)
|
|
305
314
|
|
|
@@ -342,6 +351,84 @@ export class DurableStream {
|
|
|
342
351
|
}
|
|
343
352
|
}
|
|
344
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Close the stream, optionally with a final message.
|
|
356
|
+
*
|
|
357
|
+
* After closing:
|
|
358
|
+
* - No further appends are permitted (server returns 409)
|
|
359
|
+
* - Readers can observe the closed state and treat it as EOF
|
|
360
|
+
* - The stream's data remains fully readable
|
|
361
|
+
*
|
|
362
|
+
* Closing is:
|
|
363
|
+
* - **Durable**: The closed state is persisted
|
|
364
|
+
* - **Monotonic**: Once closed, a stream cannot be reopened
|
|
365
|
+
*
|
|
366
|
+
* **Idempotency:**
|
|
367
|
+
* - `close()` without body: Idempotent — safe to call multiple times
|
|
368
|
+
* - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
|
|
369
|
+
* if stream is already closed (use `IdempotentProducer.close()` for
|
|
370
|
+
* idempotent close-with-body semantics)
|
|
371
|
+
*
|
|
372
|
+
* @returns CloseResult with the final offset
|
|
373
|
+
* @throws StreamClosedError if called with body on an already-closed stream
|
|
374
|
+
*/
|
|
375
|
+
async close(opts?: CloseOptions): Promise<CloseResult> {
|
|
376
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest()
|
|
377
|
+
|
|
378
|
+
const contentType =
|
|
379
|
+
opts?.contentType ?? this.#options.contentType ?? this.contentType
|
|
380
|
+
if (contentType) {
|
|
381
|
+
requestHeaders[`content-type`] = contentType
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Always send Stream-Closed: true header for close operation
|
|
385
|
+
requestHeaders[STREAM_CLOSED_HEADER] = `true`
|
|
386
|
+
|
|
387
|
+
// For JSON mode with body, wrap in array
|
|
388
|
+
let body: BodyInit | undefined
|
|
389
|
+
if (opts?.body !== undefined) {
|
|
390
|
+
const isJson = normalizeContentType(contentType) === `application/json`
|
|
391
|
+
if (isJson) {
|
|
392
|
+
const bodyStr =
|
|
393
|
+
typeof opts.body === `string`
|
|
394
|
+
? opts.body
|
|
395
|
+
: new TextDecoder().decode(opts.body)
|
|
396
|
+
body = `[${bodyStr}]`
|
|
397
|
+
} else {
|
|
398
|
+
body =
|
|
399
|
+
typeof opts.body === `string`
|
|
400
|
+
? opts.body
|
|
401
|
+
: (opts.body as unknown as BodyInit)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
406
|
+
method: `POST`,
|
|
407
|
+
headers: requestHeaders,
|
|
408
|
+
body,
|
|
409
|
+
signal: opts?.signal ?? this.#options.signal,
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// Check for 409 Conflict with Stream-Closed header
|
|
413
|
+
if (response.status === 409) {
|
|
414
|
+
const isClosed =
|
|
415
|
+
response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
416
|
+
if (isClosed) {
|
|
417
|
+
const finalOffset =
|
|
418
|
+
response.headers.get(STREAM_OFFSET_HEADER) ?? undefined
|
|
419
|
+
throw new StreamClosedError(this.url, finalOffset)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
await handleErrorResponse(response, this.url)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
|
|
428
|
+
|
|
429
|
+
return { finalOffset }
|
|
430
|
+
}
|
|
431
|
+
|
|
345
432
|
/**
|
|
346
433
|
* Append a single payload to the stream.
|
|
347
434
|
*
|
|
@@ -403,9 +490,24 @@ export class DurableStream {
|
|
|
403
490
|
// For JSON mode, wrap body in array to match protocol (server flattens one level)
|
|
404
491
|
// Input is pre-serialized JSON string
|
|
405
492
|
const isJson = normalizeContentType(contentType) === `application/json`
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
493
|
+
let encodedBody: BodyInit
|
|
494
|
+
if (isJson) {
|
|
495
|
+
// JSON mode: decode as UTF-8 string and wrap in array
|
|
496
|
+
const bodyStr =
|
|
497
|
+
typeof body === `string` ? body : new TextDecoder().decode(body)
|
|
498
|
+
encodedBody = `[${bodyStr}]`
|
|
499
|
+
} else {
|
|
500
|
+
// Binary mode: preserve raw bytes
|
|
501
|
+
// Use ArrayBuffer for cross-platform BodyInit compatibility
|
|
502
|
+
if (typeof body === `string`) {
|
|
503
|
+
encodedBody = body
|
|
504
|
+
} else {
|
|
505
|
+
encodedBody = body.buffer.slice(
|
|
506
|
+
body.byteOffset,
|
|
507
|
+
body.byteOffset + body.byteLength
|
|
508
|
+
) as ArrayBuffer
|
|
509
|
+
}
|
|
510
|
+
}
|
|
409
511
|
|
|
410
512
|
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
411
513
|
method: `POST`,
|
|
@@ -522,11 +624,43 @@ export class DurableStream {
|
|
|
522
624
|
)
|
|
523
625
|
batchedBody = `[${jsonStrings.join(`,`)}]`
|
|
524
626
|
} else {
|
|
525
|
-
// For byte mode:
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
627
|
+
// For byte mode: preserve original data types
|
|
628
|
+
// - Strings are concatenated as strings (for text/* content types)
|
|
629
|
+
// - Uint8Arrays are concatenated as binary (for application/octet-stream)
|
|
630
|
+
// - Mixed types: convert all to binary to avoid data corruption
|
|
631
|
+
const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array)
|
|
632
|
+
const hasString = batch.some((m) => typeof m.data === `string`)
|
|
633
|
+
|
|
634
|
+
if (hasUint8Array && !hasString) {
|
|
635
|
+
// All binary: concatenate Uint8Arrays
|
|
636
|
+
const chunks = batch.map((m) => m.data as Uint8Array)
|
|
637
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
|
|
638
|
+
const combined = new Uint8Array(totalLength)
|
|
639
|
+
let offset = 0
|
|
640
|
+
for (const chunk of chunks) {
|
|
641
|
+
combined.set(chunk, offset)
|
|
642
|
+
offset += chunk.length
|
|
643
|
+
}
|
|
644
|
+
batchedBody = combined
|
|
645
|
+
} else if (hasString && !hasUint8Array) {
|
|
646
|
+
// All strings: concatenate as string
|
|
647
|
+
batchedBody = batch.map((m) => m.data as string).join(``)
|
|
648
|
+
} else {
|
|
649
|
+
// Mixed types: convert strings to binary and concatenate
|
|
650
|
+
// This preserves binary data integrity
|
|
651
|
+
const encoder = new TextEncoder()
|
|
652
|
+
const chunks = batch.map((m) =>
|
|
653
|
+
typeof m.data === `string` ? encoder.encode(m.data) : m.data
|
|
654
|
+
)
|
|
655
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
|
|
656
|
+
const combined = new Uint8Array(totalLength)
|
|
657
|
+
let offset = 0
|
|
658
|
+
for (const chunk of chunks) {
|
|
659
|
+
combined.set(chunk, offset)
|
|
660
|
+
offset += chunk.length
|
|
661
|
+
}
|
|
662
|
+
batchedBody = combined
|
|
663
|
+
}
|
|
530
664
|
}
|
|
531
665
|
|
|
532
666
|
// Combine signals: stream-level signal + any per-message signals
|
|
@@ -682,12 +816,13 @@ export class DurableStream {
|
|
|
682
816
|
producer.append(chunk)
|
|
683
817
|
},
|
|
684
818
|
async close() {
|
|
685
|
-
|
|
819
|
+
// close() flushes pending and closes the stream (EOF)
|
|
686
820
|
await producer.close()
|
|
687
821
|
if (writeError) throw writeError // Causes pipeTo() to reject
|
|
688
822
|
},
|
|
689
823
|
abort(_reason) {
|
|
690
|
-
|
|
824
|
+
// detach() stops the producer without closing the stream
|
|
825
|
+
producer.detach().catch((err) => {
|
|
691
826
|
opts?.onError?.(err) // Report instead of swallowing
|
|
692
827
|
})
|
|
693
828
|
},
|
|
@@ -736,20 +871,6 @@ export class DurableStream {
|
|
|
736
871
|
async stream<TJson = unknown>(
|
|
737
872
|
options?: Omit<StreamOptions, `url`>
|
|
738
873
|
): Promise<StreamResponse<TJson>> {
|
|
739
|
-
// Check SSE compatibility if SSE mode is requested
|
|
740
|
-
if (options?.live === `sse` && this.contentType) {
|
|
741
|
-
const isSSECompatible = SSE_COMPATIBLE_CONTENT_TYPES.some((prefix) =>
|
|
742
|
-
this.contentType!.startsWith(prefix)
|
|
743
|
-
)
|
|
744
|
-
if (!isSSECompatible) {
|
|
745
|
-
throw new DurableStreamError(
|
|
746
|
-
`SSE is not supported for content-type: ${this.contentType}`,
|
|
747
|
-
`SSE_NOT_SUPPORTED`,
|
|
748
|
-
400
|
|
749
|
-
)
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
874
|
// Merge handle-level and call-specific headers
|
|
754
875
|
const mergedHeaders: HeadersRecord = {
|
|
755
876
|
...this.#options.headers,
|