@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/sse.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * SSE (Server-Sent Events) parsing utilities for the durable streams protocol.
3
+ *
4
+ * SSE format from protocol:
5
+ * - `event: data` events contain the stream data
6
+ * - `event: control` events contain `streamNextOffset` and optional `streamCursor` and `upToDate`
7
+ */
8
+
9
+ import type { Offset } from "./types"
10
+
11
+ /**
12
+ * Parsed SSE event from the stream.
13
+ */
14
+ export interface SSEDataEvent {
15
+ type: `data`
16
+ data: string
17
+ }
18
+
19
+ export interface SSEControlEvent {
20
+ type: `control`
21
+ streamNextOffset: Offset
22
+ streamCursor?: string
23
+ upToDate?: boolean
24
+ }
25
+
26
+ export type SSEEvent = SSEDataEvent | SSEControlEvent
27
+
28
+ /**
29
+ * Parse SSE events from a ReadableStream<Uint8Array>.
30
+ * Yields parsed events as they arrive.
31
+ */
32
+ export async function* parseSSEStream(
33
+ stream: ReadableStream<Uint8Array>,
34
+ signal?: AbortSignal
35
+ ): AsyncGenerator<SSEEvent, void, undefined> {
36
+ const reader = stream.getReader()
37
+ const decoder = new TextDecoder()
38
+ let buffer = ``
39
+ let currentEvent: { type?: string; data: Array<string> } = { data: [] }
40
+
41
+ try {
42
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
43
+ while (true) {
44
+ if (signal?.aborted) {
45
+ break
46
+ }
47
+
48
+ const { done, value } = await reader.read()
49
+ if (done) break
50
+
51
+ buffer += decoder.decode(value, { stream: true })
52
+
53
+ // Process complete lines
54
+ const lines = buffer.split(`\n`)
55
+ // Keep the last incomplete line in the buffer
56
+ buffer = lines.pop() ?? ``
57
+
58
+ for (const line of lines) {
59
+ if (line === ``) {
60
+ // Empty line signals end of event
61
+ if (currentEvent.type && currentEvent.data.length > 0) {
62
+ const dataStr = currentEvent.data.join(`\n`)
63
+
64
+ if (currentEvent.type === `data`) {
65
+ yield { type: `data`, data: dataStr }
66
+ } else if (currentEvent.type === `control`) {
67
+ try {
68
+ const control = JSON.parse(dataStr) as {
69
+ streamNextOffset: Offset
70
+ streamCursor?: string
71
+ upToDate?: boolean
72
+ }
73
+ yield {
74
+ type: `control`,
75
+ streamNextOffset: control.streamNextOffset,
76
+ streamCursor: control.streamCursor,
77
+ upToDate: control.upToDate,
78
+ }
79
+ } catch {
80
+ // Invalid control event, skip
81
+ }
82
+ }
83
+ }
84
+ currentEvent = { data: [] }
85
+ } else if (line.startsWith(`event:`)) {
86
+ currentEvent.type = line.slice(6).trim()
87
+ } else if (line.startsWith(`data:`)) {
88
+ // Per SSE spec, strip the optional space after "data:"
89
+ const content = line.slice(5)
90
+ currentEvent.data.push(
91
+ content.startsWith(` `) ? content.slice(1) : content
92
+ )
93
+ }
94
+ // Ignore other fields (id, retry, comments)
95
+ }
96
+ }
97
+
98
+ // Handle any remaining data
99
+ const remaining = decoder.decode()
100
+ if (remaining) {
101
+ buffer += remaining
102
+ }
103
+
104
+ // Process any final event
105
+ if (buffer && currentEvent.type && currentEvent.data.length > 0) {
106
+ const dataStr = currentEvent.data.join(`\n`)
107
+ if (currentEvent.type === `data`) {
108
+ yield { type: `data`, data: dataStr }
109
+ } else if (currentEvent.type === `control`) {
110
+ try {
111
+ const control = JSON.parse(dataStr) as {
112
+ streamNextOffset: Offset
113
+ streamCursor?: string
114
+ upToDate?: boolean
115
+ }
116
+ yield {
117
+ type: `control`,
118
+ streamNextOffset: control.streamNextOffset,
119
+ streamCursor: control.streamCursor,
120
+ upToDate: control.upToDate,
121
+ }
122
+ } catch {
123
+ // Invalid control event, skip
124
+ }
125
+ }
126
+ }
127
+ } finally {
128
+ reader.releaseLock()
129
+ }
130
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Standalone stream() function - the fetch-like read API.
3
+ *
4
+ * This is the primary API for consumers who only need to read from streams.
5
+ */
6
+
7
+ import {
8
+ LIVE_QUERY_PARAM,
9
+ OFFSET_QUERY_PARAM,
10
+ STREAM_CURSOR_HEADER,
11
+ STREAM_OFFSET_HEADER,
12
+ STREAM_UP_TO_DATE_HEADER,
13
+ } from "./constants"
14
+ import { DurableStreamError, FetchBackoffAbortError } from "./error"
15
+ import { BackoffDefaults, createFetchWithBackoff } from "./fetch"
16
+ import { StreamResponseImpl } from "./response"
17
+ import { handleErrorResponse, resolveHeaders, resolveParams } from "./utils"
18
+ import type { LiveMode, Offset, StreamOptions, StreamResponse } from "./types"
19
+
20
+ /**
21
+ * Create a streaming session to read from a durable stream.
22
+ *
23
+ * This is a fetch-like API:
24
+ * - The promise resolves after the first network request succeeds
25
+ * - It rejects for auth/404/other protocol errors
26
+ * - Returns a StreamResponse for consuming the data
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * // Catch-up JSON:
31
+ * const res = await stream<{ message: string }>({
32
+ * url,
33
+ * auth,
34
+ * offset: "0",
35
+ * live: false,
36
+ * })
37
+ * const items = await res.json()
38
+ *
39
+ * // Live JSON:
40
+ * const live = await stream<{ message: string }>({
41
+ * url,
42
+ * auth,
43
+ * offset: savedOffset,
44
+ * live: "auto",
45
+ * })
46
+ * live.subscribeJson(async (batch) => {
47
+ * for (const item of batch.items) {
48
+ * handle(item)
49
+ * }
50
+ * })
51
+ * ```
52
+ */
53
+ export async function stream<TJson = unknown>(
54
+ options: StreamOptions
55
+ ): Promise<StreamResponse<TJson>> {
56
+ // Validate options
57
+ if (!options.url) {
58
+ throw new DurableStreamError(
59
+ `Invalid stream options: missing required url parameter`,
60
+ `BAD_REQUEST`
61
+ )
62
+ }
63
+
64
+ // Mutable options that can be updated by onError handler
65
+ let currentHeaders = options.headers
66
+ let currentParams = options.params
67
+
68
+ // Retry loop for onError handling
69
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
70
+ while (true) {
71
+ try {
72
+ return await streamInternal<TJson>({
73
+ ...options,
74
+ headers: currentHeaders,
75
+ params: currentParams,
76
+ })
77
+ } catch (err) {
78
+ // If there's an onError handler, give it a chance to recover
79
+ if (options.onError) {
80
+ const retryOpts = await options.onError(
81
+ err instanceof Error ? err : new Error(String(err))
82
+ )
83
+
84
+ // If handler returns void/undefined, stop retrying
85
+ if (retryOpts === undefined) {
86
+ throw err
87
+ }
88
+
89
+ // Merge returned params/headers for retry
90
+ if (retryOpts.params) {
91
+ currentParams = {
92
+ ...currentParams,
93
+ ...retryOpts.params,
94
+ }
95
+ }
96
+ if (retryOpts.headers) {
97
+ currentHeaders = {
98
+ ...currentHeaders,
99
+ ...retryOpts.headers,
100
+ }
101
+ }
102
+
103
+ // Continue to retry with updated options
104
+ continue
105
+ }
106
+
107
+ // No onError handler, just throw
108
+ throw err
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Internal implementation of stream that doesn't handle onError retries.
115
+ */
116
+ async function streamInternal<TJson = unknown>(
117
+ options: StreamOptions
118
+ ): Promise<StreamResponse<TJson>> {
119
+ // Normalize URL
120
+ const url = options.url instanceof URL ? options.url.toString() : options.url
121
+
122
+ // Build the first request
123
+ const fetchUrl = new URL(url)
124
+
125
+ // Set offset query param
126
+ const startOffset = options.offset ?? `-1`
127
+ fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, startOffset)
128
+
129
+ // Set live query param for explicit modes
130
+ const live: LiveMode = options.live ?? `auto`
131
+ if (live === `long-poll` || live === `sse`) {
132
+ fetchUrl.searchParams.set(LIVE_QUERY_PARAM, live)
133
+ }
134
+
135
+ // Add custom params
136
+ const params = await resolveParams(options.params)
137
+ for (const [key, value] of Object.entries(params)) {
138
+ fetchUrl.searchParams.set(key, value)
139
+ }
140
+
141
+ // Build headers
142
+ const headers = await resolveHeaders(options.headers)
143
+
144
+ // Create abort controller
145
+ const abortController = new AbortController()
146
+ if (options.signal) {
147
+ options.signal.addEventListener(
148
+ `abort`,
149
+ () => abortController.abort(options.signal?.reason),
150
+ { once: true }
151
+ )
152
+ }
153
+
154
+ // Get fetch client with backoff
155
+ const baseFetchClient =
156
+ options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
157
+ const backoffOptions = options.backoffOptions ?? BackoffDefaults
158
+ const fetchClient = createFetchWithBackoff(baseFetchClient, backoffOptions)
159
+
160
+ // Make the first request
161
+ // Backoff client will throw FetchError for non-OK responses
162
+ let firstResponse: Response
163
+ try {
164
+ firstResponse = await fetchClient(fetchUrl.toString(), {
165
+ method: `GET`,
166
+ headers,
167
+ signal: abortController.signal,
168
+ })
169
+ } catch (err) {
170
+ if (err instanceof FetchBackoffAbortError) {
171
+ throw new DurableStreamError(`Stream request was aborted`, `UNKNOWN`)
172
+ }
173
+ // Let other errors (including FetchError) propagate to onError handler
174
+ throw err
175
+ }
176
+
177
+ // Extract metadata from headers
178
+ const contentType = firstResponse.headers.get(`content-type`) ?? undefined
179
+ const initialOffset =
180
+ firstResponse.headers.get(STREAM_OFFSET_HEADER) ?? startOffset
181
+ const initialCursor =
182
+ firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? undefined
183
+ const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER)
184
+
185
+ // Determine if JSON mode
186
+ const isJsonMode =
187
+ options.json === true ||
188
+ (contentType?.includes(`application/json`) ?? false)
189
+
190
+ // Create the fetch function for subsequent requests
191
+ const fetchNext = async (
192
+ offset: Offset,
193
+ cursor: string | undefined,
194
+ signal: AbortSignal
195
+ ): Promise<Response> => {
196
+ const nextUrl = new URL(url)
197
+ nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset)
198
+
199
+ // For subsequent requests in auto mode, use long-poll
200
+ if (live === `auto` || live === `long-poll`) {
201
+ nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`)
202
+ } else if (live === `sse`) {
203
+ nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`)
204
+ }
205
+
206
+ if (cursor) {
207
+ nextUrl.searchParams.set(`cursor`, cursor)
208
+ }
209
+
210
+ // Resolve params per-request (for dynamic values)
211
+ const nextParams = await resolveParams(options.params)
212
+ for (const [key, value] of Object.entries(nextParams)) {
213
+ nextUrl.searchParams.set(key, value)
214
+ }
215
+
216
+ const nextHeaders = await resolveHeaders(options.headers)
217
+
218
+ const response = await fetchClient(nextUrl.toString(), {
219
+ method: `GET`,
220
+ headers: nextHeaders,
221
+ signal,
222
+ })
223
+
224
+ if (!response.ok) {
225
+ await handleErrorResponse(response, url)
226
+ }
227
+
228
+ return response
229
+ }
230
+
231
+ // Create SSE start function (for SSE mode reconnection)
232
+ const startSSE =
233
+ live === `sse`
234
+ ? async (
235
+ offset: Offset,
236
+ cursor: string | undefined,
237
+ signal: AbortSignal
238
+ ): Promise<Response> => {
239
+ const sseUrl = new URL(url)
240
+ sseUrl.searchParams.set(OFFSET_QUERY_PARAM, offset)
241
+ sseUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`)
242
+ if (cursor) {
243
+ sseUrl.searchParams.set(`cursor`, cursor)
244
+ }
245
+
246
+ // Resolve params per-request (for dynamic values)
247
+ const sseParams = await resolveParams(options.params)
248
+ for (const [key, value] of Object.entries(sseParams)) {
249
+ sseUrl.searchParams.set(key, value)
250
+ }
251
+
252
+ const sseHeaders = await resolveHeaders(options.headers)
253
+
254
+ const response = await fetchClient(sseUrl.toString(), {
255
+ method: `GET`,
256
+ headers: sseHeaders,
257
+ signal,
258
+ })
259
+
260
+ if (!response.ok) {
261
+ await handleErrorResponse(response, url)
262
+ }
263
+
264
+ return response
265
+ }
266
+ : undefined
267
+
268
+ // Create and return the StreamResponse
269
+ return new StreamResponseImpl<TJson>({
270
+ url,
271
+ contentType,
272
+ live,
273
+ startOffset,
274
+ isJsonMode,
275
+ initialOffset,
276
+ initialCursor,
277
+ initialUpToDate,
278
+ firstResponse,
279
+ abortController,
280
+ fetchNext,
281
+ startSSE,
282
+ sseResilience: options.sseResilience,
283
+ })
284
+ }