@durable-streams/client 0.1.0

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/types.ts ADDED
@@ -0,0 +1,737 @@
1
+ /**
2
+ * Durable Streams TypeScript Client Types
3
+ *
4
+ * Following the Electric Durable Stream Protocol specification.
5
+ */
6
+
7
+ import type { ReadableStreamAsyncIterable } from "./asyncIterableReadableStream"
8
+ import type { BackoffOptions } from "./fetch"
9
+
10
+ /**
11
+ * Offset string - opaque to the client.
12
+ * Format: "<read-seq>_<byte-offset>"
13
+ *
14
+ * **Special value**: `-1` means "start of stream" - use this to read from the beginning.
15
+ *
16
+ * Always use the returned `offset` field from reads/follows as the next `offset` you pass in.
17
+ */
18
+ export type Offset = string
19
+
20
+ /**
21
+ * Type for values that can be provided immediately or resolved asynchronously.
22
+ */
23
+ export type MaybePromise<T> = T | Promise<T>
24
+
25
+ /**
26
+ * Headers record where values can be static strings or async functions.
27
+ * Following the @electric-sql/client pattern for dynamic headers.
28
+ *
29
+ * **Important**: Functions are called **for each request**, not once per session.
30
+ * In live mode with long-polling, the same function may be called many times
31
+ * to fetch fresh values (e.g., refreshed auth tokens) for each poll.
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * headers: {
36
+ * Authorization: `Bearer ${token}`, // Static - same for all requests
37
+ * 'X-Tenant-Id': () => getCurrentTenant(), // Called per-request
38
+ * 'X-Auth': async () => await refreshToken() // Called per-request (can refresh)
39
+ * }
40
+ * ```
41
+ */
42
+ export type HeadersRecord = {
43
+ [key: string]: string | (() => MaybePromise<string>)
44
+ }
45
+
46
+ /**
47
+ * Params record where values can be static or async functions.
48
+ * Following the @electric-sql/client pattern for dynamic params.
49
+ *
50
+ * **Important**: Functions are called **for each request**, not once per session.
51
+ * In live mode, the same function may be called multiple times to fetch
52
+ * fresh parameter values for each poll.
53
+ */
54
+ export type ParamsRecord = {
55
+ [key: string]: string | (() => MaybePromise<string>) | undefined
56
+ }
57
+
58
+ // ============================================================================
59
+ // Live Mode Types
60
+ // ============================================================================
61
+
62
+ /**
63
+ * Live mode for reading from a stream.
64
+ * - false: Catch-up only, stop at first `upToDate`
65
+ * - "auto": Behavior driven by consumption method (default)
66
+ * - "long-poll": Explicit long-poll mode for live updates
67
+ * - "sse": Explicit server-sent events for live updates
68
+ */
69
+ export type LiveMode = false | `auto` | `long-poll` | `sse`
70
+
71
+ // ============================================================================
72
+ // Stream Options (Read API)
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Options for the stream() function (read-only API).
77
+ */
78
+ export interface StreamOptions {
79
+ /**
80
+ * The full URL to the durable stream.
81
+ * E.g., "https://streams.example.com/my-account/chat/room-1"
82
+ */
83
+ url: string | URL
84
+
85
+ /**
86
+ * HTTP headers to include in requests.
87
+ * Values can be strings or functions (sync or async) that return strings.
88
+ *
89
+ * **Important**: Functions are evaluated **per-request** (not per-session).
90
+ * In live mode, functions are called for each poll, allowing fresh values
91
+ * like refreshed auth tokens.
92
+ *
93
+ * @example
94
+ * ```typescript
95
+ * headers: {
96
+ * Authorization: `Bearer ${token}`, // Static
97
+ * 'X-Tenant-Id': () => getCurrentTenant(), // Evaluated per-request
98
+ * 'X-Auth': async () => await refreshToken() // Evaluated per-request
99
+ * }
100
+ * ```
101
+ */
102
+ headers?: HeadersRecord
103
+
104
+ /**
105
+ * Query parameters to include in requests.
106
+ * Values can be strings or functions (sync or async) that return strings.
107
+ *
108
+ * **Important**: Functions are evaluated **per-request** (not per-session).
109
+ */
110
+ params?: ParamsRecord
111
+
112
+ /**
113
+ * AbortSignal for cancellation.
114
+ */
115
+ signal?: AbortSignal
116
+
117
+ /**
118
+ * Custom fetch implementation (for auth layers, proxies, etc.).
119
+ * Defaults to globalThis.fetch.
120
+ */
121
+ fetch?: typeof globalThis.fetch
122
+
123
+ /**
124
+ * Backoff options for retry behavior.
125
+ * Defaults to exponential backoff with jitter.
126
+ */
127
+ backoffOptions?: BackoffOptions
128
+
129
+ /**
130
+ * Starting offset (query param ?offset=...).
131
+ * If omitted, defaults to "-1" (start of stream).
132
+ * You can also explicitly pass "-1" to read from the beginning.
133
+ */
134
+ offset?: Offset
135
+
136
+ /**
137
+ * Live mode behavior:
138
+ * - false: Catch-up only, stop at first `upToDate`
139
+ * - "auto" (default): Behavior driven by consumption method
140
+ * - "long-poll": Explicit long-poll mode for live updates
141
+ * - "sse": Explicit server-sent events for live updates
142
+ */
143
+ live?: LiveMode
144
+
145
+ /**
146
+ * Hint: treat content as JSON even if Content-Type doesn't say so.
147
+ */
148
+ json?: boolean
149
+
150
+ /**
151
+ * Error handler for recoverable errors (following Electric client pattern).
152
+ */
153
+ onError?: StreamErrorHandler
154
+
155
+ /**
156
+ * SSE resilience options.
157
+ * When SSE connections fail repeatedly, the client can automatically
158
+ * fall back to long-polling mode.
159
+ */
160
+ sseResilience?: SSEResilienceOptions
161
+ }
162
+
163
+ /**
164
+ * Options for SSE connection resilience.
165
+ */
166
+ export interface SSEResilienceOptions {
167
+ /**
168
+ * Minimum expected SSE connection duration in milliseconds.
169
+ * Connections shorter than this are considered "short" and may indicate
170
+ * proxy buffering or server misconfiguration.
171
+ * @default 1000
172
+ */
173
+ minConnectionDuration?: number
174
+
175
+ /**
176
+ * Maximum number of consecutive short connections before falling back to long-poll.
177
+ * @default 3
178
+ */
179
+ maxShortConnections?: number
180
+
181
+ /**
182
+ * Base delay for exponential backoff between short connection retries (ms).
183
+ * @default 100
184
+ */
185
+ backoffBaseDelay?: number
186
+
187
+ /**
188
+ * Maximum delay cap for exponential backoff (ms).
189
+ * @default 5000
190
+ */
191
+ backoffMaxDelay?: number
192
+
193
+ /**
194
+ * Whether to log warnings when falling back to long-poll.
195
+ * @default true
196
+ */
197
+ logWarnings?: boolean
198
+ }
199
+
200
+ // ============================================================================
201
+ // Chunk & Batch Types
202
+ // ============================================================================
203
+
204
+ /**
205
+ * Metadata for a JSON batch or chunk.
206
+ */
207
+ export interface JsonBatchMeta {
208
+ /**
209
+ * Last Stream-Next-Offset for this batch.
210
+ */
211
+ offset: Offset
212
+
213
+ /**
214
+ * True if this batch ends at the current end of the stream.
215
+ */
216
+ upToDate: boolean
217
+
218
+ /**
219
+ * Last Stream-Cursor / streamCursor, if present.
220
+ */
221
+ cursor?: string
222
+ }
223
+
224
+ /**
225
+ * A batch of parsed JSON items with metadata.
226
+ */
227
+ export interface JsonBatch<T = unknown> extends JsonBatchMeta {
228
+ /**
229
+ * The parsed JSON items in this batch.
230
+ */
231
+ items: ReadonlyArray<T>
232
+ }
233
+
234
+ /**
235
+ * A chunk of raw bytes with metadata.
236
+ */
237
+ export interface ByteChunk extends JsonBatchMeta {
238
+ /**
239
+ * The raw byte data.
240
+ */
241
+ data: Uint8Array
242
+ }
243
+
244
+ /**
245
+ * A chunk of text with metadata.
246
+ */
247
+ export interface TextChunk extends JsonBatchMeta {
248
+ /**
249
+ * The text content.
250
+ */
251
+ text: string
252
+ }
253
+
254
+ // ============================================================================
255
+ // StreamHandle Options (Read/Write API)
256
+ // ============================================================================
257
+
258
+ /**
259
+ * Base options for StreamHandle operations.
260
+ */
261
+ export interface StreamHandleOptions {
262
+ /**
263
+ * The full URL to the durable stream.
264
+ * E.g., "https://streams.example.com/my-account/chat/room-1"
265
+ */
266
+ url: string | URL
267
+
268
+ /**
269
+ * HTTP headers to include in requests.
270
+ * Values can be strings or functions (sync or async) that return strings.
271
+ *
272
+ * Functions are evaluated **per-request** (not per-session).
273
+ */
274
+ headers?: HeadersRecord
275
+
276
+ /**
277
+ * Query parameters to include in requests.
278
+ * Values can be strings or functions (sync or async) that return strings.
279
+ *
280
+ * Functions are evaluated **per-request** (not per-session).
281
+ */
282
+ params?: ParamsRecord
283
+
284
+ /**
285
+ * Custom fetch implementation.
286
+ * Defaults to globalThis.fetch.
287
+ */
288
+ fetch?: typeof globalThis.fetch
289
+
290
+ /**
291
+ * Default AbortSignal for operations.
292
+ */
293
+ signal?: AbortSignal
294
+
295
+ /**
296
+ * The content type for the stream.
297
+ */
298
+ contentType?: string
299
+
300
+ /**
301
+ * Error handler for recoverable errors.
302
+ */
303
+ onError?: StreamErrorHandler
304
+
305
+ /**
306
+ * Enable automatic batching for append() calls.
307
+ * When true, multiple append() calls made while a POST is in-flight
308
+ * will be batched together into a single request.
309
+ *
310
+ * @default true
311
+ */
312
+ batching?: boolean
313
+ }
314
+
315
+ /**
316
+ * Options for creating a new stream.
317
+ */
318
+ export interface CreateOptions extends StreamHandleOptions {
319
+ /**
320
+ * Time-to-live in seconds (relative TTL).
321
+ */
322
+ ttlSeconds?: number
323
+
324
+ /**
325
+ * Absolute expiry time (RFC3339 format).
326
+ */
327
+ expiresAt?: string
328
+
329
+ /**
330
+ * Initial body to append on creation.
331
+ */
332
+ body?: BodyInit | Uint8Array | string
333
+
334
+ /**
335
+ * Enable automatic batching for append() calls.
336
+ * When true, multiple append() calls made while a POST is in-flight
337
+ * will be batched together into a single request.
338
+ *
339
+ * @default true
340
+ */
341
+ batching?: boolean
342
+ }
343
+
344
+ /**
345
+ * Options for appending data to a stream.
346
+ */
347
+ export interface AppendOptions {
348
+ /**
349
+ * Writer coordination sequence (stream-seq header).
350
+ * Monotonic, lexicographic sequence for coordinating multiple writers.
351
+ * If lower than last appended seq, server returns 409 Conflict.
352
+ * Not related to read offsets.
353
+ */
354
+ seq?: string
355
+
356
+ /**
357
+ * Content type for this append.
358
+ * Must match the stream's content type.
359
+ */
360
+ contentType?: string
361
+
362
+ /**
363
+ * AbortSignal for this operation.
364
+ */
365
+ signal?: AbortSignal
366
+ }
367
+
368
+ /**
369
+ * Legacy live mode type (internal use only).
370
+ * @internal
371
+ */
372
+ export type LegacyLiveMode = `long-poll` | `sse`
373
+
374
+ /**
375
+ * Options for reading from a stream (internal iterator options).
376
+ * @internal
377
+ */
378
+ export interface ReadOptions {
379
+ /**
380
+ * Starting offset, passed as ?offset=...
381
+ * If omitted, defaults to "-1" (start of stream).
382
+ */
383
+ offset?: Offset
384
+
385
+ /**
386
+ * Live mode behavior:
387
+ * - undefined/true (default): Catch-up then auto-select SSE or long-poll for live updates
388
+ * - false: Only catch-up, stop after up-to-date (no live updates)
389
+ * - "long-poll": Use long-polling for live updates
390
+ * - "sse": Use SSE for live updates (throws if unsupported)
391
+ */
392
+ live?: boolean | LegacyLiveMode
393
+
394
+ /**
395
+ * Override cursor for the request.
396
+ * By default, the client echoes the last stream-cursor value.
397
+ */
398
+ cursor?: string
399
+
400
+ /**
401
+ * AbortSignal for this operation.
402
+ */
403
+ signal?: AbortSignal
404
+ }
405
+
406
+ /**
407
+ * Result from a HEAD request on a stream.
408
+ */
409
+ export interface HeadResult {
410
+ /**
411
+ * Whether the stream exists.
412
+ */
413
+ exists: true
414
+
415
+ /**
416
+ * The stream's content type.
417
+ */
418
+ contentType?: string
419
+
420
+ /**
421
+ * The tail offset (next offset after current end of stream).
422
+ * Provided by server as stream-offset header on HEAD.
423
+ */
424
+ offset?: Offset
425
+
426
+ /**
427
+ * ETag for the stream (format: {internal_stream_id}:{end_offset}).
428
+ */
429
+ etag?: string
430
+
431
+ /**
432
+ * Cache-Control header value.
433
+ */
434
+ cacheControl?: string
435
+ }
436
+
437
+ /**
438
+ * Metadata extracted from a stream response.
439
+ * Contains headers and control information from the stream server.
440
+ */
441
+ export interface ResponseMetadata {
442
+ /**
443
+ * Next offset to read from (stream-offset header).
444
+ */
445
+ offset: string
446
+
447
+ /**
448
+ * Cursor for CDN collapsing (stream-cursor header).
449
+ */
450
+ cursor?: string
451
+
452
+ /**
453
+ * True if stream-up-to-date header was present.
454
+ */
455
+ upToDate: boolean
456
+
457
+ /**
458
+ * ETag for caching.
459
+ */
460
+ etag?: string
461
+
462
+ /**
463
+ * Content type of the stream.
464
+ */
465
+ contentType?: string
466
+ }
467
+
468
+ /**
469
+ * Error codes for DurableStreamError.
470
+ */
471
+ export type DurableStreamErrorCode =
472
+ | `NOT_FOUND`
473
+ | `CONFLICT_SEQ`
474
+ | `CONFLICT_EXISTS`
475
+ | `BAD_REQUEST`
476
+ | `BUSY`
477
+ | `SSE_NOT_SUPPORTED`
478
+ | `UNAUTHORIZED`
479
+ | `FORBIDDEN`
480
+ | `RATE_LIMITED`
481
+ | `ALREADY_CONSUMED`
482
+ | `ALREADY_CLOSED`
483
+ | `UNKNOWN`
484
+
485
+ /**
486
+ * Options returned from onError handler to retry with modified params/headers.
487
+ * Following the Electric client pattern.
488
+ */
489
+ export type RetryOpts = {
490
+ params?: ParamsRecord
491
+ headers?: HeadersRecord
492
+ }
493
+
494
+ /**
495
+ * Error handler callback type.
496
+ *
497
+ * Called when a recoverable error occurs during streaming.
498
+ *
499
+ * **Return value behavior** (following Electric client pattern):
500
+ * - Return `{}` (empty object) → Retry immediately with same params/headers
501
+ * - Return `{ params }` → Retry with merged params (existing params preserved)
502
+ * - Return `{ headers }` → Retry with merged headers (existing headers preserved)
503
+ * - Return `void` or `undefined` → Stop stream and propagate the error
504
+ * - Return `null` → INVALID (will cause error - use `{}` instead)
505
+ *
506
+ * **Important**: To retry, you MUST return an object (can be empty `{}`).
507
+ * Returning nothing (`void`), explicitly returning `undefined`, or omitting
508
+ * a return statement all stop the stream. Do NOT return `null`.
509
+ *
510
+ * Note: Automatic retries with exponential backoff are already applied
511
+ * for 5xx server errors, network errors, and 429 rate limits before
512
+ * this handler is called.
513
+ *
514
+ * @example
515
+ * ```typescript
516
+ * // Retry on any error (returns empty object)
517
+ * onError: (error) => ({})
518
+ *
519
+ * // Refresh auth token on 401, propagate other errors
520
+ * onError: async (error) => {
521
+ * if (error instanceof FetchError && error.status === 401) {
522
+ * const newToken = await refreshAuthToken()
523
+ * return { headers: { Authorization: `Bearer ${newToken}` } }
524
+ * }
525
+ * // Implicitly returns undefined - error will propagate
526
+ * }
527
+ *
528
+ * // Conditionally retry with explicit propagation
529
+ * onError: (error) => {
530
+ * if (shouldRetry(error)) {
531
+ * return {} // Retry
532
+ * }
533
+ * return undefined // Explicitly propagate error
534
+ * }
535
+ * ```
536
+ */
537
+ export type StreamErrorHandler = (
538
+ error: Error
539
+ ) => void | RetryOpts | Promise<void | RetryOpts>
540
+
541
+ // ============================================================================
542
+ // StreamResponse Interface
543
+ // ============================================================================
544
+
545
+ /**
546
+ * A streaming session returned by stream() or DurableStream.stream().
547
+ *
548
+ * Represents a live session with fixed `url`, `offset`, and `live` parameters.
549
+ * Supports multiple consumption styles: Promise helpers, ReadableStreams,
550
+ * and Subscribers.
551
+ *
552
+ * @typeParam TJson - The type of JSON items in the stream.
553
+ */
554
+ export interface StreamResponse<TJson = unknown> {
555
+ // --- Static session info (known after first response) ---
556
+
557
+ /**
558
+ * The stream URL.
559
+ */
560
+ readonly url: string
561
+
562
+ /**
563
+ * The stream's content type (from first response).
564
+ */
565
+ readonly contentType?: string
566
+
567
+ /**
568
+ * The live mode for this session.
569
+ */
570
+ readonly live: LiveMode
571
+
572
+ /**
573
+ * The starting offset for this session.
574
+ */
575
+ readonly startOffset: Offset
576
+
577
+ // --- Response metadata (updated on each response) ---
578
+
579
+ /**
580
+ * HTTP response headers from the most recent server response.
581
+ * Updated on each long-poll/SSE response.
582
+ */
583
+ readonly headers: Headers
584
+
585
+ /**
586
+ * HTTP status code from the most recent server response.
587
+ * Updated on each long-poll/SSE response.
588
+ */
589
+ readonly status: number
590
+
591
+ /**
592
+ * HTTP status text from the most recent server response.
593
+ * Updated on each long-poll/SSE response.
594
+ */
595
+ readonly statusText: string
596
+
597
+ /**
598
+ * Whether the most recent response was successful (status 200-299).
599
+ * Always true for active streams (errors are thrown).
600
+ */
601
+ readonly ok: boolean
602
+
603
+ /**
604
+ * Whether the stream is waiting for initial data.
605
+ *
606
+ * Note: Always false in current implementation because stream() awaits
607
+ * the first response before returning. A future async iterator API
608
+ * could expose this as true during initial connection.
609
+ */
610
+ readonly isLoading: boolean
611
+
612
+ // --- Evolving state as data arrives ---
613
+
614
+ /**
615
+ * The next offset to read from (Stream-Next-Offset header).
616
+ *
617
+ * **Important**: This value advances **after data is delivered to the consumer**,
618
+ * not just after fetching from the server. The offset represents the position
619
+ * in the stream that follows the data most recently provided to your consumption
620
+ * method (body(), json(), bodyStream(), subscriber callback, etc.).
621
+ *
622
+ * Use this for resuming reads after a disconnect or saving checkpoints.
623
+ */
624
+ offset: Offset
625
+
626
+ /**
627
+ * Stream cursor for CDN collapsing (stream-cursor header).
628
+ *
629
+ * Updated after each chunk is delivered to the consumer.
630
+ */
631
+ cursor?: string
632
+
633
+ /**
634
+ * Whether we've reached the current end of the stream (stream-up-to-date header).
635
+ *
636
+ * Updated after each chunk is delivered to the consumer.
637
+ */
638
+ upToDate: boolean
639
+
640
+ // =================================
641
+ // 1) Accumulating helpers (Promise)
642
+ // =================================
643
+ // Accumulate until first `upToDate`, then resolve and stop.
644
+
645
+ /**
646
+ * Accumulate raw bytes until first `upToDate` batch, then resolve.
647
+ * When used with `live: "auto"`, signals the session to stop after upToDate.
648
+ */
649
+ body: () => Promise<Uint8Array>
650
+
651
+ /**
652
+ * Accumulate JSON *items* across batches into a single array, resolve at `upToDate`.
653
+ * Only valid in JSON-mode; throws otherwise.
654
+ * When used with `live: "auto"`, signals the session to stop after upToDate.
655
+ */
656
+ json: <T = TJson>() => Promise<Array<T>>
657
+
658
+ /**
659
+ * Accumulate text chunks into a single string, resolve at `upToDate`.
660
+ * When used with `live: "auto"`, signals the session to stop after upToDate.
661
+ */
662
+ text: () => Promise<string>
663
+
664
+ // =====================
665
+ // 2) ReadableStreams
666
+ // =====================
667
+
668
+ /**
669
+ * Raw bytes as a ReadableStream<Uint8Array>.
670
+ *
671
+ * The returned stream is guaranteed to be async-iterable, so you can use
672
+ * `for await...of` syntax even on Safari/iOS which may lack native support.
673
+ */
674
+ bodyStream: () => ReadableStreamAsyncIterable<Uint8Array>
675
+
676
+ /**
677
+ * Individual JSON items (flattened) as a ReadableStream<TJson>.
678
+ * Built on jsonBatches().
679
+ *
680
+ * The returned stream is guaranteed to be async-iterable, so you can use
681
+ * `for await...of` syntax even on Safari/iOS which may lack native support.
682
+ */
683
+ jsonStream: () => ReadableStreamAsyncIterable<TJson>
684
+
685
+ /**
686
+ * Text chunks as ReadableStream<string>.
687
+ *
688
+ * The returned stream is guaranteed to be async-iterable, so you can use
689
+ * `for await...of` syntax even on Safari/iOS which may lack native support.
690
+ */
691
+ textStream: () => ReadableStreamAsyncIterable<string>
692
+
693
+ // =====================
694
+ // 3) Subscriber APIs
695
+ // =====================
696
+ // Subscribers return Promise<void> for backpressure control.
697
+ // Note: Only one consumption method can be used per StreamResponse.
698
+
699
+ /**
700
+ * Subscribe to JSON batches as they arrive.
701
+ * Returns unsubscribe function.
702
+ */
703
+ subscribeJson: <T = TJson>(
704
+ subscriber: (batch: JsonBatch<T>) => Promise<void>
705
+ ) => () => void
706
+
707
+ /**
708
+ * Subscribe to raw byte chunks as they arrive.
709
+ * Returns unsubscribe function.
710
+ */
711
+ subscribeBytes: (
712
+ subscriber: (chunk: ByteChunk) => Promise<void>
713
+ ) => () => void
714
+
715
+ /**
716
+ * Subscribe to text chunks as they arrive.
717
+ * Returns unsubscribe function.
718
+ */
719
+ subscribeText: (subscriber: (chunk: TextChunk) => Promise<void>) => () => void
720
+
721
+ // =====================
722
+ // 4) Lifecycle
723
+ // =====================
724
+
725
+ /**
726
+ * Cancel the underlying session (abort HTTP, close SSE, stop long-polls).
727
+ */
728
+ cancel: (reason?: unknown) => void
729
+
730
+ /**
731
+ * Resolves when the session has fully closed:
732
+ * - `live:false` and up-to-date reached,
733
+ * - manual cancellation,
734
+ * - terminal error.
735
+ */
736
+ readonly closed: Promise<void>
737
+ }