@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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: writing-data
|
|
3
|
+
description: >
|
|
4
|
+
Writing data to durable streams. DurableStream.create() with contentType,
|
|
5
|
+
DurableStream.append() for simple writes, IdempotentProducer for
|
|
6
|
+
high-throughput exactly-once delivery with autoClaim, fire-and-forget
|
|
7
|
+
append(), flush(), close(), StaleEpochError handling, JSON mode vs byte
|
|
8
|
+
stream mode, stream closure. Load when writing, producing, or appending
|
|
9
|
+
data to a durable stream.
|
|
10
|
+
type: core
|
|
11
|
+
library: durable-streams
|
|
12
|
+
library_version: "0.2.1"
|
|
13
|
+
requires:
|
|
14
|
+
- getting-started
|
|
15
|
+
sources:
|
|
16
|
+
- "durable-streams/durable-streams:packages/client/src/stream.ts"
|
|
17
|
+
- "durable-streams/durable-streams:packages/client/src/idempotent-producer.ts"
|
|
18
|
+
- "durable-streams/durable-streams:packages/client/src/types.ts"
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
This skill builds on durable-streams/getting-started. Read it first for setup and offset basics.
|
|
22
|
+
|
|
23
|
+
# Durable Streams — Writing Data
|
|
24
|
+
|
|
25
|
+
Two write APIs: `DurableStream.append()` for simple writes, `IdempotentProducer`
|
|
26
|
+
for sustained high-throughput writes with exactly-once delivery. Use the producer
|
|
27
|
+
for anything beyond a few one-off appends.
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import {
|
|
33
|
+
DurableStream,
|
|
34
|
+
IdempotentProducer,
|
|
35
|
+
StaleEpochError,
|
|
36
|
+
} from "@durable-streams/client"
|
|
37
|
+
|
|
38
|
+
// Create a JSON-mode stream
|
|
39
|
+
const handle = await DurableStream.create({
|
|
40
|
+
url: "https://your-server.com/v1/stream/my-stream",
|
|
41
|
+
contentType: "application/json",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Set up IdempotentProducer for reliable writes
|
|
45
|
+
const producer = new IdempotentProducer(handle, "my-service", {
|
|
46
|
+
autoClaim: true,
|
|
47
|
+
onError: (err) => {
|
|
48
|
+
if (err instanceof StaleEpochError) {
|
|
49
|
+
console.log("Another producer took over")
|
|
50
|
+
} else {
|
|
51
|
+
console.error("Write error:", err)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Fire-and-forget writes — automatically batched and deduplicated
|
|
57
|
+
for (const event of events) {
|
|
58
|
+
producer.append(JSON.stringify(event))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Ensure all pending writes are delivered
|
|
62
|
+
await producer.flush()
|
|
63
|
+
await producer.close()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Core Patterns
|
|
67
|
+
|
|
68
|
+
### Simple writes with append()
|
|
69
|
+
|
|
70
|
+
For writing a few items and waiting for completion, `append()` is straightforward:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { DurableStream } from "@durable-streams/client"
|
|
74
|
+
|
|
75
|
+
const handle = await DurableStream.create({
|
|
76
|
+
url: "https://your-server.com/v1/stream/events",
|
|
77
|
+
contentType: "application/json",
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Each append waits for server confirmation
|
|
81
|
+
await handle.append({ type: "order.created", orderId: "123" })
|
|
82
|
+
await handle.append({ type: "order.paid", orderId: "123" })
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### High-throughput writes with IdempotentProducer
|
|
86
|
+
|
|
87
|
+
For sustained writes, the producer batches, pipelines, and deduplicates automatically:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import { DurableStream, IdempotentProducer } from "@durable-streams/client"
|
|
91
|
+
|
|
92
|
+
const handle = await DurableStream.create({
|
|
93
|
+
url: "https://your-server.com/v1/stream/tokens",
|
|
94
|
+
contentType: "text/plain",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const producer = new IdempotentProducer(handle, "llm-worker", {
|
|
98
|
+
autoClaim: true,
|
|
99
|
+
lingerMs: 10, // Batch window (default: 5ms)
|
|
100
|
+
maxBatchBytes: 65536, // Max batch size (default: 1MB)
|
|
101
|
+
maxInFlight: 4, // Concurrent HTTP requests (default: 5)
|
|
102
|
+
onError: (err) => console.error(err),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
for await (const token of llm.stream(prompt)) {
|
|
106
|
+
producer.append(token) // Fire-and-forget — don't await
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await producer.flush() // Wait for all batches to land
|
|
110
|
+
await producer.close() // Clean up
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Closing a stream
|
|
114
|
+
|
|
115
|
+
Mark a stream as permanently closed (no more writes accepted):
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// Close with optional final message
|
|
119
|
+
await handle.close({ body: JSON.stringify({ type: "stream.complete" }) })
|
|
120
|
+
|
|
121
|
+
// Or close without a final message
|
|
122
|
+
await handle.close()
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Byte stream mode with custom framing
|
|
126
|
+
|
|
127
|
+
For non-JSON streams, use your own framing (e.g., newline-delimited JSON):
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
const handle = await DurableStream.create({
|
|
131
|
+
url: "https://your-server.com/v1/stream/logs",
|
|
132
|
+
contentType: "text/plain",
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const producer = new IdempotentProducer(handle, "logger", { autoClaim: true })
|
|
136
|
+
|
|
137
|
+
producer.append(JSON.stringify({ level: "info", msg: "started" }) + "\n")
|
|
138
|
+
producer.append(JSON.stringify({ level: "error", msg: "failed" }) + "\n")
|
|
139
|
+
|
|
140
|
+
await producer.flush()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Common Mistakes
|
|
144
|
+
|
|
145
|
+
### CRITICAL Using raw append() for sustained writes
|
|
146
|
+
|
|
147
|
+
Wrong:
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
const handle = await DurableStream.create({
|
|
151
|
+
url,
|
|
152
|
+
contentType: "application/json",
|
|
153
|
+
})
|
|
154
|
+
for (const event of events) {
|
|
155
|
+
await handle.append(JSON.stringify(event)) // No dedup, no batching, sequential
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Correct:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
const handle = await DurableStream.create({
|
|
163
|
+
url,
|
|
164
|
+
contentType: "application/json",
|
|
165
|
+
})
|
|
166
|
+
const producer = new IdempotentProducer(handle, "my-service", {
|
|
167
|
+
autoClaim: true,
|
|
168
|
+
onError: (err) => console.error(err),
|
|
169
|
+
})
|
|
170
|
+
for (const event of events) {
|
|
171
|
+
producer.append(JSON.stringify(event)) // Fire-and-forget, batched, deduplicated
|
|
172
|
+
}
|
|
173
|
+
await producer.flush()
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Raw `append()` has no deduplication — on retry after network error, data may be duplicated. `IdempotentProducer` handles batching, pipelining, and exactly-once delivery.
|
|
177
|
+
|
|
178
|
+
Source: packages/client/src/idempotent-producer.ts
|
|
179
|
+
|
|
180
|
+
### CRITICAL Not calling flush() before shutdown
|
|
181
|
+
|
|
182
|
+
Wrong:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
for (const event of events) {
|
|
186
|
+
producer.append(event)
|
|
187
|
+
}
|
|
188
|
+
// Process exits — pending batch lost!
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Correct:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
for (const event of events) {
|
|
195
|
+
producer.append(event)
|
|
196
|
+
}
|
|
197
|
+
await producer.flush()
|
|
198
|
+
await producer.close()
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`IdempotentProducer` batches writes. Without `flush()`, pending messages in the buffer are lost when the process exits.
|
|
202
|
+
|
|
203
|
+
Source: packages/client/src/idempotent-producer.ts
|
|
204
|
+
|
|
205
|
+
### HIGH Awaiting each producer.append() call
|
|
206
|
+
|
|
207
|
+
Wrong:
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
for (const event of events) {
|
|
211
|
+
await producer.append(JSON.stringify(event)) // Defeats pipelining!
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Correct:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
for (const event of events) {
|
|
219
|
+
producer.append(JSON.stringify(event)) // Fire-and-forget
|
|
220
|
+
}
|
|
221
|
+
await producer.flush() // Wait for all to complete
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`append()` is fire-and-forget by design. Awaiting it serializes every write and defeats batching and pipelining. Errors go to the `onError` callback.
|
|
225
|
+
|
|
226
|
+
Source: packages/client/src/idempotent-producer.ts
|
|
227
|
+
|
|
228
|
+
### HIGH Passing objects to append instead of strings
|
|
229
|
+
|
|
230
|
+
Wrong:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
producer.append({ event: "user.created" }) // throws!
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Correct:
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
producer.append(JSON.stringify({ event: "user.created" }))
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`IdempotentProducer.append()` accepts only `string` or `Uint8Array` — it does **not** auto-serialize objects, even for JSON-mode streams. Always call `JSON.stringify()` before appending.
|
|
243
|
+
|
|
244
|
+
Source: packages/client/src/idempotent-producer.ts
|
|
245
|
+
|
|
246
|
+
### HIGH Not handling StaleEpochError for multi-worker scenarios
|
|
247
|
+
|
|
248
|
+
Wrong:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
const producer = new IdempotentProducer(handle, "worker", {
|
|
252
|
+
onError: (err) => console.error(err), // Logs but doesn't stop
|
|
253
|
+
})
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Correct:
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
let fenced = false
|
|
260
|
+
const producer = new IdempotentProducer(handle, "worker", {
|
|
261
|
+
onError: (err) => {
|
|
262
|
+
if (err instanceof StaleEpochError) {
|
|
263
|
+
fenced = true // Stop producing
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
for await (const token of source) {
|
|
269
|
+
if (fenced) break
|
|
270
|
+
producer.append(token)
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
When another producer claims a higher epoch, the current producer gets fenced. Without checking, your write loop continues appending to a dead producer.
|
|
275
|
+
|
|
276
|
+
Source: packages/client/src/idempotent-producer.ts
|
|
277
|
+
|
|
278
|
+
### HIGH Manually managing epoch numbers
|
|
279
|
+
|
|
280
|
+
Wrong:
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
const producer = new IdempotentProducer(handle, "worker", { epoch: 2 })
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Correct:
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
const producer = new IdempotentProducer(handle, "worker", {
|
|
290
|
+
autoClaim: true, // Handles epoch transitions automatically
|
|
291
|
+
})
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Epoch and sequence management is an internal protocol detail. Use `autoClaim: true` and let the producer handle transitions automatically.
|
|
295
|
+
|
|
296
|
+
Source: maintainer guidance
|
|
297
|
+
|
|
298
|
+
### HIGH Tension: Simplicity vs. reliability in writes
|
|
299
|
+
|
|
300
|
+
This skill's patterns conflict with getting-started. Getting-started simplicity favors `append()` for quick demos, but production code should use `IdempotentProducer` for exactly-once guarantees. Agents optimizing for a quick setup may use `append()` and miss deduplication and batching.
|
|
301
|
+
|
|
302
|
+
See also: durable-streams/getting-started/SKILL.md § Common Mistakes
|
|
303
|
+
|
|
304
|
+
## See also
|
|
305
|
+
|
|
306
|
+
- [getting-started](../getting-started/SKILL.md) — Basic stream creation and reading
|
|
307
|
+
- [go-to-production](../go-to-production/SKILL.md) — TTL configuration and production concerns
|
|
308
|
+
|
|
309
|
+
## Version
|
|
310
|
+
|
|
311
|
+
Targets @durable-streams/client v0.2.1.
|
package/src/constants.ts
CHANGED
|
@@ -26,6 +26,12 @@ export const STREAM_CURSOR_HEADER = `Stream-Cursor`
|
|
|
26
26
|
*/
|
|
27
27
|
export const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Response/request header indicating stream is closed (EOF).
|
|
31
|
+
* When present with value "true", the stream is permanently closed.
|
|
32
|
+
*/
|
|
33
|
+
export const STREAM_CLOSED_HEADER = `Stream-Closed`
|
|
34
|
+
|
|
29
35
|
// ============================================================================
|
|
30
36
|
// Request Headers
|
|
31
37
|
// ============================================================================
|
|
@@ -97,6 +103,11 @@ export const LIVE_QUERY_PARAM = `live`
|
|
|
97
103
|
*/
|
|
98
104
|
export const CURSOR_QUERY_PARAM = `cursor`
|
|
99
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Response header indicating SSE data encoding (e.g., base64 for binary streams).
|
|
108
|
+
*/
|
|
109
|
+
export const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`
|
|
110
|
+
|
|
100
111
|
// ============================================================================
|
|
101
112
|
// SSE Control Event Fields (camelCase per PROTOCOL.md Section 5.7)
|
|
102
113
|
// ============================================================================
|
|
@@ -113,13 +124,19 @@ export const SSE_OFFSET_FIELD = `streamNextOffset`
|
|
|
113
124
|
*/
|
|
114
125
|
export const SSE_CURSOR_FIELD = `streamCursor`
|
|
115
126
|
|
|
127
|
+
/**
|
|
128
|
+
* SSE control event field for stream closed state.
|
|
129
|
+
* Note: Different from HTTP header name (camelCase vs Header-Case).
|
|
130
|
+
*/
|
|
131
|
+
export const SSE_CLOSED_FIELD = `streamClosed`
|
|
132
|
+
|
|
116
133
|
// ============================================================================
|
|
117
134
|
// Internal Constants
|
|
118
135
|
// ============================================================================
|
|
119
136
|
|
|
120
137
|
/**
|
|
121
|
-
* Content types that
|
|
122
|
-
*
|
|
138
|
+
* Content types that are natively compatible with SSE (UTF-8 text).
|
|
139
|
+
* Binary content types are also supported via automatic base64 encoding.
|
|
123
140
|
*/
|
|
124
141
|
export const SSE_COMPATIBLE_CONTENT_TYPES: ReadonlyArray<string> = [
|
|
125
142
|
`text/`,
|
package/src/error.ts
CHANGED
|
@@ -178,6 +178,26 @@ export class MissingStreamUrlError extends Error {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Error thrown when attempting to append to a closed stream.
|
|
183
|
+
*/
|
|
184
|
+
export class StreamClosedError extends DurableStreamError {
|
|
185
|
+
readonly code = `STREAM_CLOSED` as const
|
|
186
|
+
readonly status = 409
|
|
187
|
+
readonly streamClosed = true
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The final offset of the stream, if available from the response.
|
|
191
|
+
*/
|
|
192
|
+
readonly finalOffset?: string
|
|
193
|
+
|
|
194
|
+
constructor(url?: string, finalOffset?: string) {
|
|
195
|
+
super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url)
|
|
196
|
+
this.name = `StreamClosedError`
|
|
197
|
+
this.finalOffset = finalOffset
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
181
201
|
/**
|
|
182
202
|
* Error thrown when signal option is invalid.
|
|
183
203
|
*/
|
|
@@ -17,11 +17,12 @@ import {
|
|
|
17
17
|
PRODUCER_ID_HEADER,
|
|
18
18
|
PRODUCER_RECEIVED_SEQ_HEADER,
|
|
19
19
|
PRODUCER_SEQ_HEADER,
|
|
20
|
+
STREAM_CLOSED_HEADER,
|
|
20
21
|
STREAM_OFFSET_HEADER,
|
|
21
22
|
} from "./constants"
|
|
22
23
|
import type { queueAsPromised } from "fastq"
|
|
23
24
|
import type { DurableStream } from "./stream"
|
|
24
|
-
import type { IdempotentProducerOptions, Offset } from "./types"
|
|
25
|
+
import type { CloseResult, IdempotentProducerOptions, Offset } from "./types"
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Error thrown when a producer's epoch is stale (zombie fencing).
|
|
@@ -138,6 +139,8 @@ export class IdempotentProducer {
|
|
|
138
139
|
readonly #queue: queueAsPromised<BatchTask>
|
|
139
140
|
readonly #maxInFlight: number
|
|
140
141
|
#closed = false
|
|
142
|
+
#closeResult: CloseResult | null = null
|
|
143
|
+
#pendingFinalMessage?: Uint8Array | string
|
|
141
144
|
|
|
142
145
|
// When autoClaim is true, we must wait for the first batch to complete
|
|
143
146
|
// before allowing pipelining (to know what epoch was claimed)
|
|
@@ -318,11 +321,17 @@ export class IdempotentProducer {
|
|
|
318
321
|
}
|
|
319
322
|
|
|
320
323
|
/**
|
|
321
|
-
*
|
|
324
|
+
* Stop the producer without closing the underlying stream.
|
|
322
325
|
*
|
|
323
|
-
*
|
|
326
|
+
* Use this when you want to:
|
|
327
|
+
* - Hand off writing to another producer
|
|
328
|
+
* - Keep the stream open for future writes
|
|
329
|
+
* - Stop this producer but not signal EOF to readers
|
|
330
|
+
*
|
|
331
|
+
* Flushes any pending messages before detaching.
|
|
332
|
+
* After calling detach(), further append() calls will throw.
|
|
324
333
|
*/
|
|
325
|
-
async
|
|
334
|
+
async detach(): Promise<void> {
|
|
326
335
|
if (this.#closed) return
|
|
327
336
|
|
|
328
337
|
this.#closed = true
|
|
@@ -330,8 +339,138 @@ export class IdempotentProducer {
|
|
|
330
339
|
try {
|
|
331
340
|
await this.flush()
|
|
332
341
|
} catch {
|
|
333
|
-
// Ignore errors during
|
|
342
|
+
// Ignore errors during detach
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Flush pending messages and close the underlying stream (EOF).
|
|
348
|
+
*
|
|
349
|
+
* This is the typical way to end a producer session. It:
|
|
350
|
+
* 1. Flushes all pending messages
|
|
351
|
+
* 2. Optionally appends a final message
|
|
352
|
+
* 3. Closes the stream (no further appends permitted)
|
|
353
|
+
*
|
|
354
|
+
* **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
|
|
355
|
+
* idempotent even with a final message because it uses producer headers
|
|
356
|
+
* for deduplication. Safe to retry on network failures.
|
|
357
|
+
*
|
|
358
|
+
* @param finalMessage - Optional final message to append atomically with close
|
|
359
|
+
* @returns CloseResult with the final offset
|
|
360
|
+
*/
|
|
361
|
+
async close(finalMessage?: Uint8Array | string): Promise<CloseResult> {
|
|
362
|
+
if (this.#closed) {
|
|
363
|
+
// Already closed - return cached result for idempotency
|
|
364
|
+
if (this.#closeResult) {
|
|
365
|
+
return this.#closeResult
|
|
366
|
+
}
|
|
367
|
+
// Retry path: flush() threw on a previous attempt, so we need to re-run
|
|
368
|
+
// the entire close sequence with the stored finalMessage
|
|
369
|
+
await this.flush()
|
|
370
|
+
const result = await this.#doClose(this.#pendingFinalMessage)
|
|
371
|
+
this.#closeResult = result
|
|
372
|
+
return result
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.#closed = true
|
|
376
|
+
|
|
377
|
+
// Store finalMessage for retry safety (if flush() throws, we can retry)
|
|
378
|
+
this.#pendingFinalMessage = finalMessage
|
|
379
|
+
|
|
380
|
+
// Flush pending messages first
|
|
381
|
+
await this.flush()
|
|
382
|
+
|
|
383
|
+
// Close the stream with optional final message
|
|
384
|
+
const result = await this.#doClose(finalMessage)
|
|
385
|
+
this.#closeResult = result
|
|
386
|
+
return result
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Actually close the stream with optional final message.
|
|
391
|
+
* Uses producer headers for idempotency.
|
|
392
|
+
*/
|
|
393
|
+
async #doClose(finalMessage?: Uint8Array | string): Promise<CloseResult> {
|
|
394
|
+
const contentType = this.#stream.contentType ?? `application/octet-stream`
|
|
395
|
+
const isJson = normalizeContentType(contentType) === `application/json`
|
|
396
|
+
|
|
397
|
+
// Build body if final message is provided
|
|
398
|
+
let body: BodyInit | undefined
|
|
399
|
+
if (finalMessage !== undefined) {
|
|
400
|
+
const bodyBytes =
|
|
401
|
+
typeof finalMessage === `string`
|
|
402
|
+
? new TextEncoder().encode(finalMessage)
|
|
403
|
+
: finalMessage
|
|
404
|
+
|
|
405
|
+
if (isJson) {
|
|
406
|
+
// For JSON mode, wrap in array
|
|
407
|
+
const jsonStr = new TextDecoder().decode(bodyBytes)
|
|
408
|
+
body = `[${jsonStr}]`
|
|
409
|
+
} else {
|
|
410
|
+
body = bodyBytes as unknown as BodyInit
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Capture the sequence number for this request (for retry safety)
|
|
415
|
+
// We only increment #nextSeq after a successful response
|
|
416
|
+
const seqForThisRequest = this.#nextSeq
|
|
417
|
+
|
|
418
|
+
// Build headers with producer info and Stream-Closed
|
|
419
|
+
const headers: Record<string, string> = {
|
|
420
|
+
"content-type": contentType,
|
|
421
|
+
[PRODUCER_ID_HEADER]: this.#producerId,
|
|
422
|
+
[PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
|
|
423
|
+
[PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
|
|
424
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const response = await this.#fetchClient(this.#stream.url, {
|
|
428
|
+
method: `POST`,
|
|
429
|
+
headers,
|
|
430
|
+
body,
|
|
431
|
+
signal: this.#signal,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
// Handle 204 (duplicate close - idempotent success)
|
|
435
|
+
if (response.status === 204) {
|
|
436
|
+
// Only increment seq on success (retry-safe)
|
|
437
|
+
this.#nextSeq = seqForThisRequest + 1
|
|
438
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
|
|
439
|
+
return { finalOffset }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Handle success
|
|
443
|
+
if (response.status === 200) {
|
|
444
|
+
// Only increment seq on success (retry-safe)
|
|
445
|
+
this.#nextSeq = seqForThisRequest + 1
|
|
446
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``
|
|
447
|
+
return { finalOffset }
|
|
334
448
|
}
|
|
449
|
+
|
|
450
|
+
// Handle errors
|
|
451
|
+
if (response.status === 403) {
|
|
452
|
+
// Stale epoch
|
|
453
|
+
const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER)
|
|
454
|
+
const currentEpoch = currentEpochStr
|
|
455
|
+
? parseInt(currentEpochStr, 10)
|
|
456
|
+
: this.#epoch
|
|
457
|
+
|
|
458
|
+
if (this.#autoClaim) {
|
|
459
|
+
// Auto-claim: retry with epoch+1
|
|
460
|
+
const newEpoch = currentEpoch + 1
|
|
461
|
+
this.#epoch = newEpoch
|
|
462
|
+
// Reset sequence for new epoch - set to 0 so the recursive call uses seq 0
|
|
463
|
+
// (the first operation in a new epoch should be seq 0)
|
|
464
|
+
this.#nextSeq = 0
|
|
465
|
+
return this.#doClose(finalMessage)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
throw new StaleEpochError(currentEpoch)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Other errors
|
|
472
|
+
const error = await FetchError.fromResponse(response, this.#stream.url)
|
|
473
|
+
throw error
|
|
335
474
|
}
|
|
336
475
|
|
|
337
476
|
/**
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,10 @@ export type {
|
|
|
65
65
|
HeadResult,
|
|
66
66
|
LegacyLiveMode,
|
|
67
67
|
|
|
68
|
+
// Close types
|
|
69
|
+
CloseResult,
|
|
70
|
+
CloseOptions,
|
|
71
|
+
|
|
68
72
|
// Idempotent producer types
|
|
69
73
|
IdempotentProducerOptions,
|
|
70
74
|
IdempotentAppendResult,
|
|
@@ -89,6 +93,7 @@ export {
|
|
|
89
93
|
DurableStreamError,
|
|
90
94
|
MissingStreamUrlError,
|
|
91
95
|
InvalidSignalError,
|
|
96
|
+
StreamClosedError,
|
|
92
97
|
} from "./error"
|
|
93
98
|
|
|
94
99
|
// ============================================================================
|
|
@@ -110,6 +115,7 @@ export {
|
|
|
110
115
|
STREAM_OFFSET_HEADER,
|
|
111
116
|
STREAM_CURSOR_HEADER,
|
|
112
117
|
STREAM_UP_TO_DATE_HEADER,
|
|
118
|
+
STREAM_CLOSED_HEADER,
|
|
113
119
|
STREAM_SEQ_HEADER,
|
|
114
120
|
STREAM_TTL_HEADER,
|
|
115
121
|
STREAM_EXPIRES_AT_HEADER,
|
|
@@ -117,6 +123,7 @@ export {
|
|
|
117
123
|
LIVE_QUERY_PARAM,
|
|
118
124
|
CURSOR_QUERY_PARAM,
|
|
119
125
|
SSE_COMPATIBLE_CONTENT_TYPES,
|
|
126
|
+
SSE_CLOSED_FIELD,
|
|
120
127
|
DURABLE_STREAM_PROTOCOL_QUERY_PARAMS,
|
|
121
128
|
// Idempotent producer headers
|
|
122
129
|
PRODUCER_ID_HEADER,
|