@tanstack/ai-durable-stream 0.0.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.
@@ -0,0 +1,951 @@
1
+ import { resolveResumeRunId } from '@tanstack/ai'
2
+ import type { StreamChunk, StreamDurability } from '@tanstack/ai'
3
+
4
+ declare const durableStreamCursorBrand: unique symbol
5
+
6
+ /** A validated, versioned offset produced by this adapter. */
7
+ type DurableStreamCursor = string & {
8
+ readonly [durableStreamCursorBrand]: true
9
+ }
10
+
11
+ /** Adapter offsets also include the Durable Streams protocol sentinels. */
12
+ export type DurableStreamOffset = DurableStreamCursor | '-1' | 'now'
13
+
14
+ export interface DurableStreamOptions {
15
+ /**
16
+ * Base URL of the Durable Streams server (no trailing slash needed).
17
+ * Optional when `fetch` is supplied — e.g. a Cloudflare service binding that
18
+ * ignores the host and dispatches to the bound Worker by path — in which case
19
+ * an internal placeholder base is used and only the `/streams/...` path
20
+ * matters.
21
+ */
22
+ server?: string
23
+ /** Stream-name prefix. Defaults to `runs`. */
24
+ streamPrefix?: string
25
+ /** Fetch implementation. Defaults to the global fetch. */
26
+ fetch?: typeof globalThis.fetch
27
+ /**
28
+ * Headers applied to every create, append, read, and close request. A
29
+ * resolver is called for every request so credentials can rotate.
30
+ */
31
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)
32
+ /**
33
+ * Bounding for the read reconnect loop. After a response-body read failure
34
+ * mid-window, `read` retries from the last valid position; these cap
35
+ * consecutive retries and throttle them so a persistently failing backend
36
+ * surfaces the error instead of looping without end. Normal window
37
+ * advancement (long-poll) is never throttled.
38
+ */
39
+ reconnect?: {
40
+ /**
41
+ * Consecutive body-read-failure retries before surfacing the underlying
42
+ * read error. Default 10.
43
+ */
44
+ maxReadFailures?: number
45
+ /** Delay between read retries, in ms. Default 250. */
46
+ delayMs?: number
47
+ }
48
+ /**
49
+ * Timeout (ms) for a single create / append / close request to the backend.
50
+ * A stalled backend would otherwise hang chunk delivery or terminalization
51
+ * indefinitely. Default 30000. Long-poll `read` window advancement is NOT
52
+ * bounded by this — a caught-up reader may legitimately wait. `snapshot`,
53
+ * which must always return, IS bounded by it.
54
+ */
55
+ operationTimeoutMs?: number
56
+ /**
57
+ * Producer fencing epoch sent as `Producer-Epoch` on every append.
58
+ *
59
+ * A backend that fences producers rejects an append whose epoch is below the
60
+ * highest it has seen, so a zombie host that lost its claim cannot keep
61
+ * writing to a run a newer host took over. Callers that track a monotonic
62
+ * per-run driver epoch (`RunRecord.driverEpoch`) should pass it here; the
63
+ * default of `0` makes every producer look equally current to the backend,
64
+ * which leaves fencing entirely to the caller's own run claim.
65
+ */
66
+ producerEpoch?: number
67
+ }
68
+
69
+ /** Resolve after `ms`, or immediately once `signal` aborts. Never rejects. */
70
+ function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
71
+ if (ms <= 0 || signal?.aborted) return Promise.resolve()
72
+ return new Promise((resolve) => {
73
+ const onAbort = () => {
74
+ clearTimeout(timer)
75
+ resolve()
76
+ }
77
+ const timer = setTimeout(() => {
78
+ signal?.removeEventListener('abort', onAbort)
79
+ resolve()
80
+ }, ms)
81
+ signal?.addEventListener('abort', onAbort, { once: true })
82
+ })
83
+ }
84
+
85
+ export class DurableStreamError extends Error {
86
+ override name = 'DurableStreamError'
87
+
88
+ constructor(message: string) {
89
+ super(`durableStream: ${message}`)
90
+ }
91
+ }
92
+
93
+ interface SseEvent {
94
+ event?: string
95
+ data?: string
96
+ }
97
+
98
+ interface WireRecord {
99
+ v: 1
100
+ seq: number
101
+ chunk: StreamChunk
102
+ }
103
+
104
+ interface CursorPayload {
105
+ v: 1
106
+ backendOffset: string
107
+ seq: number
108
+ }
109
+
110
+ interface ControlFrame {
111
+ streamNextOffset: string
112
+ streamCursor?: string
113
+ upToDate?: boolean
114
+ streamClosed?: boolean
115
+ }
116
+
117
+ const CURSOR_PREFIX = 'tanstack-ai-ds:v1:'
118
+ const READ_ABORTED = Symbol('read aborted')
119
+
120
+ /**
121
+ * Hard ceiling on the SSE windows a single `snapshot` will pull before it gives
122
+ * up. A snapshot stops at the first control frame that reports the reader caught
123
+ * up (`upToDate`), so a conforming backend ends it in one or two windows. This
124
+ * only fires for a backend that keeps handing out advancing windows without ever
125
+ * reporting `upToDate`, where the alternative is a read that never returns.
126
+ */
127
+ const SNAPSHOT_MAX_WINDOWS = 1000
128
+
129
+ class ResponseBodyReadFailure extends Error {
130
+ override name = 'ResponseBodyReadFailure'
131
+
132
+ constructor(readonly readError: unknown) {
133
+ super('response body read failed')
134
+ }
135
+ }
136
+
137
+ function assertTransportField(value: string, name: string): string {
138
+ if (value.trim().length === 0 || /[\r\n]/.test(value)) {
139
+ throw new DurableStreamError(
140
+ `${name} must be non-empty and contain no CR/LF`,
141
+ )
142
+ }
143
+ return value
144
+ }
145
+
146
+ function assertRunId(value: string): string {
147
+ return assertTransportField(value, 'runId')
148
+ }
149
+
150
+ function isDurableStreamCursor(value: string): value is DurableStreamCursor {
151
+ return value.startsWith(CURSOR_PREFIX)
152
+ }
153
+
154
+ function isCursorPayload(value: unknown): value is CursorPayload {
155
+ return (
156
+ typeof value === 'object' &&
157
+ value !== null &&
158
+ 'v' in value &&
159
+ value.v === 1 &&
160
+ 'backendOffset' in value &&
161
+ typeof value.backendOffset === 'string' &&
162
+ 'seq' in value &&
163
+ typeof value.seq === 'number' &&
164
+ Number.isSafeInteger(value.seq) &&
165
+ value.seq > 0
166
+ )
167
+ }
168
+
169
+ function encodeCursor(payload: CursorPayload): DurableStreamCursor {
170
+ assertTransportField(payload.backendOffset, 'backend offset')
171
+ if (!Number.isSafeInteger(payload.seq) || payload.seq < 1) {
172
+ throw new DurableStreamError(`invalid record sequence: ${payload.seq}`)
173
+ }
174
+ const cursor = `${CURSOR_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`
175
+ if (!isDurableStreamCursor(cursor)) {
176
+ throw new DurableStreamError('failed to encode cursor')
177
+ }
178
+ return cursor
179
+ }
180
+
181
+ function decodeCursor(cursor: string): CursorPayload {
182
+ if (!isDurableStreamCursor(cursor)) {
183
+ throw new DurableStreamError('invalid or unsupported resume offset')
184
+ }
185
+ let parsed: unknown
186
+ try {
187
+ parsed = JSON.parse(decodeURIComponent(cursor.slice(CURSOR_PREFIX.length)))
188
+ } catch {
189
+ throw new DurableStreamError('invalid or unsupported resume offset')
190
+ }
191
+ if (!isCursorPayload(parsed)) {
192
+ throw new DurableStreamError('invalid or unsupported resume offset')
193
+ }
194
+ assertTransportField(parsed.backendOffset, 'backend offset')
195
+ return parsed
196
+ }
197
+
198
+ function safeSearchParam(request: Request, key: string): string | null {
199
+ try {
200
+ return new URL(request.url).searchParams.get(key)
201
+ } catch {
202
+ return null
203
+ }
204
+ }
205
+
206
+ function parseResumeOffset(raw: string | null): DurableStreamOffset | null {
207
+ if (raw === null || raw === '-1' || raw === 'now') return raw
208
+ decodeCursor(raw)
209
+ if (!isDurableStreamCursor(raw)) {
210
+ throw new DurableStreamError('invalid or unsupported resume offset')
211
+ }
212
+ return raw
213
+ }
214
+
215
+ async function* readLines(
216
+ body: ReadableStream<Uint8Array>,
217
+ signal?: AbortSignal,
218
+ ): AsyncGenerator<string> {
219
+ const reader = body.getReader()
220
+ const decoder = new TextDecoder()
221
+ let buffer = ''
222
+ let completed = false
223
+ let cancelled = false
224
+ let readFailed = false
225
+ try {
226
+ for (;;) {
227
+ let result: ReadableStreamReadResult<Uint8Array> | typeof READ_ABORTED
228
+ try {
229
+ result = await readWithAbort(reader, signal)
230
+ } catch (error) {
231
+ readFailed = true
232
+ throw new ResponseBodyReadFailure(error)
233
+ }
234
+ if (result === READ_ABORTED) {
235
+ cancelled = true
236
+ await reader.cancel(signal?.reason)
237
+ return
238
+ }
239
+ if (result.done) {
240
+ completed = true
241
+ break
242
+ }
243
+ buffer += decoder.decode(result.value, { stream: true })
244
+ const parts = buffer.split('\n')
245
+ buffer = parts.pop() ?? ''
246
+ for (const raw of parts) {
247
+ yield raw.endsWith('\r') ? raw.slice(0, -1) : raw
248
+ }
249
+ }
250
+ buffer += decoder.decode()
251
+ if (buffer.length > 0) {
252
+ yield buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer
253
+ }
254
+ } finally {
255
+ try {
256
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- readFailed is set in the catch before its throw; CFA can't see that from finally
257
+ if (!completed && !cancelled && !readFailed) await reader.cancel()
258
+ } finally {
259
+ reader.releaseLock()
260
+ }
261
+ }
262
+ }
263
+
264
+ function readWithAbort(
265
+ reader: ReadableStreamDefaultReader<Uint8Array>,
266
+ signal: AbortSignal | undefined,
267
+ ): Promise<ReadableStreamReadResult<Uint8Array> | typeof READ_ABORTED> {
268
+ if (!signal) return reader.read()
269
+ if (signal.aborted) return Promise.resolve(READ_ABORTED)
270
+
271
+ return new Promise((resolve, reject) => {
272
+ const onAbort = () => {
273
+ signal.removeEventListener('abort', onAbort)
274
+ resolve(READ_ABORTED)
275
+ }
276
+ signal.addEventListener('abort', onAbort, { once: true })
277
+ reader.read().then(
278
+ (result) => {
279
+ signal.removeEventListener('abort', onAbort)
280
+ resolve(result)
281
+ },
282
+ (error: unknown) => {
283
+ signal.removeEventListener('abort', onAbort)
284
+ reject(error)
285
+ },
286
+ )
287
+ })
288
+ }
289
+
290
+ async function* parseSseEvents(
291
+ body: ReadableStream<Uint8Array>,
292
+ signal?: AbortSignal,
293
+ ): AsyncGenerator<SseEvent> {
294
+ let current: SseEvent = {}
295
+ let hasField = false
296
+
297
+ for await (const line of readLines(body, signal)) {
298
+ if (line === '') {
299
+ if (hasField) yield current
300
+ current = {}
301
+ hasField = false
302
+ continue
303
+ }
304
+ if (line.startsWith(':')) continue
305
+
306
+ const colon = line.indexOf(':')
307
+ const field = colon === -1 ? line : line.slice(0, colon)
308
+ let value = colon === -1 ? '' : line.slice(colon + 1)
309
+ if (value.startsWith(' ')) value = value.slice(1)
310
+ if (field === 'event') {
311
+ current.event = value
312
+ hasField = true
313
+ } else if (field === 'data') {
314
+ current.data =
315
+ current.data === undefined ? value : `${current.data}\n${value}`
316
+ hasField = true
317
+ }
318
+ }
319
+ if (hasField) yield current
320
+ }
321
+
322
+ function isStreamChunk(value: unknown): value is StreamChunk {
323
+ return (
324
+ typeof value === 'object' &&
325
+ value !== null &&
326
+ 'type' in value &&
327
+ typeof value.type === 'string'
328
+ )
329
+ }
330
+
331
+ function isWireRecord(value: unknown): value is WireRecord {
332
+ return (
333
+ typeof value === 'object' &&
334
+ value !== null &&
335
+ 'v' in value &&
336
+ value.v === 1 &&
337
+ 'seq' in value &&
338
+ typeof value.seq === 'number' &&
339
+ Number.isSafeInteger(value.seq) &&
340
+ value.seq > 0 &&
341
+ 'chunk' in value &&
342
+ isStreamChunk(value.chunk)
343
+ )
344
+ }
345
+
346
+ function parseDataRecords(data: string | undefined): Array<WireRecord> {
347
+ if (data === undefined) {
348
+ throw new DurableStreamError('data event had no payload')
349
+ }
350
+ let parsed: unknown
351
+ try {
352
+ parsed = JSON.parse(data)
353
+ } catch {
354
+ throw new DurableStreamError('data event contained invalid JSON')
355
+ }
356
+ if (!Array.isArray(parsed)) {
357
+ throw new DurableStreamError('data event payload must be a JSON array')
358
+ }
359
+ const records: Array<WireRecord> = []
360
+ for (const value of parsed) {
361
+ if (!isWireRecord(value)) {
362
+ throw new DurableStreamError('data event contained an invalid record')
363
+ }
364
+ records.push(value)
365
+ }
366
+ return records
367
+ }
368
+
369
+ function optionalBoolean(
370
+ value: object,
371
+ name: 'upToDate' | 'streamClosed',
372
+ ): boolean | undefined {
373
+ const field =
374
+ name === 'upToDate'
375
+ ? 'upToDate' in value
376
+ ? value.upToDate
377
+ : undefined
378
+ : 'streamClosed' in value
379
+ ? value.streamClosed
380
+ : undefined
381
+ if (field === undefined) return undefined
382
+ if (typeof field !== 'boolean') {
383
+ throw new DurableStreamError(`control field ${name} must be boolean`)
384
+ }
385
+ return field
386
+ }
387
+
388
+ function parseControlFrame(data: string | undefined): ControlFrame {
389
+ if (data === undefined) {
390
+ throw new DurableStreamError('control event had no payload')
391
+ }
392
+ let parsed: unknown
393
+ try {
394
+ parsed = JSON.parse(data)
395
+ } catch {
396
+ throw new DurableStreamError('control event contained invalid JSON')
397
+ }
398
+ if (typeof parsed !== 'object' || parsed === null) {
399
+ throw new DurableStreamError('control event payload must be an object')
400
+ }
401
+ if (
402
+ !('streamNextOffset' in parsed) ||
403
+ typeof parsed.streamNextOffset !== 'string'
404
+ ) {
405
+ throw new DurableStreamError(
406
+ 'control event requires string streamNextOffset',
407
+ )
408
+ }
409
+ const streamNextOffset = assertTransportField(
410
+ parsed.streamNextOffset,
411
+ 'control streamNextOffset',
412
+ )
413
+ let streamCursor: string | undefined
414
+ if ('streamCursor' in parsed) {
415
+ if (typeof parsed.streamCursor !== 'string') {
416
+ throw new DurableStreamError('control streamCursor must be a string')
417
+ }
418
+ streamCursor = assertTransportField(
419
+ parsed.streamCursor,
420
+ 'control streamCursor',
421
+ )
422
+ }
423
+ const upToDate = optionalBoolean(parsed, 'upToDate')
424
+ const streamClosed = optionalBoolean(parsed, 'streamClosed')
425
+ if (streamClosed !== true && streamCursor === undefined) {
426
+ throw new DurableStreamError(
427
+ 'open control event requires string streamCursor',
428
+ )
429
+ }
430
+ return {
431
+ streamNextOffset,
432
+ ...(streamCursor === undefined ? {} : { streamCursor }),
433
+ ...(upToDate === undefined ? {} : { upToDate }),
434
+ ...(streamClosed === undefined ? {} : { streamClosed }),
435
+ }
436
+ }
437
+
438
+ function requireNextOffset(response: Response, operation: string): string {
439
+ const offset = response.headers.get('Stream-Next-Offset')
440
+ if (offset === null || offset.trim().length === 0) {
441
+ throw new DurableStreamError(
442
+ `${operation} response missing non-empty Stream-Next-Offset`,
443
+ )
444
+ }
445
+ return assertTransportField(offset, `${operation} Stream-Next-Offset`)
446
+ }
447
+
448
+ function httpFailure(
449
+ operation: string,
450
+ response: Response,
451
+ ): DurableStreamError {
452
+ return new DurableStreamError(
453
+ `failed to ${operation} (${response.status} ${response.statusText})`,
454
+ )
455
+ }
456
+
457
+ /**
458
+ * External-URL Durable Streams protocol adapter.
459
+ *
460
+ * `request` must name a run — `X-Run-Id` header (what a `@tanstack/ai-client`
461
+ * POST sends) or `?runId` (what a GET attach sends), resolved by core's
462
+ * `resolveResumeRunId`. A request that names neither throws rather than
463
+ * silently producing into an unaddressable stream.
464
+ *
465
+ * Returns a plain `StreamDurability`, not an `UpsertableStreamDurability`.
466
+ * This adapter's offsets embed a backend-assigned Next-Offset cursor, so a
467
+ * caller cannot choose them; there is no `upsert` implementation to supply.
468
+ * Omitting `upsert` is the type-level statement that this adapter does not
469
+ * support caller-supplied offsets, so a consumer requiring that capability
470
+ * gets a compile error at the wiring site instead of a runtime failure.
471
+ */
472
+ export function durableStream(
473
+ request: Request,
474
+ options: DurableStreamOptions,
475
+ ): StreamDurability<DurableStreamOffset> {
476
+ const fetchFn = options.fetch ?? globalThis.fetch
477
+ if (options.server === undefined && options.fetch === undefined) {
478
+ throw new DurableStreamError(
479
+ 'server is required unless a fetch implementation is provided',
480
+ )
481
+ }
482
+ // When a custom fetch routes by path (e.g. a service binding), the host is
483
+ // irrelevant; a reserved `.internal` base parses without ever resolving.
484
+ const rawServer = options.server ?? 'https://durable-streams.internal'
485
+ assertTransportField(rawServer, 'server URL')
486
+ try {
487
+ void new URL(rawServer)
488
+ } catch {
489
+ throw new DurableStreamError(
490
+ `invalid server URL: ${JSON.stringify(rawServer)}`,
491
+ )
492
+ }
493
+ const server = rawServer.replace(/\/+$/, '')
494
+ const maxReadFailures = options.reconnect?.maxReadFailures ?? 10
495
+ const readRetryDelayMs = options.reconnect?.delayMs ?? 250
496
+ const operationTimeoutMs = options.operationTimeoutMs ?? 30_000
497
+
498
+ // create / append / close go through this so a stalled backend can't hang the
499
+ // operation forever. Each call gets a fresh timeout; long-poll `read` calls
500
+ // deliberately do NOT use it (they may wait for the producer to advance).
501
+ const fetchWithTimeout = async (
502
+ url: string | URL,
503
+ init: RequestInit,
504
+ ): Promise<Response> => {
505
+ const controller = new AbortController()
506
+ const timer = setTimeout(() => {
507
+ controller.abort(
508
+ new DurableStreamError(
509
+ `request exceeded operationTimeoutMs (${operationTimeoutMs}ms)`,
510
+ ),
511
+ )
512
+ }, operationTimeoutMs)
513
+ try {
514
+ return await fetchFn(url, { ...init, signal: controller.signal })
515
+ } finally {
516
+ clearTimeout(timer)
517
+ }
518
+ }
519
+ const prefix = assertTransportField(
520
+ options.streamPrefix ?? 'runs',
521
+ 'streamPrefix',
522
+ )
523
+ const rawResumeOffset =
524
+ request.headers.get('Last-Event-ID') ?? safeSearchParam(request, 'offset')
525
+ const resumeOffset = parseResumeOffset(rawResumeOffset)
526
+ // Resolved through core's `resolveResumeRunId` — `X-Run-Id` header first,
527
+ // then `?runId` — the same single implementation `memoryStream` and the
528
+ // resume response helpers use, so no two durability adapters can disagree
529
+ // about which run a request names. It has to be the header first: a
530
+ // `@tanstack/ai-client` POST keeps its URL byte-identical to a plain chat
531
+ // request and carries the run id in `X-Run-Id`, so a query-only adapter
532
+ // would name a different stream than the GET attach route addresses.
533
+ const requestedRunId = resolveResumeRunId(request)
534
+ if (requestedRunId === null) {
535
+ // Never mint a random id here. The backend stream name is derived from the
536
+ // run id, so a generated one addresses a stream no attach request could
537
+ // ever name: the producer would appear to work while writing where nobody
538
+ // can read. Refuse up front instead, matching `DurableRunIdRequiredError`
539
+ // in `@tanstack/ai-sandbox`.
540
+ throw new DurableStreamError(
541
+ resumeOffset === null
542
+ ? 'a runId is required: send it as an X-Run-Id header or a ?runId query param'
543
+ : 'resume offset requires a runId',
544
+ )
545
+ }
546
+ const runId = assertRunId(requestedRunId)
547
+
548
+ const streamUrl = `${server}/streams/${encodeURIComponent(`${prefix}/${runId}`)}`
549
+ let createPromise: Promise<string> | undefined
550
+ // Set only when the create PUT answered `201 Created`, which RFC 9110 §9.3.4
551
+ // reserves for a PUT that brought the resource into existence: an existing
552
+ // stream is a replace/no-op and answers 200 or 204. A stream this instance
553
+ // created cannot already hold records, which is what makes the tail read below
554
+ // skippable. Defaulting to `false` keeps the conservative side: anything other
555
+ // than a proven creation still pays for the seeding read.
556
+ let createdHere = false
557
+ let appendTailOffset: string | undefined
558
+ // `seq` is a single per-run counter, not a per-instance one: the read side
559
+ // dedups and orders by it across every window and every producer. A takeover
560
+ // host therefore MUST continue the log's existing sequence instead of
561
+ // restarting at 1, or its records collide with the prefix an earlier host
562
+ // stored and get silently dropped by the reader's dedup. `seqSeeded` tracks
563
+ // whether this instance has learned where the log ends.
564
+ let nextSeq = 1
565
+ let seqSeeded = false
566
+ let seedPromise: Promise<void> | undefined
567
+ const producerId = crypto.randomUUID()
568
+ const producerEpoch = options.producerEpoch ?? 0
569
+ if (!Number.isSafeInteger(producerEpoch) || producerEpoch < 0) {
570
+ throw new DurableStreamError(
571
+ `producerEpoch must be a non-negative safe integer: ${producerEpoch}`,
572
+ )
573
+ }
574
+ let producerSeq = 0
575
+ let closePromise: Promise<void> | undefined
576
+
577
+ /**
578
+ * Raise the append counter past a sequence already present in the log.
579
+ *
580
+ * Called for every record any read observes — including records the reader
581
+ * then dedups away — so both `snapshot` (the alignment path a takeover
582
+ * already runs) and a plain `read` teach this instance the log's tail.
583
+ */
584
+ const observeSeq = (seq: number): void => {
585
+ if (seq >= nextSeq) nextSeq = seq + 1
586
+ }
587
+
588
+ const resolveHeaders = async (required?: HeadersInit): Promise<Headers> => {
589
+ const configured =
590
+ typeof options.headers === 'function'
591
+ ? await options.headers()
592
+ : options.headers
593
+ const headers = new Headers(configured)
594
+ if (required) {
595
+ new Headers(required).forEach((value, key) => headers.set(key, value))
596
+ }
597
+ return headers
598
+ }
599
+
600
+ const ensureCreated = (): Promise<string> => {
601
+ if (createPromise) return createPromise
602
+ createPromise = (async () => {
603
+ const response = await fetchWithTimeout(streamUrl, {
604
+ method: 'PUT',
605
+ headers: await resolveHeaders({ 'Content-Type': 'application/json' }),
606
+ })
607
+ if (!response.ok) throw httpFailure('create stream', response)
608
+ const offset = requireNextOffset(response, 'create')
609
+ createdHere = response.status === 201
610
+ appendTailOffset = offset
611
+ return offset
612
+ })().catch((error: unknown) => {
613
+ createPromise = undefined
614
+ throw error
615
+ })
616
+ return createPromise
617
+ }
618
+
619
+ /**
620
+ * The one window-pulling loop behind both `read` and `snapshot`.
621
+ *
622
+ * `stopWhenUpToDate` is the only difference between them. The protocol's
623
+ * control frame carries `upToDate: true` when the backend has handed the
624
+ * reader everything the stream currently holds; a live `read` ignores that and
625
+ * keeps long-polling for more, while a `snapshot` returns there. That makes a
626
+ * snapshot bounded even on a stream nobody ever closed.
627
+ */
628
+ const readWindows = async function* (
629
+ offset: DurableStreamOffset,
630
+ signal: AbortSignal | undefined,
631
+ stopWhenUpToDate: boolean,
632
+ ): AsyncGenerator<{ offset: DurableStreamOffset; chunk: StreamChunk }> {
633
+ let backendOffset: string
634
+ let deliveredThroughSeq = 0
635
+ if (offset === '-1' || offset === 'now') {
636
+ backendOffset = offset
637
+ } else {
638
+ const cursor = decodeCursor(offset)
639
+ backendOffset = cursor.backendOffset
640
+ deliveredThroughSeq = cursor.seq
641
+ }
642
+ let streamCursor: string | undefined
643
+ let consecutiveReadFailures = 0
644
+ let windowsPulled = 0
645
+
646
+ for (;;) {
647
+ if (signal?.aborted) return
648
+ windowsPulled += 1
649
+ if (stopWhenUpToDate && windowsPulled > SNAPSHOT_MAX_WINDOWS) {
650
+ throw new DurableStreamError(
651
+ `snapshot read ${SNAPSHOT_MAX_WINDOWS} windows without the backend reporting upToDate`,
652
+ )
653
+ }
654
+ const requestOffset = backendOffset
655
+ const requestCursor = streamCursor
656
+ const url = new URL(streamUrl)
657
+ url.searchParams.set('offset', backendOffset)
658
+ url.searchParams.set('live', 'sse')
659
+ if (streamCursor !== undefined) {
660
+ url.searchParams.set('cursor', streamCursor)
661
+ }
662
+
663
+ let response: Response
664
+ try {
665
+ response = await fetchFn(url, {
666
+ method: 'GET',
667
+ headers: await resolveHeaders(),
668
+ signal,
669
+ })
670
+ } catch (error) {
671
+ if (signal?.aborted) return
672
+ throw error
673
+ }
674
+ if (!response.ok) throw httpFailure('read', response)
675
+ if (!response.body) {
676
+ throw new DurableStreamError('read response had no body')
677
+ }
678
+
679
+ let dataStartOffset = backendOffset
680
+ let sawControl = false
681
+ let dataAwaitingControl = false
682
+ let yieldedData = false
683
+ // Guards intra-response ordering: seqs must strictly increase across the
684
+ // whole response (including across data frames and control frames — seq
685
+ // is per-run, not per-window). Starts at 0 so a legitimate replay of
686
+ // already-delivered records still passes, then the dedup below drops
687
+ // them; the throw catches a genuinely malformed [seq 2, seq 1] or a
688
+ // duplicate seq that would otherwise be silently discarded.
689
+ let previousResponseSeq = 0
690
+ try {
691
+ for await (const event of parseSseEvents(response.body, signal)) {
692
+ if (signal?.aborted) return
693
+ if (event.event === 'data') {
694
+ dataAwaitingControl = true
695
+ for (const record of parseDataRecords(event.data)) {
696
+ if (record.seq <= previousResponseSeq) {
697
+ throw new DurableStreamError(
698
+ 'data records must have strictly increasing sequences',
699
+ )
700
+ }
701
+ previousResponseSeq = record.seq
702
+ observeSeq(record.seq)
703
+ if (record.seq <= deliveredThroughSeq) continue
704
+ deliveredThroughSeq = record.seq
705
+ yieldedData = true
706
+ yield {
707
+ offset: encodeCursor({
708
+ v: 1,
709
+ backendOffset: dataStartOffset,
710
+ seq: record.seq,
711
+ }),
712
+ chunk: record.chunk,
713
+ }
714
+ }
715
+ continue
716
+ }
717
+ if (event.event === 'control') {
718
+ const control = parseControlFrame(event.data)
719
+ backendOffset = control.streamNextOffset
720
+ streamCursor = control.streamCursor
721
+ dataStartOffset = backendOffset
722
+ sawControl = true
723
+ dataAwaitingControl = false
724
+ if (control.streamClosed === true) return
725
+ // A snapshot has now been handed everything the backend holds.
726
+ // Returning here abandons the rest of the SSE body, which the
727
+ // reader's `finally` cancels.
728
+ if (stopWhenUpToDate && control.upToDate === true) return
729
+ continue
730
+ }
731
+ throw new DurableStreamError(
732
+ `unexpected SSE event type: ${JSON.stringify(event.event)}`,
733
+ )
734
+ }
735
+ } catch (error) {
736
+ if (signal?.aborted) return
737
+ if (error instanceof ResponseBodyReadFailure) {
738
+ if (
739
+ yieldedData ||
740
+ (sawControl &&
741
+ (backendOffset !== requestOffset ||
742
+ streamCursor !== requestCursor))
743
+ ) {
744
+ // Made progress before the body failed — retry from the last valid
745
+ // position, but cap consecutive failures and throttle so a
746
+ // persistently failing backend surfaces the error, not a hot loop.
747
+ consecutiveReadFailures += 1
748
+ if (consecutiveReadFailures > maxReadFailures) throw error.readError
749
+ await abortableDelay(readRetryDelayMs, signal)
750
+ continue
751
+ }
752
+ throw error.readError
753
+ }
754
+ throw error
755
+ }
756
+
757
+ // A window read to completion (no body failure) clears the streak; only
758
+ // consecutive failures accumulate toward the ceiling.
759
+ consecutiveReadFailures = 0
760
+
761
+ if (signal?.aborted) return
762
+ if (dataAwaitingControl || !sawControl) {
763
+ throw new DurableStreamError(
764
+ 'read SSE window ended without a matching control event',
765
+ )
766
+ }
767
+ if (backendOffset === requestOffset && streamCursor === requestCursor) {
768
+ throw new DurableStreamError(
769
+ 'read SSE window ended without advancing offset or cursor',
770
+ )
771
+ }
772
+ }
773
+ }
774
+
775
+ /**
776
+ * One bounded pass over everything the log currently holds.
777
+ *
778
+ * Two things bound it. `readWindows(..., stopWhenUpToDate=true)` returns at
779
+ * the first control frame reporting the reader caught up, and
780
+ * `SNAPSHOT_MAX_WINDOWS` catches a backend that keeps handing out advancing
781
+ * windows without ever saying so. Neither covers a backend that simply never
782
+ * answers — `upToDate` is an optional protocol field, read windows
783
+ * deliberately skip `fetchWithTimeout` (a caught-up live reader may wait),
784
+ * and an empty still-open log has nothing to send. That shape would park the
785
+ * fetch forever, so the snapshot carries its own `operationTimeoutMs`
786
+ * deadline. Timing out is a loud failure, never a truncated result: an
787
+ * aborted `readWindows` ends its iteration quietly, so the flag is rechecked
788
+ * after the loop and thrown.
789
+ *
790
+ * `ensureCreated()` first, exactly as `append` and `read` do. A snapshot of a
791
+ * stream the backend does not hold yet must answer "nothing has been
792
+ * delivered", not reject: `sandboxRunDriver`'s `pipe` calls
793
+ * `awaitLogQuiescence` — two `snapshot()` reads — BEFORE the first append, so
794
+ * the very first producer of every durable run snapshots a stream no `PUT` has
795
+ * created. Reading straight through would surface that as
796
+ * `httpFailure('read', ...)` and fail the run at its first chunk. It also keeps
797
+ * this adapter's contract identical to core's `memoryStream`, which resolves to
798
+ * `[]` for an unknown run; two `StreamDurability` implementations must not
799
+ * disagree about so basic a case. `ensureCreated` is idempotent and memoised,
800
+ * so this costs nothing once the stream exists.
801
+ */
802
+ const collectSnapshot = async (): Promise<
803
+ Array<{ offset: DurableStreamOffset; chunk: StreamChunk }>
804
+ > => {
805
+ await ensureCreated()
806
+ const controller = new AbortController()
807
+ let timedOut = false
808
+ const timer = setTimeout(() => {
809
+ timedOut = true
810
+ controller.abort(
811
+ new DurableStreamError(
812
+ `snapshot exceeded operationTimeoutMs (${operationTimeoutMs}ms)`,
813
+ ),
814
+ )
815
+ }, operationTimeoutMs)
816
+ try {
817
+ const entries: Array<{
818
+ offset: DurableStreamOffset
819
+ chunk: StreamChunk
820
+ }> = []
821
+ for await (const entry of readWindows('-1', controller.signal, true)) {
822
+ entries.push(entry)
823
+ }
824
+ if (timedOut) {
825
+ throw new DurableStreamError(
826
+ `snapshot exceeded operationTimeoutMs (${operationTimeoutMs}ms) before the backend reported upToDate`,
827
+ )
828
+ }
829
+ // A completed snapshot has seen every record stored, so `observeSeq` has
830
+ // already moved `nextSeq` past the log's tail.
831
+ seqSeeded = true
832
+ return entries
833
+ } finally {
834
+ clearTimeout(timer)
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Learn where the log ends before this instance appends to it for the first
840
+ * time.
841
+ *
842
+ * A takeover host is handed a fresh adapter for a run whose log already holds
843
+ * `seq 1..N`, and nothing in the protocol reports a record count, so the tail
844
+ * has to be read. One bounded read per instance, and the alignment `snapshot()`
845
+ * a takeover already performs satisfies it.
846
+ *
847
+ * A brand-new run pays nothing, and must not: the seeding read cannot be on the
848
+ * producer's critical path. `upToDate` is an optional protocol field and an
849
+ * empty still-open log has nothing to send, so on a backend that omits it the
850
+ * read has to run its `operationTimeoutMs` deadline out and then fail — the
851
+ * producer would wait on a reader that is waiting on the producer, and every
852
+ * fresh run on such a backend would die of a synthetic error. `createdHere`
853
+ * settles it without a request: a stream this instance brought into existence
854
+ * provably holds no records, so `nextSeq` is already correct at 1.
855
+ */
856
+ const ensureSeqSeeded = (): Promise<void> => {
857
+ if (seqSeeded) return Promise.resolve()
858
+ if (seedPromise) return seedPromise
859
+ seedPromise = (async () => {
860
+ await ensureCreated()
861
+ if (createdHere) {
862
+ seqSeeded = true
863
+ return
864
+ }
865
+ await collectSnapshot()
866
+ })().catch((error: unknown) => {
867
+ seedPromise = undefined
868
+ throw error
869
+ })
870
+ return seedPromise
871
+ }
872
+
873
+ return {
874
+ resumeFrom: () => resumeOffset,
875
+ append: async (chunks) => {
876
+ if (chunks.length === 0) return []
877
+ await ensureSeqSeeded()
878
+ const batchStartOffset = appendTailOffset ?? (await ensureCreated())
879
+ const firstSeq = nextSeq
880
+ const records = chunks.map(
881
+ (chunk, index): WireRecord => ({
882
+ v: 1,
883
+ seq: firstSeq + index,
884
+ chunk,
885
+ }),
886
+ )
887
+ // Through `observeSeq`, so a concurrent read that already pushed the
888
+ // counter further cannot be walked backwards.
889
+ observeSeq(firstSeq + records.length - 1)
890
+ const requestProducerSeq = producerSeq
891
+ producerSeq += 1
892
+ const requestInit: RequestInit = {
893
+ method: 'POST',
894
+ headers: await resolveHeaders({
895
+ 'Content-Type': 'application/json',
896
+ 'Producer-Id': producerId,
897
+ 'Producer-Epoch': String(producerEpoch),
898
+ 'Producer-Seq': String(requestProducerSeq),
899
+ }),
900
+ body: JSON.stringify(records),
901
+ }
902
+ let response: Response
903
+ try {
904
+ response = await fetchWithTimeout(streamUrl, requestInit)
905
+ } catch (firstError) {
906
+ try {
907
+ response = await fetchWithTimeout(streamUrl, requestInit)
908
+ } catch (retryError) {
909
+ throw new AggregateError(
910
+ [firstError, retryError],
911
+ 'durableStream: append failed before its outcome could be confirmed',
912
+ )
913
+ }
914
+ }
915
+ if (!response.ok) throw httpFailure('append', response)
916
+ const nextOffset = requireNextOffset(response, 'append')
917
+ appendTailOffset = nextOffset
918
+ return records.map((record) =>
919
+ encodeCursor({
920
+ v: 1,
921
+ backendOffset: batchStartOffset,
922
+ seq: record.seq,
923
+ }),
924
+ )
925
+ },
926
+ close: () => {
927
+ if (closePromise) return closePromise
928
+ closePromise = (async () => {
929
+ await ensureCreated()
930
+ const response = await fetchWithTimeout(streamUrl, {
931
+ method: 'POST',
932
+ headers: await resolveHeaders({ 'Stream-Closed': 'true' }),
933
+ })
934
+ if (!response.ok) throw httpFailure('close', response)
935
+ const nextOffset = requireNextOffset(response, 'close')
936
+ if (response.headers.get('Stream-Closed')?.toLowerCase() !== 'true') {
937
+ throw new DurableStreamError(
938
+ 'close response missing Stream-Closed: true',
939
+ )
940
+ }
941
+ appendTailOffset = nextOffset
942
+ })().catch((error: unknown) => {
943
+ closePromise = undefined
944
+ throw error
945
+ })
946
+ return closePromise
947
+ },
948
+ read: (offset, signal) => readWindows(offset, signal, false),
949
+ snapshot: () => collectSnapshot(),
950
+ }
951
+ }