@durable-streams/client 0.2.0 → 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/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
- currentEvent.type = line.slice(6).trim()
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
  }
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
- SSE_COMPATIBLE_CONTENT_TYPES,
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
- const bodyStr =
407
- typeof body === `string` ? body : new TextDecoder().decode(body)
408
- const encodedBody: BodyInit = isJson ? `[${bodyStr}]` : bodyStr
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: concatenate all chunks as a string
526
- const strings = batch.map((m) =>
527
- typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data)
528
- )
529
- batchedBody = strings.join(``)
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
- await producer.flush()
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
- producer.close().catch((err) => {
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,
package/src/types.ts CHANGED
@@ -228,6 +228,12 @@ export interface JsonBatchMeta {
228
228
  * Last Stream-Cursor / streamCursor, if present.
229
229
  */
230
230
  cursor?: string
231
+
232
+ /**
233
+ * Whether the stream is closed and this batch contains the final data.
234
+ * When true, no more data will ever be appended to the stream.
235
+ */
236
+ streamClosed: boolean
231
237
  }
232
238
 
233
239
  /**
@@ -357,6 +363,18 @@ export interface CreateOptions extends StreamHandleOptions {
357
363
  * @default true
358
364
  */
359
365
  batching?: boolean
366
+
367
+ /**
368
+ * If true, create the stream in the closed state.
369
+ * Any body provided becomes the complete and final content.
370
+ *
371
+ * Useful for:
372
+ * - Cached responses
373
+ * - Placeholder errors
374
+ * - Pre-computed results
375
+ * - Single-message streams that are immediately complete
376
+ */
377
+ closed?: boolean
360
378
  }
361
379
 
362
380
  /**
@@ -403,6 +421,41 @@ export interface AppendOptions {
403
421
  producerSeq?: number
404
422
  }
405
423
 
424
+ /**
425
+ * Result of a close operation.
426
+ */
427
+ export interface CloseResult {
428
+ /**
429
+ * The final offset of the stream.
430
+ * This is the offset after the last byte (including any final message).
431
+ * Returned via the `Stream-Next-Offset` header.
432
+ */
433
+ finalOffset: Offset
434
+ }
435
+
436
+ /**
437
+ * Options for closing a stream.
438
+ */
439
+ export interface CloseOptions {
440
+ /**
441
+ * Optional final message to append atomically with close.
442
+ * For JSON streams, pass a pre-serialized JSON string.
443
+ * Strings are UTF-8 encoded.
444
+ */
445
+ body?: Uint8Array | string
446
+
447
+ /**
448
+ * Content type for the final message.
449
+ * Defaults to the stream's content type. Must match if provided.
450
+ */
451
+ contentType?: string
452
+
453
+ /**
454
+ * AbortSignal for this operation.
455
+ */
456
+ signal?: AbortSignal
457
+ }
458
+
406
459
  /**
407
460
  * Legacy live mode type (internal use only).
408
461
  * @internal
@@ -470,6 +523,12 @@ export interface HeadResult {
470
523
  * Cache-Control header value.
471
524
  */
472
525
  cacheControl?: string
526
+
527
+ /**
528
+ * Whether the stream is closed.
529
+ * When true, no further appends are permitted.
530
+ */
531
+ streamClosed: boolean
473
532
  }
474
533
 
475
534
  /**
@@ -519,6 +578,7 @@ export type DurableStreamErrorCode =
519
578
  | `ALREADY_CONSUMED`
520
579
  | `ALREADY_CLOSED`
521
580
  | `PARSE_ERROR`
581
+ | `STREAM_CLOSED`
522
582
  | `UNKNOWN`
523
583
 
524
584
  /**
@@ -676,6 +736,19 @@ export interface StreamResponse<TJson = unknown> {
676
736
  */
677
737
  readonly upToDate: boolean
678
738
 
739
+ /**
740
+ * Whether the stream is closed (EOF).
741
+ *
742
+ * When true, no more data will ever be appended to the stream.
743
+ * This is updated after each chunk is delivered to the consumer.
744
+ *
745
+ * In live mode, when streamClosed becomes true:
746
+ * - Long-poll requests return immediately (no waiting)
747
+ * - SSE connections are closed by the server
748
+ * - Clients stop reconnecting automatically
749
+ */
750
+ readonly streamClosed: boolean
751
+
679
752
  // =================================
680
753
  // 1) Accumulating helpers (Promise)
681
754
  // =================================
package/src/utils.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Shared utility functions for the Durable Streams client.
3
3
  */
4
4
 
5
- import { DurableStreamError } from "./error"
5
+ import { STREAM_CLOSED_HEADER, STREAM_OFFSET_HEADER } from "./constants"
6
+ import { DurableStreamError, StreamClosedError } from "./error"
6
7
  import type { HeadersRecord, MaybePromise } from "./types"
7
8
 
8
9
  /**
@@ -45,6 +46,14 @@ export async function handleErrorResponse(
45
46
  }
46
47
 
47
48
  if (status === 409) {
49
+ // Check if this is a stream closed error
50
+ const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER)
51
+ if (streamClosedHeader?.toLowerCase() === `true`) {
52
+ const finalOffset =
53
+ response.headers.get(STREAM_OFFSET_HEADER) ?? undefined
54
+ throw new StreamClosedError(url, finalOffset)
55
+ }
56
+
48
57
  // Context-specific 409 messages
49
58
  const message =
50
59
  context?.operation === `create`