@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/README.md +799 -0
- package/dist/index.cjs +1172 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +627 -0
- package/dist/index.d.ts +1072 -0
- package/dist/index.js +1830 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/src/asyncIterableReadableStream.ts +220 -0
- package/src/constants.ts +105 -0
- package/src/error.ts +189 -0
- package/src/fetch.ts +267 -0
- package/src/index.ts +103 -0
- package/src/response.ts +1053 -0
- package/src/sse.ts +130 -0
- package/src/stream-api.ts +284 -0
- package/src/stream.ts +867 -0
- package/src/types.ts +737 -0
- package/src/utils.ts +104 -0
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
|
+
}
|