@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/error.ts ADDED
@@ -0,0 +1,189 @@
1
+ import type { DurableStreamErrorCode } from "./types"
2
+
3
+ /**
4
+ * Error thrown for transport/network errors.
5
+ * Following the @electric-sql/client FetchError pattern.
6
+ */
7
+ export class FetchError extends Error {
8
+ status: number
9
+ text?: string
10
+ json?: object
11
+ headers: Record<string, string>
12
+
13
+ constructor(
14
+ status: number,
15
+ text: string | undefined,
16
+ json: object | undefined,
17
+ headers: Record<string, string>,
18
+ public url: string,
19
+ message?: string
20
+ ) {
21
+ super(
22
+ message ||
23
+ `HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`
24
+ )
25
+ this.name = `FetchError`
26
+ this.status = status
27
+ this.text = text
28
+ this.json = json
29
+ this.headers = headers
30
+ }
31
+
32
+ static async fromResponse(
33
+ response: Response,
34
+ url: string
35
+ ): Promise<FetchError> {
36
+ const status = response.status
37
+ const headers = Object.fromEntries([...response.headers.entries()])
38
+ let text: string | undefined = undefined
39
+ let json: object | undefined = undefined
40
+
41
+ const contentType = response.headers.get(`content-type`)
42
+ if (!response.bodyUsed) {
43
+ if (contentType && contentType.includes(`application/json`)) {
44
+ try {
45
+ json = (await response.json()) as object
46
+ } catch {
47
+ // If JSON parsing fails, fall back to text
48
+ text = await response.text()
49
+ }
50
+ } else {
51
+ text = await response.text()
52
+ }
53
+ }
54
+
55
+ return new FetchError(status, text, json, headers, url)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Error thrown when a fetch operation is aborted during backoff.
61
+ */
62
+ export class FetchBackoffAbortError extends Error {
63
+ constructor() {
64
+ super(`Fetch with backoff aborted`)
65
+ this.name = `FetchBackoffAbortError`
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Protocol-level error for Durable Streams operations.
71
+ * Provides structured error handling with error codes.
72
+ */
73
+ export class DurableStreamError extends Error {
74
+ /**
75
+ * HTTP status code, if applicable.
76
+ */
77
+ status?: number
78
+
79
+ /**
80
+ * Structured error code for programmatic handling.
81
+ */
82
+ code: DurableStreamErrorCode
83
+
84
+ /**
85
+ * Additional error details (e.g., raw response body).
86
+ */
87
+ details?: unknown
88
+
89
+ constructor(
90
+ message: string,
91
+ code: DurableStreamErrorCode,
92
+ status?: number,
93
+ details?: unknown
94
+ ) {
95
+ super(message)
96
+ this.name = `DurableStreamError`
97
+ this.code = code
98
+ this.status = status
99
+ this.details = details
100
+ }
101
+
102
+ /**
103
+ * Create a DurableStreamError from an HTTP response.
104
+ */
105
+ static async fromResponse(
106
+ response: Response,
107
+ url: string
108
+ ): Promise<DurableStreamError> {
109
+ const status = response.status
110
+ let details: unknown
111
+
112
+ const contentType = response.headers.get(`content-type`)
113
+ if (!response.bodyUsed) {
114
+ if (contentType && contentType.includes(`application/json`)) {
115
+ try {
116
+ details = await response.json()
117
+ } catch {
118
+ details = await response.text()
119
+ }
120
+ } else {
121
+ details = await response.text()
122
+ }
123
+ }
124
+
125
+ const code = statusToCode(status)
126
+ const message = `Durable stream error at ${url}: ${response.statusText || status}`
127
+
128
+ return new DurableStreamError(message, code, status, details)
129
+ }
130
+
131
+ /**
132
+ * Create a DurableStreamError from a FetchError.
133
+ */
134
+ static fromFetchError(error: FetchError): DurableStreamError {
135
+ const code = statusToCode(error.status)
136
+ return new DurableStreamError(
137
+ error.message,
138
+ code,
139
+ error.status,
140
+ error.json ?? error.text
141
+ )
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Map HTTP status codes to DurableStreamErrorCode.
147
+ */
148
+ function statusToCode(status: number): DurableStreamErrorCode {
149
+ switch (status) {
150
+ case 400:
151
+ return `BAD_REQUEST`
152
+ case 401:
153
+ return `UNAUTHORIZED`
154
+ case 403:
155
+ return `FORBIDDEN`
156
+ case 404:
157
+ return `NOT_FOUND`
158
+ case 409:
159
+ // Could be CONFLICT_SEQ or CONFLICT_EXISTS depending on context
160
+ // Default to CONFLICT_SEQ, caller can override
161
+ return `CONFLICT_SEQ`
162
+ case 429:
163
+ return `RATE_LIMITED`
164
+ case 503:
165
+ return `BUSY`
166
+ default:
167
+ return `UNKNOWN`
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Error thrown when stream URL is missing.
173
+ */
174
+ export class MissingStreamUrlError extends Error {
175
+ constructor() {
176
+ super(`Invalid stream options: missing required url parameter`)
177
+ this.name = `MissingStreamUrlError`
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Error thrown when signal option is invalid.
183
+ */
184
+ export class InvalidSignalError extends Error {
185
+ constructor() {
186
+ super(`Invalid signal option. It must be an instance of AbortSignal.`)
187
+ this.name = `InvalidSignalError`
188
+ }
189
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Fetch utilities with retry and backoff support.
3
+ * Based on @electric-sql/client patterns.
4
+ */
5
+
6
+ import { FetchBackoffAbortError, FetchError } from "./error"
7
+
8
+ /**
9
+ * HTTP status codes that should be retried.
10
+ */
11
+ const HTTP_RETRY_STATUS_CODES = [429, 503]
12
+
13
+ /**
14
+ * Options for configuring exponential backoff retry behavior.
15
+ */
16
+ export interface BackoffOptions {
17
+ /**
18
+ * Initial delay before retrying in milliseconds.
19
+ */
20
+ initialDelay: number
21
+
22
+ /**
23
+ * Maximum retry delay in milliseconds.
24
+ * After reaching this, delay stays constant.
25
+ */
26
+ maxDelay: number
27
+
28
+ /**
29
+ * Multiplier for exponential backoff.
30
+ */
31
+ multiplier: number
32
+
33
+ /**
34
+ * Callback invoked on each failed attempt.
35
+ */
36
+ onFailedAttempt?: () => void
37
+
38
+ /**
39
+ * Enable debug logging.
40
+ */
41
+ debug?: boolean
42
+
43
+ /**
44
+ * Maximum number of retry attempts before giving up.
45
+ * Set to Infinity for indefinite retries (useful for offline scenarios).
46
+ */
47
+ maxRetries?: number
48
+ }
49
+
50
+ /**
51
+ * Default backoff options.
52
+ */
53
+ export const BackoffDefaults: BackoffOptions = {
54
+ initialDelay: 100,
55
+ maxDelay: 60_000, // Cap at 60s
56
+ multiplier: 1.3,
57
+ maxRetries: Infinity, // Retry forever by default
58
+ }
59
+
60
+ /**
61
+ * Parse Retry-After header value and return delay in milliseconds.
62
+ * Supports both delta-seconds format and HTTP-date format.
63
+ * Returns 0 if header is not present or invalid.
64
+ */
65
+ export function parseRetryAfterHeader(retryAfter: string | undefined): number {
66
+ if (!retryAfter) return 0
67
+
68
+ // Try parsing as seconds (delta-seconds format)
69
+ const retryAfterSec = Number(retryAfter)
70
+ if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
71
+ return retryAfterSec * 1000
72
+ }
73
+
74
+ // Try parsing as HTTP-date
75
+ const retryDate = Date.parse(retryAfter)
76
+ if (!isNaN(retryDate)) {
77
+ // Handle clock skew: clamp to non-negative, cap at reasonable max
78
+ const deltaMs = retryDate - Date.now()
79
+ return Math.max(0, Math.min(deltaMs, 3600_000)) // Cap at 1 hour
80
+ }
81
+
82
+ return 0
83
+ }
84
+
85
+ /**
86
+ * Creates a fetch client that retries failed requests with exponential backoff.
87
+ *
88
+ * @param fetchClient - The base fetch client to wrap
89
+ * @param backoffOptions - Options for retry behavior
90
+ * @returns A fetch function with automatic retry
91
+ */
92
+ export function createFetchWithBackoff(
93
+ fetchClient: typeof fetch,
94
+ backoffOptions: BackoffOptions = BackoffDefaults
95
+ ): typeof fetch {
96
+ const {
97
+ initialDelay,
98
+ maxDelay,
99
+ multiplier,
100
+ debug = false,
101
+ onFailedAttempt,
102
+ maxRetries = Infinity,
103
+ } = backoffOptions
104
+
105
+ return async (...args: Parameters<typeof fetch>): Promise<Response> => {
106
+ const url = args[0]
107
+ const options = args[1]
108
+
109
+ let delay = initialDelay
110
+ let attempt = 0
111
+
112
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
113
+ while (true) {
114
+ try {
115
+ const result = await fetchClient(...args)
116
+ if (result.ok) {
117
+ return result
118
+ }
119
+
120
+ const err = await FetchError.fromResponse(result, url.toString())
121
+ throw err
122
+ } catch (e) {
123
+ onFailedAttempt?.()
124
+
125
+ if (options?.signal?.aborted) {
126
+ throw new FetchBackoffAbortError()
127
+ } else if (
128
+ e instanceof FetchError &&
129
+ !HTTP_RETRY_STATUS_CODES.includes(e.status) &&
130
+ e.status >= 400 &&
131
+ e.status < 500
132
+ ) {
133
+ // Client errors (except 429) cannot be backed off on
134
+ throw e
135
+ } else {
136
+ // Check max retries
137
+ attempt++
138
+ if (attempt > maxRetries) {
139
+ if (debug) {
140
+ console.log(
141
+ `Max retries reached (${attempt}/${maxRetries}), giving up`
142
+ )
143
+ }
144
+ throw e
145
+ }
146
+
147
+ // Calculate wait time honoring server-driven backoff as a floor
148
+ // Parse server-provided Retry-After (if present)
149
+ const serverMinimumMs =
150
+ e instanceof FetchError
151
+ ? parseRetryAfterHeader(e.headers[`retry-after`])
152
+ : 0
153
+
154
+ // Calculate client backoff with full jitter strategy
155
+ // Full jitter: random_between(0, min(cap, exponential_backoff))
156
+ const jitter = Math.random() * delay
157
+ const clientBackoffMs = Math.min(jitter, maxDelay)
158
+
159
+ // Server minimum is the floor, client cap is the ceiling
160
+ const waitMs = Math.max(serverMinimumMs, clientBackoffMs)
161
+
162
+ if (debug) {
163
+ const source = serverMinimumMs > 0 ? `server+client` : `client`
164
+ console.log(
165
+ `Retry attempt #${attempt} after ${waitMs}ms (${source}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`
166
+ )
167
+ }
168
+
169
+ // Wait for the calculated duration
170
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
171
+
172
+ // Increase the delay for the next attempt (capped at maxDelay)
173
+ delay = Math.min(delay * multiplier, maxDelay)
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Status codes where we shouldn't try to read the body.
182
+ */
183
+ const NO_BODY_STATUS_CODES = [201, 204, 205]
184
+
185
+ /**
186
+ * Creates a fetch client that ensures the response body is fully consumed.
187
+ * This prevents issues with connection pooling when bodies aren't read.
188
+ *
189
+ * Uses arrayBuffer() instead of text() to preserve binary data integrity.
190
+ *
191
+ * @param fetchClient - The base fetch client to wrap
192
+ * @returns A fetch function that consumes response bodies
193
+ */
194
+ export function createFetchWithConsumedBody(
195
+ fetchClient: typeof fetch
196
+ ): typeof fetch {
197
+ return async (...args: Parameters<typeof fetch>): Promise<Response> => {
198
+ const url = args[0]
199
+ const res = await fetchClient(...args)
200
+
201
+ try {
202
+ if (res.status < 200 || NO_BODY_STATUS_CODES.includes(res.status)) {
203
+ return res
204
+ }
205
+
206
+ // Read body as arrayBuffer to preserve binary data integrity
207
+ const buf = await res.arrayBuffer()
208
+ return new Response(buf, {
209
+ status: res.status,
210
+ statusText: res.statusText,
211
+ headers: res.headers,
212
+ })
213
+ } catch (err) {
214
+ if (args[1]?.signal?.aborted) {
215
+ throw new FetchBackoffAbortError()
216
+ }
217
+
218
+ throw new FetchError(
219
+ res.status,
220
+ undefined,
221
+ undefined,
222
+ Object.fromEntries([...res.headers.entries()]),
223
+ url.toString(),
224
+ err instanceof Error
225
+ ? err.message
226
+ : typeof err === `string`
227
+ ? err
228
+ : `failed to read body`
229
+ )
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Chains an AbortController to an optional source signal.
236
+ * If the source signal is aborted, the provided controller will also abort.
237
+ */
238
+ export function chainAborter(
239
+ aborter: AbortController,
240
+ sourceSignal?: AbortSignal | null
241
+ ): {
242
+ signal: AbortSignal
243
+ cleanup: () => void
244
+ } {
245
+ let cleanup = noop
246
+ if (!sourceSignal) {
247
+ // no-op, nothing to chain to
248
+ } else if (sourceSignal.aborted) {
249
+ // source signal is already aborted, abort immediately
250
+ aborter.abort(sourceSignal.reason)
251
+ } else {
252
+ // chain to source signal abort event
253
+ const abortParent = () => aborter.abort(sourceSignal.reason)
254
+ sourceSignal.addEventListener(`abort`, abortParent, {
255
+ once: true,
256
+ signal: aborter.signal,
257
+ })
258
+ cleanup = () => sourceSignal.removeEventListener(`abort`, abortParent)
259
+ }
260
+
261
+ return {
262
+ signal: aborter.signal,
263
+ cleanup,
264
+ }
265
+ }
266
+
267
+ function noop() {}
package/src/index.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Durable Streams TypeScript Client
3
+ *
4
+ * A client library for the Electric Durable Streams protocol.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ // ============================================================================
10
+ // Primary Read API (new)
11
+ // ============================================================================
12
+
13
+ // Standalone stream() function - the fetch-like read API
14
+ export { stream } from "./stream-api"
15
+
16
+ // ============================================================================
17
+ // Handle API (read/write)
18
+ // ============================================================================
19
+
20
+ // DurableStream class for read/write operations
21
+ export { DurableStream, type DurableStreamOptions } from "./stream"
22
+
23
+ // ============================================================================
24
+ // Types
25
+ // ============================================================================
26
+
27
+ export type {
28
+ // Core types
29
+ Offset,
30
+ HeadersRecord,
31
+ ParamsRecord,
32
+ MaybePromise,
33
+
34
+ // Stream options (new API)
35
+ StreamOptions,
36
+ StreamHandleOptions,
37
+ LiveMode,
38
+ SSEResilienceOptions,
39
+
40
+ // Chunk & batch types (new API)
41
+ JsonBatchMeta,
42
+ JsonBatch,
43
+ ByteChunk,
44
+ TextChunk,
45
+ StreamResponse,
46
+
47
+ // Legacy types (still used internally)
48
+ CreateOptions,
49
+ AppendOptions,
50
+ ReadOptions,
51
+ HeadResult,
52
+ LegacyLiveMode,
53
+
54
+ // Error handling
55
+ DurableStreamErrorCode,
56
+ RetryOpts,
57
+ StreamErrorHandler,
58
+ } from "./types"
59
+
60
+ // Re-export async iterable helper type and function
61
+ export type { ReadableStreamAsyncIterable } from "./asyncIterableReadableStream"
62
+ export { asAsyncIterableReadableStream } from "./asyncIterableReadableStream"
63
+
64
+ // ============================================================================
65
+ // Errors
66
+ // ============================================================================
67
+
68
+ export {
69
+ FetchError,
70
+ FetchBackoffAbortError,
71
+ DurableStreamError,
72
+ MissingStreamUrlError,
73
+ InvalidSignalError,
74
+ } from "./error"
75
+
76
+ // ============================================================================
77
+ // Fetch utilities
78
+ // ============================================================================
79
+
80
+ export {
81
+ type BackoffOptions,
82
+ BackoffDefaults,
83
+ createFetchWithBackoff,
84
+ createFetchWithConsumedBody,
85
+ } from "./fetch"
86
+
87
+ // ============================================================================
88
+ // Constants (for advanced users)
89
+ // ============================================================================
90
+
91
+ export {
92
+ STREAM_OFFSET_HEADER,
93
+ STREAM_CURSOR_HEADER,
94
+ STREAM_UP_TO_DATE_HEADER,
95
+ STREAM_SEQ_HEADER,
96
+ STREAM_TTL_HEADER,
97
+ STREAM_EXPIRES_AT_HEADER,
98
+ OFFSET_QUERY_PARAM,
99
+ LIVE_QUERY_PARAM,
100
+ CURSOR_QUERY_PARAM,
101
+ SSE_COMPATIBLE_CONTENT_TYPES,
102
+ DURABLE_STREAM_PROTOCOL_QUERY_PARAMS,
103
+ } from "./constants"