@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.
- package/dist/esm/durable-stream.d.ts +83 -0
- package/dist/esm/durable-stream.js +577 -0
- package/dist/esm/durable-stream.js.map +1 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.js +2 -0
- package/package.json +54 -0
- package/src/durable-stream.ts +951 -0
- package/src/index.ts +10 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { StreamDurability } from '@tanstack/ai';
|
|
2
|
+
declare const durableStreamCursorBrand: unique symbol;
|
|
3
|
+
/** A validated, versioned offset produced by this adapter. */
|
|
4
|
+
type DurableStreamCursor = string & {
|
|
5
|
+
readonly [durableStreamCursorBrand]: true;
|
|
6
|
+
};
|
|
7
|
+
/** Adapter offsets also include the Durable Streams protocol sentinels. */
|
|
8
|
+
export type DurableStreamOffset = DurableStreamCursor | '-1' | 'now';
|
|
9
|
+
export interface DurableStreamOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Base URL of the Durable Streams server (no trailing slash needed).
|
|
12
|
+
* Optional when `fetch` is supplied — e.g. a Cloudflare service binding that
|
|
13
|
+
* ignores the host and dispatches to the bound Worker by path — in which case
|
|
14
|
+
* an internal placeholder base is used and only the `/streams/...` path
|
|
15
|
+
* matters.
|
|
16
|
+
*/
|
|
17
|
+
server?: string;
|
|
18
|
+
/** Stream-name prefix. Defaults to `runs`. */
|
|
19
|
+
streamPrefix?: string;
|
|
20
|
+
/** Fetch implementation. Defaults to the global fetch. */
|
|
21
|
+
fetch?: typeof globalThis.fetch;
|
|
22
|
+
/**
|
|
23
|
+
* Headers applied to every create, append, read, and close request. A
|
|
24
|
+
* resolver is called for every request so credentials can rotate.
|
|
25
|
+
*/
|
|
26
|
+
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
27
|
+
/**
|
|
28
|
+
* Bounding for the read reconnect loop. After a response-body read failure
|
|
29
|
+
* mid-window, `read` retries from the last valid position; these cap
|
|
30
|
+
* consecutive retries and throttle them so a persistently failing backend
|
|
31
|
+
* surfaces the error instead of looping without end. Normal window
|
|
32
|
+
* advancement (long-poll) is never throttled.
|
|
33
|
+
*/
|
|
34
|
+
reconnect?: {
|
|
35
|
+
/**
|
|
36
|
+
* Consecutive body-read-failure retries before surfacing the underlying
|
|
37
|
+
* read error. Default 10.
|
|
38
|
+
*/
|
|
39
|
+
maxReadFailures?: number;
|
|
40
|
+
/** Delay between read retries, in ms. Default 250. */
|
|
41
|
+
delayMs?: number;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Timeout (ms) for a single create / append / close request to the backend.
|
|
45
|
+
* A stalled backend would otherwise hang chunk delivery or terminalization
|
|
46
|
+
* indefinitely. Default 30000. Long-poll `read` window advancement is NOT
|
|
47
|
+
* bounded by this — a caught-up reader may legitimately wait. `snapshot`,
|
|
48
|
+
* which must always return, IS bounded by it.
|
|
49
|
+
*/
|
|
50
|
+
operationTimeoutMs?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Producer fencing epoch sent as `Producer-Epoch` on every append.
|
|
53
|
+
*
|
|
54
|
+
* A backend that fences producers rejects an append whose epoch is below the
|
|
55
|
+
* highest it has seen, so a zombie host that lost its claim cannot keep
|
|
56
|
+
* writing to a run a newer host took over. Callers that track a monotonic
|
|
57
|
+
* per-run driver epoch (`RunRecord.driverEpoch`) should pass it here; the
|
|
58
|
+
* default of `0` makes every producer look equally current to the backend,
|
|
59
|
+
* which leaves fencing entirely to the caller's own run claim.
|
|
60
|
+
*/
|
|
61
|
+
producerEpoch?: number;
|
|
62
|
+
}
|
|
63
|
+
export declare class DurableStreamError extends Error {
|
|
64
|
+
name: string;
|
|
65
|
+
constructor(message: string);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* External-URL Durable Streams protocol adapter.
|
|
69
|
+
*
|
|
70
|
+
* `request` must name a run — `X-Run-Id` header (what a `@tanstack/ai-client`
|
|
71
|
+
* POST sends) or `?runId` (what a GET attach sends), resolved by core's
|
|
72
|
+
* `resolveResumeRunId`. A request that names neither throws rather than
|
|
73
|
+
* silently producing into an unaddressable stream.
|
|
74
|
+
*
|
|
75
|
+
* Returns a plain `StreamDurability`, not an `UpsertableStreamDurability`.
|
|
76
|
+
* This adapter's offsets embed a backend-assigned Next-Offset cursor, so a
|
|
77
|
+
* caller cannot choose them; there is no `upsert` implementation to supply.
|
|
78
|
+
* Omitting `upsert` is the type-level statement that this adapter does not
|
|
79
|
+
* support caller-supplied offsets, so a consumer requiring that capability
|
|
80
|
+
* gets a compile error at the wiring site instead of a runtime failure.
|
|
81
|
+
*/
|
|
82
|
+
export declare function durableStream(request: Request, options: DurableStreamOptions): StreamDurability<DurableStreamOffset>;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { resolveResumeRunId } from "@tanstack/ai";
|
|
2
|
+
//#region src/durable-stream.ts
|
|
3
|
+
/** Resolve after `ms`, or immediately once `signal` aborts. Never rejects. */
|
|
4
|
+
function abortableDelay(ms, signal) {
|
|
5
|
+
if (ms <= 0 || signal?.aborted) return Promise.resolve();
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const onAbort = () => {
|
|
8
|
+
clearTimeout(timer);
|
|
9
|
+
resolve();
|
|
10
|
+
};
|
|
11
|
+
const timer = setTimeout(() => {
|
|
12
|
+
signal?.removeEventListener("abort", onAbort);
|
|
13
|
+
resolve();
|
|
14
|
+
}, ms);
|
|
15
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
var DurableStreamError = class extends Error {
|
|
19
|
+
name = "DurableStreamError";
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(`durableStream: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var CURSOR_PREFIX = "tanstack-ai-ds:v1:";
|
|
25
|
+
var READ_ABORTED = Symbol("read aborted");
|
|
26
|
+
/**
|
|
27
|
+
* Hard ceiling on the SSE windows a single `snapshot` will pull before it gives
|
|
28
|
+
* up. A snapshot stops at the first control frame that reports the reader caught
|
|
29
|
+
* up (`upToDate`), so a conforming backend ends it in one or two windows. This
|
|
30
|
+
* only fires for a backend that keeps handing out advancing windows without ever
|
|
31
|
+
* reporting `upToDate`, where the alternative is a read that never returns.
|
|
32
|
+
*/
|
|
33
|
+
var SNAPSHOT_MAX_WINDOWS = 1e3;
|
|
34
|
+
var ResponseBodyReadFailure = class extends Error {
|
|
35
|
+
readError;
|
|
36
|
+
name = "ResponseBodyReadFailure";
|
|
37
|
+
constructor(readError) {
|
|
38
|
+
super("response body read failed");
|
|
39
|
+
this.readError = readError;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
function assertTransportField(value, name) {
|
|
43
|
+
if (value.trim().length === 0 || /[\r\n]/.test(value)) throw new DurableStreamError(`${name} must be non-empty and contain no CR/LF`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function assertRunId(value) {
|
|
47
|
+
return assertTransportField(value, "runId");
|
|
48
|
+
}
|
|
49
|
+
function isDurableStreamCursor(value) {
|
|
50
|
+
return value.startsWith(CURSOR_PREFIX);
|
|
51
|
+
}
|
|
52
|
+
function isCursorPayload(value) {
|
|
53
|
+
return typeof value === "object" && value !== null && "v" in value && value.v === 1 && "backendOffset" in value && typeof value.backendOffset === "string" && "seq" in value && typeof value.seq === "number" && Number.isSafeInteger(value.seq) && value.seq > 0;
|
|
54
|
+
}
|
|
55
|
+
function encodeCursor(payload) {
|
|
56
|
+
assertTransportField(payload.backendOffset, "backend offset");
|
|
57
|
+
if (!Number.isSafeInteger(payload.seq) || payload.seq < 1) throw new DurableStreamError(`invalid record sequence: ${payload.seq}`);
|
|
58
|
+
const cursor = `${CURSOR_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`;
|
|
59
|
+
if (!isDurableStreamCursor(cursor)) throw new DurableStreamError("failed to encode cursor");
|
|
60
|
+
return cursor;
|
|
61
|
+
}
|
|
62
|
+
function decodeCursor(cursor) {
|
|
63
|
+
if (!isDurableStreamCursor(cursor)) throw new DurableStreamError("invalid or unsupported resume offset");
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(decodeURIComponent(cursor.slice(18)));
|
|
67
|
+
} catch {
|
|
68
|
+
throw new DurableStreamError("invalid or unsupported resume offset");
|
|
69
|
+
}
|
|
70
|
+
if (!isCursorPayload(parsed)) throw new DurableStreamError("invalid or unsupported resume offset");
|
|
71
|
+
assertTransportField(parsed.backendOffset, "backend offset");
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
function safeSearchParam(request, key) {
|
|
75
|
+
try {
|
|
76
|
+
return new URL(request.url).searchParams.get(key);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function parseResumeOffset(raw) {
|
|
82
|
+
if (raw === null || raw === "-1" || raw === "now") return raw;
|
|
83
|
+
decodeCursor(raw);
|
|
84
|
+
if (!isDurableStreamCursor(raw)) throw new DurableStreamError("invalid or unsupported resume offset");
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
async function* readLines(body, signal) {
|
|
88
|
+
const reader = body.getReader();
|
|
89
|
+
const decoder = new TextDecoder();
|
|
90
|
+
let buffer = "";
|
|
91
|
+
let completed = false;
|
|
92
|
+
let cancelled = false;
|
|
93
|
+
let readFailed = false;
|
|
94
|
+
try {
|
|
95
|
+
for (;;) {
|
|
96
|
+
let result;
|
|
97
|
+
try {
|
|
98
|
+
result = await readWithAbort(reader, signal);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
readFailed = true;
|
|
101
|
+
throw new ResponseBodyReadFailure(error);
|
|
102
|
+
}
|
|
103
|
+
if (result === READ_ABORTED) {
|
|
104
|
+
cancelled = true;
|
|
105
|
+
await reader.cancel(signal?.reason);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (result.done) {
|
|
109
|
+
completed = true;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
buffer += decoder.decode(result.value, { stream: true });
|
|
113
|
+
const parts = buffer.split("\n");
|
|
114
|
+
buffer = parts.pop() ?? "";
|
|
115
|
+
for (const raw of parts) yield raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
116
|
+
}
|
|
117
|
+
buffer += decoder.decode();
|
|
118
|
+
if (buffer.length > 0) yield buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
|
119
|
+
} finally {
|
|
120
|
+
try {
|
|
121
|
+
if (!completed && !cancelled && !readFailed) await reader.cancel();
|
|
122
|
+
} finally {
|
|
123
|
+
reader.releaseLock();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function readWithAbort(reader, signal) {
|
|
128
|
+
if (!signal) return reader.read();
|
|
129
|
+
if (signal.aborted) return Promise.resolve(READ_ABORTED);
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const onAbort = () => {
|
|
132
|
+
signal.removeEventListener("abort", onAbort);
|
|
133
|
+
resolve(READ_ABORTED);
|
|
134
|
+
};
|
|
135
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
136
|
+
reader.read().then((result) => {
|
|
137
|
+
signal.removeEventListener("abort", onAbort);
|
|
138
|
+
resolve(result);
|
|
139
|
+
}, (error) => {
|
|
140
|
+
signal.removeEventListener("abort", onAbort);
|
|
141
|
+
reject(error);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async function* parseSseEvents(body, signal) {
|
|
146
|
+
let current = {};
|
|
147
|
+
let hasField = false;
|
|
148
|
+
for await (const line of readLines(body, signal)) {
|
|
149
|
+
if (line === "") {
|
|
150
|
+
if (hasField) yield current;
|
|
151
|
+
current = {};
|
|
152
|
+
hasField = false;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (line.startsWith(":")) continue;
|
|
156
|
+
const colon = line.indexOf(":");
|
|
157
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
158
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
159
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
160
|
+
if (field === "event") {
|
|
161
|
+
current.event = value;
|
|
162
|
+
hasField = true;
|
|
163
|
+
} else if (field === "data") {
|
|
164
|
+
current.data = current.data === void 0 ? value : `${current.data}\n${value}`;
|
|
165
|
+
hasField = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (hasField) yield current;
|
|
169
|
+
}
|
|
170
|
+
function isStreamChunk(value) {
|
|
171
|
+
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
172
|
+
}
|
|
173
|
+
function isWireRecord(value) {
|
|
174
|
+
return typeof value === "object" && value !== null && "v" in value && value.v === 1 && "seq" in value && typeof value.seq === "number" && Number.isSafeInteger(value.seq) && value.seq > 0 && "chunk" in value && isStreamChunk(value.chunk);
|
|
175
|
+
}
|
|
176
|
+
function parseDataRecords(data) {
|
|
177
|
+
if (data === void 0) throw new DurableStreamError("data event had no payload");
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(data);
|
|
181
|
+
} catch {
|
|
182
|
+
throw new DurableStreamError("data event contained invalid JSON");
|
|
183
|
+
}
|
|
184
|
+
if (!Array.isArray(parsed)) throw new DurableStreamError("data event payload must be a JSON array");
|
|
185
|
+
const records = [];
|
|
186
|
+
for (const value of parsed) {
|
|
187
|
+
if (!isWireRecord(value)) throw new DurableStreamError("data event contained an invalid record");
|
|
188
|
+
records.push(value);
|
|
189
|
+
}
|
|
190
|
+
return records;
|
|
191
|
+
}
|
|
192
|
+
function optionalBoolean(value, name) {
|
|
193
|
+
const field = name === "upToDate" ? "upToDate" in value ? value.upToDate : void 0 : "streamClosed" in value ? value.streamClosed : void 0;
|
|
194
|
+
if (field === void 0) return void 0;
|
|
195
|
+
if (typeof field !== "boolean") throw new DurableStreamError(`control field ${name} must be boolean`);
|
|
196
|
+
return field;
|
|
197
|
+
}
|
|
198
|
+
function parseControlFrame(data) {
|
|
199
|
+
if (data === void 0) throw new DurableStreamError("control event had no payload");
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = JSON.parse(data);
|
|
203
|
+
} catch {
|
|
204
|
+
throw new DurableStreamError("control event contained invalid JSON");
|
|
205
|
+
}
|
|
206
|
+
if (typeof parsed !== "object" || parsed === null) throw new DurableStreamError("control event payload must be an object");
|
|
207
|
+
if (!("streamNextOffset" in parsed) || typeof parsed.streamNextOffset !== "string") throw new DurableStreamError("control event requires string streamNextOffset");
|
|
208
|
+
const streamNextOffset = assertTransportField(parsed.streamNextOffset, "control streamNextOffset");
|
|
209
|
+
let streamCursor;
|
|
210
|
+
if ("streamCursor" in parsed) {
|
|
211
|
+
if (typeof parsed.streamCursor !== "string") throw new DurableStreamError("control streamCursor must be a string");
|
|
212
|
+
streamCursor = assertTransportField(parsed.streamCursor, "control streamCursor");
|
|
213
|
+
}
|
|
214
|
+
const upToDate = optionalBoolean(parsed, "upToDate");
|
|
215
|
+
const streamClosed = optionalBoolean(parsed, "streamClosed");
|
|
216
|
+
if (streamClosed !== true && streamCursor === void 0) throw new DurableStreamError("open control event requires string streamCursor");
|
|
217
|
+
return {
|
|
218
|
+
streamNextOffset,
|
|
219
|
+
...streamCursor === void 0 ? {} : { streamCursor },
|
|
220
|
+
...upToDate === void 0 ? {} : { upToDate },
|
|
221
|
+
...streamClosed === void 0 ? {} : { streamClosed }
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function requireNextOffset(response, operation) {
|
|
225
|
+
const offset = response.headers.get("Stream-Next-Offset");
|
|
226
|
+
if (offset === null || offset.trim().length === 0) throw new DurableStreamError(`${operation} response missing non-empty Stream-Next-Offset`);
|
|
227
|
+
return assertTransportField(offset, `${operation} Stream-Next-Offset`);
|
|
228
|
+
}
|
|
229
|
+
function httpFailure(operation, response) {
|
|
230
|
+
return new DurableStreamError(`failed to ${operation} (${response.status} ${response.statusText})`);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* External-URL Durable Streams protocol adapter.
|
|
234
|
+
*
|
|
235
|
+
* `request` must name a run — `X-Run-Id` header (what a `@tanstack/ai-client`
|
|
236
|
+
* POST sends) or `?runId` (what a GET attach sends), resolved by core's
|
|
237
|
+
* `resolveResumeRunId`. A request that names neither throws rather than
|
|
238
|
+
* silently producing into an unaddressable stream.
|
|
239
|
+
*
|
|
240
|
+
* Returns a plain `StreamDurability`, not an `UpsertableStreamDurability`.
|
|
241
|
+
* This adapter's offsets embed a backend-assigned Next-Offset cursor, so a
|
|
242
|
+
* caller cannot choose them; there is no `upsert` implementation to supply.
|
|
243
|
+
* Omitting `upsert` is the type-level statement that this adapter does not
|
|
244
|
+
* support caller-supplied offsets, so a consumer requiring that capability
|
|
245
|
+
* gets a compile error at the wiring site instead of a runtime failure.
|
|
246
|
+
*/
|
|
247
|
+
function durableStream(request, options) {
|
|
248
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
249
|
+
if (options.server === void 0 && options.fetch === void 0) throw new DurableStreamError("server is required unless a fetch implementation is provided");
|
|
250
|
+
const rawServer = options.server ?? "https://durable-streams.internal";
|
|
251
|
+
assertTransportField(rawServer, "server URL");
|
|
252
|
+
try {
|
|
253
|
+
new URL(rawServer);
|
|
254
|
+
} catch {
|
|
255
|
+
throw new DurableStreamError(`invalid server URL: ${JSON.stringify(rawServer)}`);
|
|
256
|
+
}
|
|
257
|
+
const server = rawServer.replace(/\/+$/, "");
|
|
258
|
+
const maxReadFailures = options.reconnect?.maxReadFailures ?? 10;
|
|
259
|
+
const readRetryDelayMs = options.reconnect?.delayMs ?? 250;
|
|
260
|
+
const operationTimeoutMs = options.operationTimeoutMs ?? 3e4;
|
|
261
|
+
const fetchWithTimeout = async (url, init) => {
|
|
262
|
+
const controller = new AbortController();
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
controller.abort(new DurableStreamError(`request exceeded operationTimeoutMs (${operationTimeoutMs}ms)`));
|
|
265
|
+
}, operationTimeoutMs);
|
|
266
|
+
try {
|
|
267
|
+
return await fetchFn(url, {
|
|
268
|
+
...init,
|
|
269
|
+
signal: controller.signal
|
|
270
|
+
});
|
|
271
|
+
} finally {
|
|
272
|
+
clearTimeout(timer);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const prefix = assertTransportField(options.streamPrefix ?? "runs", "streamPrefix");
|
|
276
|
+
const resumeOffset = parseResumeOffset(request.headers.get("Last-Event-ID") ?? safeSearchParam(request, "offset"));
|
|
277
|
+
const requestedRunId = resolveResumeRunId(request);
|
|
278
|
+
if (requestedRunId === null) throw new DurableStreamError(resumeOffset === null ? "a runId is required: send it as an X-Run-Id header or a ?runId query param" : "resume offset requires a runId");
|
|
279
|
+
const runId = assertRunId(requestedRunId);
|
|
280
|
+
const streamUrl = `${server}/streams/${encodeURIComponent(`${prefix}/${runId}`)}`;
|
|
281
|
+
let createPromise;
|
|
282
|
+
let createdHere = false;
|
|
283
|
+
let appendTailOffset;
|
|
284
|
+
let nextSeq = 1;
|
|
285
|
+
let seqSeeded = false;
|
|
286
|
+
let seedPromise;
|
|
287
|
+
const producerId = crypto.randomUUID();
|
|
288
|
+
const producerEpoch = options.producerEpoch ?? 0;
|
|
289
|
+
if (!Number.isSafeInteger(producerEpoch) || producerEpoch < 0) throw new DurableStreamError(`producerEpoch must be a non-negative safe integer: ${producerEpoch}`);
|
|
290
|
+
let producerSeq = 0;
|
|
291
|
+
let closePromise;
|
|
292
|
+
/**
|
|
293
|
+
* Raise the append counter past a sequence already present in the log.
|
|
294
|
+
*
|
|
295
|
+
* Called for every record any read observes — including records the reader
|
|
296
|
+
* then dedups away — so both `snapshot` (the alignment path a takeover
|
|
297
|
+
* already runs) and a plain `read` teach this instance the log's tail.
|
|
298
|
+
*/
|
|
299
|
+
const observeSeq = (seq) => {
|
|
300
|
+
if (seq >= nextSeq) nextSeq = seq + 1;
|
|
301
|
+
};
|
|
302
|
+
const resolveHeaders = async (required) => {
|
|
303
|
+
const configured = typeof options.headers === "function" ? await options.headers() : options.headers;
|
|
304
|
+
const headers = new Headers(configured);
|
|
305
|
+
if (required) new Headers(required).forEach((value, key) => headers.set(key, value));
|
|
306
|
+
return headers;
|
|
307
|
+
};
|
|
308
|
+
const ensureCreated = () => {
|
|
309
|
+
if (createPromise) return createPromise;
|
|
310
|
+
createPromise = (async () => {
|
|
311
|
+
const response = await fetchWithTimeout(streamUrl, {
|
|
312
|
+
method: "PUT",
|
|
313
|
+
headers: await resolveHeaders({ "Content-Type": "application/json" })
|
|
314
|
+
});
|
|
315
|
+
if (!response.ok) throw httpFailure("create stream", response);
|
|
316
|
+
const offset = requireNextOffset(response, "create");
|
|
317
|
+
createdHere = response.status === 201;
|
|
318
|
+
appendTailOffset = offset;
|
|
319
|
+
return offset;
|
|
320
|
+
})().catch((error) => {
|
|
321
|
+
createPromise = void 0;
|
|
322
|
+
throw error;
|
|
323
|
+
});
|
|
324
|
+
return createPromise;
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* The one window-pulling loop behind both `read` and `snapshot`.
|
|
328
|
+
*
|
|
329
|
+
* `stopWhenUpToDate` is the only difference between them. The protocol's
|
|
330
|
+
* control frame carries `upToDate: true` when the backend has handed the
|
|
331
|
+
* reader everything the stream currently holds; a live `read` ignores that and
|
|
332
|
+
* keeps long-polling for more, while a `snapshot` returns there. That makes a
|
|
333
|
+
* snapshot bounded even on a stream nobody ever closed.
|
|
334
|
+
*/
|
|
335
|
+
const readWindows = async function* (offset, signal, stopWhenUpToDate) {
|
|
336
|
+
let backendOffset;
|
|
337
|
+
let deliveredThroughSeq = 0;
|
|
338
|
+
if (offset === "-1" || offset === "now") backendOffset = offset;
|
|
339
|
+
else {
|
|
340
|
+
const cursor = decodeCursor(offset);
|
|
341
|
+
backendOffset = cursor.backendOffset;
|
|
342
|
+
deliveredThroughSeq = cursor.seq;
|
|
343
|
+
}
|
|
344
|
+
let streamCursor;
|
|
345
|
+
let consecutiveReadFailures = 0;
|
|
346
|
+
let windowsPulled = 0;
|
|
347
|
+
for (;;) {
|
|
348
|
+
if (signal?.aborted) return;
|
|
349
|
+
windowsPulled += 1;
|
|
350
|
+
if (stopWhenUpToDate && windowsPulled > SNAPSHOT_MAX_WINDOWS) throw new DurableStreamError(`snapshot read ${SNAPSHOT_MAX_WINDOWS} windows without the backend reporting upToDate`);
|
|
351
|
+
const requestOffset = backendOffset;
|
|
352
|
+
const requestCursor = streamCursor;
|
|
353
|
+
const url = new URL(streamUrl);
|
|
354
|
+
url.searchParams.set("offset", backendOffset);
|
|
355
|
+
url.searchParams.set("live", "sse");
|
|
356
|
+
if (streamCursor !== void 0) url.searchParams.set("cursor", streamCursor);
|
|
357
|
+
let response;
|
|
358
|
+
try {
|
|
359
|
+
response = await fetchFn(url, {
|
|
360
|
+
method: "GET",
|
|
361
|
+
headers: await resolveHeaders(),
|
|
362
|
+
signal
|
|
363
|
+
});
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (signal?.aborted) return;
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
if (!response.ok) throw httpFailure("read", response);
|
|
369
|
+
if (!response.body) throw new DurableStreamError("read response had no body");
|
|
370
|
+
let dataStartOffset = backendOffset;
|
|
371
|
+
let sawControl = false;
|
|
372
|
+
let dataAwaitingControl = false;
|
|
373
|
+
let yieldedData = false;
|
|
374
|
+
let previousResponseSeq = 0;
|
|
375
|
+
try {
|
|
376
|
+
for await (const event of parseSseEvents(response.body, signal)) {
|
|
377
|
+
if (signal?.aborted) return;
|
|
378
|
+
if (event.event === "data") {
|
|
379
|
+
dataAwaitingControl = true;
|
|
380
|
+
for (const record of parseDataRecords(event.data)) {
|
|
381
|
+
if (record.seq <= previousResponseSeq) throw new DurableStreamError("data records must have strictly increasing sequences");
|
|
382
|
+
previousResponseSeq = record.seq;
|
|
383
|
+
observeSeq(record.seq);
|
|
384
|
+
if (record.seq <= deliveredThroughSeq) continue;
|
|
385
|
+
deliveredThroughSeq = record.seq;
|
|
386
|
+
yieldedData = true;
|
|
387
|
+
yield {
|
|
388
|
+
offset: encodeCursor({
|
|
389
|
+
v: 1,
|
|
390
|
+
backendOffset: dataStartOffset,
|
|
391
|
+
seq: record.seq
|
|
392
|
+
}),
|
|
393
|
+
chunk: record.chunk
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (event.event === "control") {
|
|
399
|
+
const control = parseControlFrame(event.data);
|
|
400
|
+
backendOffset = control.streamNextOffset;
|
|
401
|
+
streamCursor = control.streamCursor;
|
|
402
|
+
dataStartOffset = backendOffset;
|
|
403
|
+
sawControl = true;
|
|
404
|
+
dataAwaitingControl = false;
|
|
405
|
+
if (control.streamClosed === true) return;
|
|
406
|
+
if (stopWhenUpToDate && control.upToDate === true) return;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
throw new DurableStreamError(`unexpected SSE event type: ${JSON.stringify(event.event)}`);
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (signal?.aborted) return;
|
|
413
|
+
if (error instanceof ResponseBodyReadFailure) {
|
|
414
|
+
if (yieldedData || sawControl && (backendOffset !== requestOffset || streamCursor !== requestCursor)) {
|
|
415
|
+
consecutiveReadFailures += 1;
|
|
416
|
+
if (consecutiveReadFailures > maxReadFailures) throw error.readError;
|
|
417
|
+
await abortableDelay(readRetryDelayMs, signal);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
throw error.readError;
|
|
421
|
+
}
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
consecutiveReadFailures = 0;
|
|
425
|
+
if (signal?.aborted) return;
|
|
426
|
+
if (dataAwaitingControl || !sawControl) throw new DurableStreamError("read SSE window ended without a matching control event");
|
|
427
|
+
if (backendOffset === requestOffset && streamCursor === requestCursor) throw new DurableStreamError("read SSE window ended without advancing offset or cursor");
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
/**
|
|
431
|
+
* One bounded pass over everything the log currently holds.
|
|
432
|
+
*
|
|
433
|
+
* Two things bound it. `readWindows(..., stopWhenUpToDate=true)` returns at
|
|
434
|
+
* the first control frame reporting the reader caught up, and
|
|
435
|
+
* `SNAPSHOT_MAX_WINDOWS` catches a backend that keeps handing out advancing
|
|
436
|
+
* windows without ever saying so. Neither covers a backend that simply never
|
|
437
|
+
* answers — `upToDate` is an optional protocol field, read windows
|
|
438
|
+
* deliberately skip `fetchWithTimeout` (a caught-up live reader may wait),
|
|
439
|
+
* and an empty still-open log has nothing to send. That shape would park the
|
|
440
|
+
* fetch forever, so the snapshot carries its own `operationTimeoutMs`
|
|
441
|
+
* deadline. Timing out is a loud failure, never a truncated result: an
|
|
442
|
+
* aborted `readWindows` ends its iteration quietly, so the flag is rechecked
|
|
443
|
+
* after the loop and thrown.
|
|
444
|
+
*
|
|
445
|
+
* `ensureCreated()` first, exactly as `append` and `read` do. A snapshot of a
|
|
446
|
+
* stream the backend does not hold yet must answer "nothing has been
|
|
447
|
+
* delivered", not reject: `sandboxRunDriver`'s `pipe` calls
|
|
448
|
+
* `awaitLogQuiescence` — two `snapshot()` reads — BEFORE the first append, so
|
|
449
|
+
* the very first producer of every durable run snapshots a stream no `PUT` has
|
|
450
|
+
* created. Reading straight through would surface that as
|
|
451
|
+
* `httpFailure('read', ...)` and fail the run at its first chunk. It also keeps
|
|
452
|
+
* this adapter's contract identical to core's `memoryStream`, which resolves to
|
|
453
|
+
* `[]` for an unknown run; two `StreamDurability` implementations must not
|
|
454
|
+
* disagree about so basic a case. `ensureCreated` is idempotent and memoised,
|
|
455
|
+
* so this costs nothing once the stream exists.
|
|
456
|
+
*/
|
|
457
|
+
const collectSnapshot = async () => {
|
|
458
|
+
await ensureCreated();
|
|
459
|
+
const controller = new AbortController();
|
|
460
|
+
let timedOut = false;
|
|
461
|
+
const timer = setTimeout(() => {
|
|
462
|
+
timedOut = true;
|
|
463
|
+
controller.abort(new DurableStreamError(`snapshot exceeded operationTimeoutMs (${operationTimeoutMs}ms)`));
|
|
464
|
+
}, operationTimeoutMs);
|
|
465
|
+
try {
|
|
466
|
+
const entries = [];
|
|
467
|
+
for await (const entry of readWindows("-1", controller.signal, true)) entries.push(entry);
|
|
468
|
+
if (timedOut) throw new DurableStreamError(`snapshot exceeded operationTimeoutMs (${operationTimeoutMs}ms) before the backend reported upToDate`);
|
|
469
|
+
seqSeeded = true;
|
|
470
|
+
return entries;
|
|
471
|
+
} finally {
|
|
472
|
+
clearTimeout(timer);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* Learn where the log ends before this instance appends to it for the first
|
|
477
|
+
* time.
|
|
478
|
+
*
|
|
479
|
+
* A takeover host is handed a fresh adapter for a run whose log already holds
|
|
480
|
+
* `seq 1..N`, and nothing in the protocol reports a record count, so the tail
|
|
481
|
+
* has to be read. One bounded read per instance, and the alignment `snapshot()`
|
|
482
|
+
* a takeover already performs satisfies it.
|
|
483
|
+
*
|
|
484
|
+
* A brand-new run pays nothing, and must not: the seeding read cannot be on the
|
|
485
|
+
* producer's critical path. `upToDate` is an optional protocol field and an
|
|
486
|
+
* empty still-open log has nothing to send, so on a backend that omits it the
|
|
487
|
+
* read has to run its `operationTimeoutMs` deadline out and then fail — the
|
|
488
|
+
* producer would wait on a reader that is waiting on the producer, and every
|
|
489
|
+
* fresh run on such a backend would die of a synthetic error. `createdHere`
|
|
490
|
+
* settles it without a request: a stream this instance brought into existence
|
|
491
|
+
* provably holds no records, so `nextSeq` is already correct at 1.
|
|
492
|
+
*/
|
|
493
|
+
const ensureSeqSeeded = () => {
|
|
494
|
+
if (seqSeeded) return Promise.resolve();
|
|
495
|
+
if (seedPromise) return seedPromise;
|
|
496
|
+
seedPromise = (async () => {
|
|
497
|
+
await ensureCreated();
|
|
498
|
+
if (createdHere) {
|
|
499
|
+
seqSeeded = true;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
await collectSnapshot();
|
|
503
|
+
})().catch((error) => {
|
|
504
|
+
seedPromise = void 0;
|
|
505
|
+
throw error;
|
|
506
|
+
});
|
|
507
|
+
return seedPromise;
|
|
508
|
+
};
|
|
509
|
+
return {
|
|
510
|
+
resumeFrom: () => resumeOffset,
|
|
511
|
+
append: async (chunks) => {
|
|
512
|
+
if (chunks.length === 0) return [];
|
|
513
|
+
await ensureSeqSeeded();
|
|
514
|
+
const batchStartOffset = appendTailOffset ?? await ensureCreated();
|
|
515
|
+
const firstSeq = nextSeq;
|
|
516
|
+
const records = chunks.map((chunk, index) => ({
|
|
517
|
+
v: 1,
|
|
518
|
+
seq: firstSeq + index,
|
|
519
|
+
chunk
|
|
520
|
+
}));
|
|
521
|
+
observeSeq(firstSeq + records.length - 1);
|
|
522
|
+
const requestProducerSeq = producerSeq;
|
|
523
|
+
producerSeq += 1;
|
|
524
|
+
const requestInit = {
|
|
525
|
+
method: "POST",
|
|
526
|
+
headers: await resolveHeaders({
|
|
527
|
+
"Content-Type": "application/json",
|
|
528
|
+
"Producer-Id": producerId,
|
|
529
|
+
"Producer-Epoch": String(producerEpoch),
|
|
530
|
+
"Producer-Seq": String(requestProducerSeq)
|
|
531
|
+
}),
|
|
532
|
+
body: JSON.stringify(records)
|
|
533
|
+
};
|
|
534
|
+
let response;
|
|
535
|
+
try {
|
|
536
|
+
response = await fetchWithTimeout(streamUrl, requestInit);
|
|
537
|
+
} catch (firstError) {
|
|
538
|
+
try {
|
|
539
|
+
response = await fetchWithTimeout(streamUrl, requestInit);
|
|
540
|
+
} catch (retryError) {
|
|
541
|
+
throw new AggregateError([firstError, retryError], "durableStream: append failed before its outcome could be confirmed");
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (!response.ok) throw httpFailure("append", response);
|
|
545
|
+
appendTailOffset = requireNextOffset(response, "append");
|
|
546
|
+
return records.map((record) => encodeCursor({
|
|
547
|
+
v: 1,
|
|
548
|
+
backendOffset: batchStartOffset,
|
|
549
|
+
seq: record.seq
|
|
550
|
+
}));
|
|
551
|
+
},
|
|
552
|
+
close: () => {
|
|
553
|
+
if (closePromise) return closePromise;
|
|
554
|
+
closePromise = (async () => {
|
|
555
|
+
await ensureCreated();
|
|
556
|
+
const response = await fetchWithTimeout(streamUrl, {
|
|
557
|
+
method: "POST",
|
|
558
|
+
headers: await resolveHeaders({ "Stream-Closed": "true" })
|
|
559
|
+
});
|
|
560
|
+
if (!response.ok) throw httpFailure("close", response);
|
|
561
|
+
const nextOffset = requireNextOffset(response, "close");
|
|
562
|
+
if (response.headers.get("Stream-Closed")?.toLowerCase() !== "true") throw new DurableStreamError("close response missing Stream-Closed: true");
|
|
563
|
+
appendTailOffset = nextOffset;
|
|
564
|
+
})().catch((error) => {
|
|
565
|
+
closePromise = void 0;
|
|
566
|
+
throw error;
|
|
567
|
+
});
|
|
568
|
+
return closePromise;
|
|
569
|
+
},
|
|
570
|
+
read: (offset, signal) => readWindows(offset, signal, false),
|
|
571
|
+
snapshot: () => collectSnapshot()
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
//#endregion
|
|
575
|
+
export { DurableStreamError, durableStream };
|
|
576
|
+
|
|
577
|
+
//# sourceMappingURL=durable-stream.js.map
|