@durable-streams/client 0.1.5 → 0.2.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@durable-streams/client",
3
3
  "description": "TypeScript client for the Durable Streams protocol",
4
- "version": "0.1.5",
4
+ "version": "0.2.1",
5
5
  "author": "Durable Stream contributors",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -47,7 +47,7 @@
47
47
  "devDependencies": {
48
48
  "fast-check": "^4.4.0",
49
49
  "tsdown": "^0.9.0",
50
- "@durable-streams/server": "0.1.6"
50
+ "@durable-streams/server": "0.2.1"
51
51
  },
52
52
  "engines": {
53
53
  "node": ">=18.0.0"
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 support SSE mode.
122
- * SSE is only valid for text/* or application/json streams.
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).
@@ -76,12 +77,9 @@ function normalizeContentType(contentType: string | undefined): string {
76
77
 
77
78
  /**
78
79
  * Internal type for pending batch entries.
79
- * Stores original data for proper JSON batching.
80
80
  */
81
81
  interface PendingEntry {
82
- /** Original data - parsed for JSON mode batching */
83
- data: unknown
84
- /** Encoded bytes for byte-stream mode */
82
+ /** Encoded bytes */
85
83
  body: Uint8Array
86
84
  }
87
85
 
@@ -141,6 +139,8 @@ export class IdempotentProducer {
141
139
  readonly #queue: queueAsPromised<BatchTask>
142
140
  readonly #maxInFlight: number
143
141
  #closed = false
142
+ #closeResult: CloseResult | null = null
143
+ #pendingFinalMessage?: Uint8Array | string
144
144
 
145
145
  // When autoClaim is true, we must wait for the first batch to complete
146
146
  // before allowing pipelining (to know what epoch was claimed)
@@ -173,18 +173,37 @@ export class IdempotentProducer {
173
173
  producerId: string,
174
174
  opts?: IdempotentProducerOptions
175
175
  ) {
176
+ // Validate inputs
177
+ const epoch = opts?.epoch ?? 0
178
+ const maxBatchBytes = opts?.maxBatchBytes ?? 1024 * 1024 // 1MB
179
+ const maxInFlight = opts?.maxInFlight ?? 5
180
+ const lingerMs = opts?.lingerMs ?? 5
181
+
182
+ if (epoch < 0) {
183
+ throw new Error(`epoch must be >= 0`)
184
+ }
185
+ if (maxBatchBytes <= 0) {
186
+ throw new Error(`maxBatchBytes must be > 0`)
187
+ }
188
+ if (maxInFlight <= 0) {
189
+ throw new Error(`maxInFlight must be > 0`)
190
+ }
191
+ if (lingerMs < 0) {
192
+ throw new Error(`lingerMs must be >= 0`)
193
+ }
194
+
176
195
  this.#stream = stream
177
196
  this.#producerId = producerId
178
- this.#epoch = opts?.epoch ?? 0
197
+ this.#epoch = epoch
179
198
  this.#autoClaim = opts?.autoClaim ?? false
180
- this.#maxBatchBytes = opts?.maxBatchBytes ?? 1024 * 1024 // 1MB
181
- this.#lingerMs = opts?.lingerMs ?? 5
199
+ this.#maxBatchBytes = maxBatchBytes
200
+ this.#lingerMs = lingerMs
182
201
  this.#signal = opts?.signal
183
202
  this.#onError = opts?.onError
184
203
  this.#fetchClient =
185
204
  opts?.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
186
205
 
187
- this.#maxInFlight = opts?.maxInFlight ?? 5
206
+ this.#maxInFlight = maxInFlight
188
207
 
189
208
  // When autoClaim is true, epoch is not yet known until first batch completes
190
209
  // We block pipelining until then to avoid racing with the claim
@@ -224,12 +243,22 @@ export class IdempotentProducer {
224
243
  * Errors are reported via onError callback if configured. Use flush() to
225
244
  * wait for all pending messages to be sent.
226
245
  *
227
- * For JSON streams, pass native objects (which will be serialized internally).
246
+ * For JSON streams, pass pre-serialized JSON strings.
228
247
  * For byte streams, pass string or Uint8Array.
229
248
  *
230
- * @param body - Data to append (object for JSON streams, string or Uint8Array for byte streams)
249
+ * @param body - Data to append (string or Uint8Array)
250
+ *
251
+ * @example
252
+ * ```typescript
253
+ * // JSON stream
254
+ * producer.append(JSON.stringify({ message: "hello" }));
255
+ *
256
+ * // Byte stream
257
+ * producer.append("raw text data");
258
+ * producer.append(new Uint8Array([1, 2, 3]));
259
+ * ```
231
260
  */
232
- append(body: Uint8Array | string | unknown): void {
261
+ append(body: Uint8Array | string): void {
233
262
  if (this.#closed) {
234
263
  throw new DurableStreamError(
235
264
  `Producer is closed`,
@@ -239,35 +268,21 @@ export class IdempotentProducer {
239
268
  )
240
269
  }
241
270
 
242
- const isJson =
243
- normalizeContentType(this.#stream.contentType) === `application/json`
244
-
245
271
  let bytes: Uint8Array
246
- let data: unknown
247
-
248
- if (isJson) {
249
- // For JSON streams: accept native objects, serialize internally
250
- const json = JSON.stringify(body)
251
- bytes = new TextEncoder().encode(json)
252
- data = body
272
+ if (typeof body === `string`) {
273
+ bytes = new TextEncoder().encode(body)
274
+ } else if (body instanceof Uint8Array) {
275
+ bytes = body
253
276
  } else {
254
- // For byte streams, require string or Uint8Array
255
- if (typeof body === `string`) {
256
- bytes = new TextEncoder().encode(body)
257
- } else if (body instanceof Uint8Array) {
258
- bytes = body
259
- } else {
260
- throw new DurableStreamError(
261
- `Non-JSON streams require string or Uint8Array`,
262
- `BAD_REQUEST`,
263
- 400,
264
- undefined
265
- )
266
- }
267
- data = bytes
277
+ throw new DurableStreamError(
278
+ `append() requires string or Uint8Array. For objects, use JSON.stringify().`,
279
+ `BAD_REQUEST`,
280
+ 400,
281
+ undefined
282
+ )
268
283
  }
269
284
 
270
- this.#pendingBatch.push({ data, body: bytes })
285
+ this.#pendingBatch.push({ body: bytes })
271
286
  this.#batchBytes += bytes.length
272
287
 
273
288
  // Check if batch should be sent immediately
@@ -306,11 +321,17 @@ export class IdempotentProducer {
306
321
  }
307
322
 
308
323
  /**
309
- * Flush pending messages and close the producer.
324
+ * Stop the producer without closing the underlying stream.
310
325
  *
311
- * After calling close(), further append() calls will throw.
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.
312
333
  */
313
- async close(): Promise<void> {
334
+ async detach(): Promise<void> {
314
335
  if (this.#closed) return
315
336
 
316
337
  this.#closed = true
@@ -318,10 +339,140 @@ export class IdempotentProducer {
318
339
  try {
319
340
  await this.flush()
320
341
  } catch {
321
- // Ignore errors during close
342
+ // Ignore errors during detach
322
343
  }
323
344
  }
324
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 }
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
474
+ }
475
+
325
476
  /**
326
477
  * Increment epoch and reset sequence.
327
478
  *
@@ -523,8 +674,9 @@ export class IdempotentProducer {
523
674
  // For JSON mode: always send as array (server flattens one level)
524
675
  // Single append: [value] → server stores value
525
676
  // Multiple appends: [val1, val2] → server stores val1, val2
526
- const values = batch.map((e) => e.data)
527
- batchedBody = JSON.stringify(values)
677
+ // Input is pre-serialized JSON strings, join them into an array
678
+ const jsonStrings = batch.map((e) => new TextDecoder().decode(e.body))
679
+ batchedBody = `[${jsonStrings.join(`,`)}]`
528
680
  } else {
529
681
  // For byte mode: concatenate all chunks
530
682
  const totalSize = batch.reduce((sum, e) => sum + e.body.length, 0)
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,