@loro-dev/streams-crdt 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.
@@ -0,0 +1,2151 @@
1
+ //#region src/checkpoint.ts
2
+ function composeBeforeRemoteCursorSaveHooks(...hooks) {
3
+ const active = hooks.filter((hook) => hook != null);
4
+ if (active.length === 0) return;
5
+ return async (context) => {
6
+ for (const hook of active) await hook(context);
7
+ };
8
+ }
9
+ async function persistRemoteCursor(input) {
10
+ const next = {
11
+ ...input.cursor,
12
+ updatedAtMs: (input.now ?? Date.now)()
13
+ };
14
+ if (input.beforeRemoteCursorSave != null) await input.beforeRemoteCursorSave({
15
+ cursor: next,
16
+ source: input.source
17
+ });
18
+ await input.remoteCursorStore.save(next);
19
+ return next;
20
+ }
21
+ function key(streamUrl) {
22
+ return streamUrl;
23
+ }
24
+ function isJsonObject(value) {
25
+ return typeof value === "object" && value != null && !Array.isArray(value);
26
+ }
27
+ function cloneJsonValue(value) {
28
+ return structuredClone(value);
29
+ }
30
+ function cloneRemoteCursor(cursor) {
31
+ return {
32
+ ...cursor,
33
+ serverLowerBoundVersion: cloneJsonValue(cursor.serverLowerBoundVersion)
34
+ };
35
+ }
36
+ function fromStoredRemoteCursor(record) {
37
+ if (!isJsonObject(record.serverLowerBoundVersion)) throw new Error("invalid remote cursor record: serverLowerBoundVersion is not a JSON object");
38
+ return {
39
+ streamUrl: record.streamUrl,
40
+ nextOffset: record.nextOffset,
41
+ serverLowerBoundVersion: cloneJsonValue(record.serverLowerBoundVersion),
42
+ updatedAtMs: record.updatedAtMs
43
+ };
44
+ }
45
+ function toStoredRemoteCursor(cursor) {
46
+ return {
47
+ streamUrl: cursor.streamUrl,
48
+ nextOffset: cursor.nextOffset,
49
+ serverLowerBoundVersion: cloneJsonValue(cursor.serverLowerBoundVersion),
50
+ updatedAtMs: cursor.updatedAtMs
51
+ };
52
+ }
53
+ /**
54
+ * In-memory cursor store useful for tests or ephemeral runtimes.
55
+ */
56
+ var InMemoryRemoteCursorStore = class {
57
+ data = /* @__PURE__ */ new Map();
58
+ async load(streamUrl) {
59
+ const value = this.data.get(key(streamUrl));
60
+ return value == null ? null : cloneRemoteCursor(value);
61
+ }
62
+ async save(cursor) {
63
+ this.data.set(key(cursor.streamUrl), cloneRemoteCursor(cursor));
64
+ }
65
+ async delete(streamUrl) {
66
+ this.data.delete(key(streamUrl));
67
+ }
68
+ };
69
+ const DEFAULT_DB_NAME = "streams-crdt";
70
+ const DEFAULT_STORE_NAME = "remote-cursors";
71
+ const DEFAULT_DB_VERSION = 1;
72
+ /**
73
+ * IndexedDB-backed cursor store for browser clients that need replay progress persistence.
74
+ *
75
+ * This store only persists `RemoteCursor` metadata. It does not persist CRDT
76
+ * payloads. Use it only when the application also persists the corresponding
77
+ * local document state and restores that state before calling `sync()` or
78
+ * `join()` after a reload.
79
+ */
80
+ var IndexedDbRemoteCursorStore = class {
81
+ dbName;
82
+ storeName;
83
+ dbVersion;
84
+ indexedDb;
85
+ db = null;
86
+ initPromise = null;
87
+ constructor(options) {
88
+ this.dbName = options?.dbName ?? DEFAULT_DB_NAME;
89
+ this.storeName = options?.storeName ?? DEFAULT_STORE_NAME;
90
+ this.dbVersion = options?.dbVersion ?? DEFAULT_DB_VERSION;
91
+ this.indexedDb = options?.indexedDb ?? globalThis.indexedDB;
92
+ }
93
+ async load(streamUrl) {
94
+ const db = await this.ensureDb();
95
+ const record = await new Promise((resolve, reject) => {
96
+ const request = db.transaction([this.storeName], "readonly").objectStore(this.storeName).get(key(streamUrl));
97
+ request.onsuccess = () => {
98
+ resolve(request.result ?? null);
99
+ };
100
+ request.onerror = () => {
101
+ reject(request.error ?? /* @__PURE__ */ new Error("indexeddb load failed"));
102
+ };
103
+ });
104
+ return record == null ? null : fromStoredRemoteCursor(record);
105
+ }
106
+ async save(cursor) {
107
+ const db = await this.ensureDb();
108
+ const record = toStoredRemoteCursor(cursor);
109
+ await new Promise((resolve, reject) => {
110
+ const tx = db.transaction([this.storeName], "readwrite");
111
+ const request = tx.objectStore(this.storeName).put(record, key(cursor.streamUrl));
112
+ tx.oncomplete = () => resolve();
113
+ tx.onerror = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb save failed"));
114
+ tx.onabort = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb save transaction aborted"));
115
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexeddb save failed"));
116
+ });
117
+ }
118
+ async delete(streamUrl) {
119
+ const db = await this.ensureDb();
120
+ await new Promise((resolve, reject) => {
121
+ const tx = db.transaction([this.storeName], "readwrite");
122
+ const request = tx.objectStore(this.storeName).delete(key(streamUrl));
123
+ tx.oncomplete = () => resolve();
124
+ tx.onerror = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb delete failed"));
125
+ tx.onabort = () => reject(tx.error ?? request.error ?? /* @__PURE__ */ new Error("indexeddb delete transaction aborted"));
126
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexeddb delete failed"));
127
+ });
128
+ }
129
+ async ensureDb() {
130
+ if (this.db != null) return this.db;
131
+ if (this.initPromise != null) {
132
+ await this.initPromise;
133
+ if (this.db == null) throw new Error("indexeddb is not available");
134
+ return this.db;
135
+ }
136
+ if (this.indexedDb == null) throw new Error("indexeddb is not available in this environment");
137
+ this.initPromise = new Promise((resolve, reject) => {
138
+ const request = this.indexedDb.open(this.dbName, this.dbVersion);
139
+ request.onupgradeneeded = () => {
140
+ const db = request.result;
141
+ if (!db.objectStoreNames.contains(this.storeName)) db.createObjectStore(this.storeName);
142
+ };
143
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("indexeddb open failed"));
144
+ request.onsuccess = () => {
145
+ this.db = request.result;
146
+ this.db.onversionchange = () => {
147
+ this.db?.close();
148
+ this.db = null;
149
+ };
150
+ resolve();
151
+ };
152
+ }).finally(() => {
153
+ this.initPromise = null;
154
+ });
155
+ await this.initPromise;
156
+ if (this.db == null) throw new Error("indexeddb open succeeded without a database handle");
157
+ return this.db;
158
+ }
159
+ };
160
+ /**
161
+ * Creates a cursor value suitable for seeding a `RemoteCursorStore`.
162
+ */
163
+ function createInitialRemoteCursor(input) {
164
+ return {
165
+ streamUrl: input.streamUrl,
166
+ nextOffset: input.nextOffset,
167
+ serverLowerBoundVersion: cloneJsonValue(input.serverLowerBoundVersion),
168
+ updatedAtMs: Date.now()
169
+ };
170
+ }
171
+ //#endregion
172
+ //#region src/reconnect.ts
173
+ const DEFAULT_RECONNECT_CONFIG = {
174
+ delays: [
175
+ 0,
176
+ 500,
177
+ 1e3,
178
+ 2e3,
179
+ 4e3,
180
+ 8e3,
181
+ 15e3
182
+ ],
183
+ jitterFraction: .2,
184
+ maxAttempts: 5,
185
+ connectTimeoutMs: 1e4,
186
+ pollTimeoutMs: 3e4
187
+ };
188
+ /**
189
+ * Apply jitter of +/- fraction to a base delay.
190
+ * E.g. applyJitter(1000, 0.2) returns a value in [800, 1200].
191
+ */
192
+ function applyJitter(baseMs, fraction) {
193
+ if (baseMs === 0) return 0;
194
+ const jitter = baseMs * fraction * (2 * Math.random() - 1);
195
+ return Math.max(0, Math.round(baseMs + jitter));
196
+ }
197
+ /**
198
+ * Compute the retry delay for a given attempt number.
199
+ * Uses the delays table from config, clamping at the last entry.
200
+ */
201
+ function computeRetryDelay(attempt, config = DEFAULT_RECONNECT_CONFIG) {
202
+ const { delays, jitterFraction } = config;
203
+ const base = delays[Math.min(attempt, delays.length - 1)];
204
+ return applyJitter(base, jitterFraction);
205
+ }
206
+ //#endregion
207
+ //#region src/items.ts
208
+ const BINARY_CONTENT_TYPE = "application/octet-stream";
209
+ function encodeItems(items) {
210
+ const total = items.reduce((sum, item) => sum + 4 + item.byteLength, 0);
211
+ const out = new Uint8Array(total);
212
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
213
+ let cursor = 0;
214
+ for (const item of items) {
215
+ view.setUint32(cursor, item.byteLength, false);
216
+ cursor += 4;
217
+ out.set(item, cursor);
218
+ cursor += item.byteLength;
219
+ }
220
+ return out;
221
+ }
222
+ function decodeItems(payload) {
223
+ const out = [];
224
+ const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
225
+ let cursor = 0;
226
+ while (cursor < payload.byteLength) {
227
+ if (cursor + 4 > payload.byteLength) throw new Error("corrupted items payload: truncated length prefix");
228
+ const len = view.getUint32(cursor, false);
229
+ cursor += 4;
230
+ if (cursor + len > payload.byteLength) throw new Error("corrupted items payload: truncated item payload");
231
+ out.push(payload.slice(cursor, cursor + len));
232
+ cursor += len;
233
+ }
234
+ return out;
235
+ }
236
+ //#endregion
237
+ //#region src/multipart.ts
238
+ /**
239
+ * Parse an RFC 2046 multipart/mixed body into its constituent parts.
240
+ *
241
+ * The boundary must NOT include the leading "--".
242
+ */
243
+ function decodeMultipartMixed(boundary, data) {
244
+ const delimiter = new TextEncoder().encode(`--${boundary}`);
245
+ const closeDelimiter = new TextEncoder().encode(`--${boundary}--`);
246
+ const crlfCrlf = new TextEncoder().encode("\r\n\r\n");
247
+ const parts = [];
248
+ let pos = 0;
249
+ const firstDelimPos = indexOf(data, delimiter, pos);
250
+ if (firstDelimPos < 0) return parts;
251
+ pos = firstDelimPos + delimiter.byteLength;
252
+ if (pos < data.byteLength && data[pos] === 13) pos++;
253
+ if (pos < data.byteLength && data[pos] === 10) pos++;
254
+ while (pos < data.byteLength) {
255
+ if (startsWith(data.subarray(pos), closeDelimiter.subarray(delimiter.byteLength))) break;
256
+ const headerEnd = indexOf(data, crlfCrlf, pos);
257
+ if (headerEnd < 0) break;
258
+ const headerBytes = data.subarray(pos, headerEnd);
259
+ const headerText = new TextDecoder().decode(headerBytes);
260
+ let contentType = "application/octet-stream";
261
+ for (const line of headerText.split("\r\n")) {
262
+ const colonIdx = line.indexOf(":");
263
+ if (colonIdx > 0) {
264
+ if (line.slice(0, colonIdx).trim().toLowerCase() === "content-type") contentType = line.slice(colonIdx + 1).trim();
265
+ }
266
+ }
267
+ const bodyStart = headerEnd + crlfCrlf.byteLength;
268
+ const nextDelimPos = indexOf(data, delimiter, bodyStart);
269
+ if (nextDelimPos < 0) {
270
+ parts.push({
271
+ contentType,
272
+ body: data.subarray(bodyStart)
273
+ });
274
+ break;
275
+ }
276
+ let bodyEnd = nextDelimPos;
277
+ if (bodyEnd >= 2 && data[bodyEnd - 2] === 13 && data[bodyEnd - 1] === 10) bodyEnd -= 2;
278
+ parts.push({
279
+ contentType,
280
+ body: data.subarray(bodyStart, bodyEnd)
281
+ });
282
+ pos = nextDelimPos + delimiter.byteLength;
283
+ if (pos < data.byteLength && data[pos] === 45 && pos + 1 < data.byteLength && data[pos + 1] === 45) break;
284
+ if (pos < data.byteLength && data[pos] === 13) pos++;
285
+ if (pos < data.byteLength && data[pos] === 10) pos++;
286
+ }
287
+ return parts;
288
+ }
289
+ /**
290
+ * Extract the boundary parameter from a Content-Type header value.
291
+ * Returns null if not found.
292
+ */
293
+ function extractBoundary(contentType) {
294
+ const match = /boundary=("?)([^";,\s]+)\1/i.exec(contentType);
295
+ return match ? match[2] : null;
296
+ }
297
+ function indexOf(haystack, needle, from) {
298
+ outer: for (let i = from; i <= haystack.byteLength - needle.byteLength; i++) {
299
+ for (let j = 0; j < needle.byteLength; j++) if (haystack[i + j] !== needle[j]) continue outer;
300
+ return i;
301
+ }
302
+ return -1;
303
+ }
304
+ function startsWith(data, prefix) {
305
+ if (data.byteLength < prefix.byteLength) return false;
306
+ for (let i = 0; i < prefix.byteLength; i++) if (data[i] !== prefix[i]) return false;
307
+ return true;
308
+ }
309
+ //#endregion
310
+ //#region src/transport-internal.ts
311
+ function createProducerId() {
312
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
313
+ return `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
314
+ }
315
+ var HttpError = class extends Error {
316
+ status;
317
+ constructor(message, status) {
318
+ super(message);
319
+ this.status = status;
320
+ }
321
+ };
322
+ var TimeoutError = class extends Error {
323
+ timeoutType;
324
+ timeoutMs;
325
+ constructor(timeoutType, timeoutMs) {
326
+ super(`TIMEOUT: ${timeoutType} request exceeded ${timeoutMs}ms`);
327
+ this.timeoutType = timeoutType;
328
+ this.timeoutMs = timeoutMs;
329
+ }
330
+ };
331
+ var ProtocolError = class extends Error {
332
+ status;
333
+ constructor(message, status) {
334
+ super(message);
335
+ this.status = status;
336
+ }
337
+ };
338
+ function getHeader(headers, lowerCaseName, titleCaseName) {
339
+ return headers.get(lowerCaseName) ?? headers.get(titleCaseName);
340
+ }
341
+ function visibleHeaderNames(headers) {
342
+ const names = [];
343
+ headers.forEach((_value, name) => {
344
+ names.push(name);
345
+ });
346
+ if (names.length === 0) return "(none)";
347
+ names.sort();
348
+ return names.join(", ");
349
+ }
350
+ function missingRequiredHeaderError(context, requiredHeader, headers) {
351
+ return new ProtocolError(`${context} response missing ${requiredHeader}. If this is cross-origin, expose it via Access-Control-Expose-Headers (${requiredHeader.toLowerCase()}). Visible headers: ${visibleHeaderNames(headers)}`);
352
+ }
353
+ function isObject(value) {
354
+ return typeof value === "object" && value != null;
355
+ }
356
+ function parseHeaderU64(value) {
357
+ if (value == null) return null;
358
+ const parsed = Number.parseInt(value, 10);
359
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
360
+ return parsed;
361
+ }
362
+ function decodeBase64Bytes(encoded) {
363
+ if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(encoded, "base64"));
364
+ if (typeof atob === "function") {
365
+ const binary = atob(encoded);
366
+ const out = new Uint8Array(binary.length);
367
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
368
+ return out;
369
+ }
370
+ throw new Error("base64 decoding is unavailable");
371
+ }
372
+ function decodeSseRawPayload(data) {
373
+ try {
374
+ const parsed = JSON.parse(data);
375
+ if (isObject(parsed) && parsed.encoding === "base64" && typeof parsed.payload === "string") return decodeBase64Bytes(parsed.payload);
376
+ } catch {}
377
+ return new TextEncoder().encode(data);
378
+ }
379
+ function decodeSseItemPayloads(data) {
380
+ const payload = decodeSseRawPayload(data);
381
+ if (payload.byteLength === 0) return [payload];
382
+ try {
383
+ return decodeItems(payload);
384
+ } catch {
385
+ return [payload];
386
+ }
387
+ }
388
+ /**
389
+ * Decode a single multipart part body. If the payload contains
390
+ * length-prefixed binary framed items, decode them. Otherwise
391
+ * return the raw bytes as a single-element array. This mirrors the
392
+ * try/fallback approach used by {@link decodeSseItemPayloads}.
393
+ */
394
+ function decodeMultipartPartItems(payload) {
395
+ if (payload.byteLength === 0) return [payload];
396
+ try {
397
+ return decodeItems(payload);
398
+ } catch {
399
+ return [payload];
400
+ }
401
+ }
402
+ function parseSseControlData(data) {
403
+ let parsed;
404
+ try {
405
+ parsed = JSON.parse(data);
406
+ } catch {
407
+ throw new ProtocolError("invalid sse control event JSON");
408
+ }
409
+ if (!isObject(parsed) || typeof parsed.streamNextOffset !== "string") throw new ProtocolError("sse control event missing streamNextOffset");
410
+ const streamCursor = typeof parsed.streamCursor === "string" ? parsed.streamCursor : void 0;
411
+ return {
412
+ nextOffset: parsed.streamNextOffset,
413
+ upToDate: parsed.upToDate === true,
414
+ streamCursor
415
+ };
416
+ }
417
+ function isSseUnsupportedErrorText(message) {
418
+ const normalized = message.trim().toLowerCase();
419
+ if (normalized.length === 0) return false;
420
+ const mentionsSse = normalized.includes("sse") || normalized.includes("text/event-stream");
421
+ const mentionsCompatibility = normalized.includes("unsupported") || normalized.includes("not support") || normalized.includes("incompatible") || normalized.includes("content type");
422
+ return mentionsSse && mentionsCompatibility;
423
+ }
424
+ function withCursorQuery(url, cursor) {
425
+ if (cursor == null || cursor.length === 0) return url;
426
+ const target = new URL(url);
427
+ target.searchParams.set("cursor", cursor);
428
+ return target.toString();
429
+ }
430
+ function bytesToArrayBuffer(bytes) {
431
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
432
+ }
433
+ function sleep(ms) {
434
+ return new Promise((resolve) => setTimeout(resolve, ms));
435
+ }
436
+ function mergeAbortSignals(...signals) {
437
+ const active = signals.filter((signal) => signal != null);
438
+ if (active.length === 0) return;
439
+ if (active.length === 1) return active[0];
440
+ if (typeof AbortSignal.any === "function") return AbortSignal.any(active);
441
+ const controller = new AbortController();
442
+ for (const signal of active) {
443
+ if (signal.aborted) {
444
+ controller.abort(signal.reason);
445
+ break;
446
+ }
447
+ signal.addEventListener("abort", () => {
448
+ controller.abort(signal.reason);
449
+ }, { once: true });
450
+ }
451
+ return controller.signal;
452
+ }
453
+ function classifyFetchError(err) {
454
+ const message = err instanceof Error ? err.message : String(err);
455
+ const status = err instanceof HttpError ? err.status : void 0;
456
+ if (status === 401 || status === 403) return {
457
+ code: "auth_failed",
458
+ message,
459
+ retriable: false,
460
+ atMs: Date.now()
461
+ };
462
+ if (status === 410) return {
463
+ code: "gone_410",
464
+ message,
465
+ retriable: true,
466
+ atMs: Date.now()
467
+ };
468
+ if (status === 400 || status === 405 || status === 409) return {
469
+ code: "protocol",
470
+ message,
471
+ retriable: false,
472
+ atMs: Date.now()
473
+ };
474
+ if (err instanceof ProtocolError) return {
475
+ code: "protocol",
476
+ message,
477
+ retriable: false,
478
+ atMs: Date.now()
479
+ };
480
+ if (err instanceof TimeoutError) return {
481
+ code: "timeout",
482
+ message,
483
+ retriable: true,
484
+ atMs: Date.now()
485
+ };
486
+ if (err instanceof TypeError) return {
487
+ code: "network",
488
+ message,
489
+ retriable: true,
490
+ atMs: Date.now()
491
+ };
492
+ return {
493
+ code: "internal",
494
+ message,
495
+ retriable: false,
496
+ atMs: Date.now()
497
+ };
498
+ }
499
+ function isStreamNotFoundError(error) {
500
+ return error instanceof HttpError && error.status === 404;
501
+ }
502
+ function toTransportError(error) {
503
+ if (isStreamNotFoundError(error)) return {
504
+ code: "stream_not_found",
505
+ retryable: false
506
+ };
507
+ if (error instanceof HttpError && (error.status === 401 || error.status === 403)) return {
508
+ code: "auth_failed",
509
+ retryable: false,
510
+ status: error.status,
511
+ message: error.message
512
+ };
513
+ if (error instanceof HttpError && [
514
+ 400,
515
+ 405,
516
+ 409
517
+ ].includes(error.status)) return {
518
+ code: "protocol_error",
519
+ retryable: false,
520
+ status: error.status,
521
+ message: error.message
522
+ };
523
+ if (error instanceof ProtocolError) return {
524
+ code: "protocol_error",
525
+ retryable: false,
526
+ status: error.status,
527
+ message: error.message
528
+ };
529
+ if (error instanceof TimeoutError) return {
530
+ code: "timeout",
531
+ retryable: true,
532
+ phase: error.timeoutType,
533
+ message: error.message
534
+ };
535
+ if (error instanceof HttpError && error.status === 410) return {
536
+ code: "gone",
537
+ retryable: true,
538
+ message: error.message
539
+ };
540
+ if (error instanceof TypeError) return {
541
+ code: "network_error",
542
+ retryable: true,
543
+ message: error.message
544
+ };
545
+ if (error instanceof Error) return {
546
+ code: "internal_error",
547
+ retryable: false,
548
+ message: error.message
549
+ };
550
+ return {
551
+ code: "unknown",
552
+ retryable: false,
553
+ message: String(error)
554
+ };
555
+ }
556
+ function isReadNoProgress(requestOffset, result) {
557
+ return !result.upToDate && result.updates.length === 0 && result.nextOffset === requestOffset;
558
+ }
559
+ function isAuthOrProtocolError(error) {
560
+ return error instanceof ProtocolError || error instanceof HttpError && [
561
+ 400,
562
+ 401,
563
+ 403,
564
+ 405,
565
+ 409
566
+ ].includes(error.status);
567
+ }
568
+ function normalizeAuthProvider(auth) {
569
+ if (auth == null) return;
570
+ if (typeof auth === "string") {
571
+ const token = auth.trim();
572
+ return async () => token.length === 0 ? void 0 : token;
573
+ }
574
+ return async () => {
575
+ const token = (await auth())?.trim() ?? "";
576
+ return token.length === 0 ? void 0 : token;
577
+ };
578
+ }
579
+ function normalizeSnapshotUploadConfig(config) {
580
+ if (config == null || config.enabled === false) return;
581
+ if (!Number.isFinite(config.debounceMs ?? 1e4)) throw new Error("snapshotUpload.debounceMs must be a finite number");
582
+ if (!Number.isFinite(config.minBytesSinceRemoteSnapshot ?? 100 * 1024)) throw new Error("snapshotUpload.minBytesSinceRemoteSnapshot must be a finite number");
583
+ const debounceMs = Math.max(0, Math.floor(config.debounceMs ?? 1e4));
584
+ const minBytes = config.minBytesSinceRemoteSnapshot ?? 100 * 1024;
585
+ if (minBytes < 0) throw new Error("snapshotUpload.minBytesSinceRemoteSnapshot must be non-negative");
586
+ return {
587
+ canUpload: async () => Boolean(await config.canUpload()),
588
+ debounceMs,
589
+ minBytesSinceRemoteSnapshot: BigInt(Math.floor(minBytes))
590
+ };
591
+ }
592
+ function jsonValueEquals(a, b) {
593
+ if (a === b) return true;
594
+ if (typeof a !== typeof b || a == null || b == null) return false;
595
+ if (Array.isArray(a) || Array.isArray(b)) {
596
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
597
+ for (let i = 0; i < a.length; i += 1) if (!jsonValueEquals(a[i], b[i])) return false;
598
+ return true;
599
+ }
600
+ if (typeof a !== "object" || typeof b !== "object") return false;
601
+ const aEntries = Object.entries(a).sort(([left], [right]) => left.localeCompare(right));
602
+ const bEntries = Object.entries(b).sort(([left], [right]) => left.localeCompare(right));
603
+ if (aEntries.length !== bEntries.length) return false;
604
+ for (let i = 0; i < aEntries.length; i += 1) {
605
+ const [aKey, aValue] = aEntries[i];
606
+ const [bKey, bValue] = bEntries[i];
607
+ if (aKey !== bKey || !jsonValueEquals(aValue, bValue)) return false;
608
+ }
609
+ return true;
610
+ }
611
+ function jsonObjectEquals(a, b) {
612
+ return jsonValueEquals(a, b);
613
+ }
614
+ function parseSnapshotByteOffset(offset) {
615
+ if (offset == null || offset.length === 0 || offset === "-1") return 0n;
616
+ if (!/^\d+$/.test(offset)) return null;
617
+ return BigInt(offset);
618
+ }
619
+ function resolveNextOffsetAfterConcurrentAdvance(requestOffset, currentOffset, responseNextOffset) {
620
+ if (currentOffset === requestOffset) return responseNextOffset;
621
+ if (currentOffset === responseNextOffset) return currentOffset;
622
+ const current = parseSnapshotByteOffset(currentOffset);
623
+ const incoming = parseSnapshotByteOffset(responseNextOffset);
624
+ if (current != null && incoming != null) return incoming > current ? responseNextOffset : currentOffset;
625
+ return currentOffset;
626
+ }
627
+ //#endregion
628
+ //#region src/transport-client.ts
629
+ /**
630
+ * Low-level HTTP and SSE client for one stream.
631
+ *
632
+ * This layer owns request headers, timeouts, and response parsing. Higher-level
633
+ * join/sync state transitions stay in `transport-runtime.ts`.
634
+ */
635
+ var StreamsTransportClient = class {
636
+ streamUrl;
637
+ adapter;
638
+ authProvider;
639
+ fetchImpl;
640
+ reconnectConfig;
641
+ constructor(options) {
642
+ this.streamUrl = options.streamUrl;
643
+ this.adapter = options.adapter;
644
+ this.authProvider = options.authProvider;
645
+ this.fetchImpl = options.fetchImpl;
646
+ this.reconnectConfig = options.reconnectConfig;
647
+ }
648
+ async createStreamRequest() {
649
+ const adapterHeaders = this.adapter.getCreateStreamHeaders ? await this.adapter.getCreateStreamHeaders() : void 0;
650
+ const response = await this.fetchWithTimeout(this.streamUrl, {
651
+ method: "PUT",
652
+ headers: await this.resolveHeaders({
653
+ "Content-Type": BINARY_CONTENT_TYPE,
654
+ ...adapterHeaders
655
+ })
656
+ }, this.reconnectConfig.connectTimeoutMs, "connect");
657
+ if (response.status === 200 || response.status === 201) return response.status === 201;
658
+ throw new HttpError(`create stream failed with status ${response.status}`, response.status);
659
+ }
660
+ async deleteStreamRequest() {
661
+ const response = await this.fetchWithTimeout(this.streamUrl, {
662
+ method: "DELETE",
663
+ headers: await this.resolveHeaders()
664
+ }, this.reconnectConfig.connectTimeoutMs, "connect");
665
+ if (response.status === 200 || response.status === 204) return true;
666
+ if (response.status === 404) return false;
667
+ throw new HttpError(`delete stream failed with status ${response.status}`, response.status);
668
+ }
669
+ async readFromOffsetSseItems(offset, cursor, onControl, onOpen, signal) {
670
+ const query = new URLSearchParams({
671
+ offset,
672
+ live: "sse"
673
+ });
674
+ return this.readItemsSse(withCursorQuery(`${this.streamUrl}?${query.toString()}`, cursor), offset, onControl, onOpen, signal);
675
+ }
676
+ async openBootstrapSseSession(signal) {
677
+ const query = new URLSearchParams({ live: "sse" });
678
+ return await this.openItemsSseSession(`${this.streamUrl}/bootstrap?${query.toString()}`, "-1", void 0, signal);
679
+ }
680
+ async readBootstrapLongPoll(cursor, signal) {
681
+ const query = new URLSearchParams({ live: "long-poll" });
682
+ if (cursor != null && cursor.length > 0) query.set("cursor", cursor);
683
+ const response = await this.fetchWithTimeout(`${this.streamUrl}/bootstrap?${query.toString()}`, {
684
+ method: "GET",
685
+ headers: await this.resolveHeaders(),
686
+ signal
687
+ }, this.reconnectConfig.pollTimeoutMs, "poll");
688
+ if (response.status === 410) return { kind: "gone" };
689
+ if (!response.ok) throw new HttpError(`bootstrap long-poll failed with status ${response.status}`, response.status);
690
+ const nextOffset = getHeader(response.headers, "stream-next-offset", "Stream-Next-Offset");
691
+ if (nextOffset == null) throw missingRequiredHeaderError("bootstrap", "Stream-Next-Offset", response.headers);
692
+ const upToDate = getHeader(response.headers, "stream-up-to-date", "Stream-Up-To-Date") === "true";
693
+ const streamCursor = getHeader(response.headers, "stream-cursor", "Stream-Cursor") ?? void 0;
694
+ const body = new Uint8Array(await response.arrayBuffer());
695
+ const boundary = extractBoundary(response.headers.get("Content-Type") ?? "");
696
+ let items;
697
+ if (boundary != null && body.byteLength > 0) items = decodeMultipartMixed(boundary, body).flatMap((part) => decodeMultipartPartItems(part.body));
698
+ else if (body.byteLength === 0) items = [];
699
+ else throw new ProtocolError("bootstrap long-poll returned a non-multipart body");
700
+ return {
701
+ kind: "ok",
702
+ value: {
703
+ nextOffset,
704
+ upToDate,
705
+ items,
706
+ streamCursor
707
+ }
708
+ };
709
+ }
710
+ async readFromOffset(offset, cursor, live, signal) {
711
+ const query = new URLSearchParams({ offset });
712
+ if (live != null) query.set("live", live);
713
+ if (cursor != null && cursor.length > 0) query.set("cursor", cursor);
714
+ const timeoutMs = live === "long-poll" ? this.reconnectConfig.pollTimeoutMs : this.reconnectConfig.connectTimeoutMs;
715
+ const timeoutType = live === "long-poll" ? "poll" : "connect";
716
+ const response = await this.fetchWithTimeout(`${this.streamUrl}?${query.toString()}`, {
717
+ method: "GET",
718
+ headers: await this.resolveHeaders(),
719
+ signal
720
+ }, timeoutMs, timeoutType);
721
+ if (response.status === 410) return { kind: "gone" };
722
+ if (!response.ok) throw new HttpError(`read failed with status ${response.status}`, response.status);
723
+ const nextOffset = getHeader(response.headers, "stream-next-offset", "Stream-Next-Offset");
724
+ if (nextOffset == null) throw missingRequiredHeaderError("read", "Stream-Next-Offset", response.headers);
725
+ const upToDate = getHeader(response.headers, "stream-up-to-date", "Stream-Up-To-Date") === "true";
726
+ const streamCursor = getHeader(response.headers, "stream-cursor", "Stream-Cursor") ?? void 0;
727
+ const body = new Uint8Array(await response.arrayBuffer());
728
+ return {
729
+ kind: "ok",
730
+ value: {
731
+ nextOffset,
732
+ upToDate,
733
+ updates: body.byteLength === 0 ? [] : decodeItems(body),
734
+ streamCursor
735
+ }
736
+ };
737
+ }
738
+ async bootstrap() {
739
+ const response = await this.fetchWithTimeout(`${this.streamUrl}/bootstrap`, {
740
+ method: "GET",
741
+ headers: await this.resolveHeaders()
742
+ }, this.reconnectConfig.connectTimeoutMs, "connect");
743
+ if (!response.ok) throw new HttpError(`bootstrap failed with status ${response.status}`, response.status);
744
+ const nextOffset = getHeader(response.headers, "stream-next-offset", "Stream-Next-Offset");
745
+ if (nextOffset == null) throw missingRequiredHeaderError("bootstrap", "Stream-Next-Offset", response.headers);
746
+ const upToDate = getHeader(response.headers, "stream-up-to-date", "Stream-Up-To-Date") === "true";
747
+ const streamCursor = getHeader(response.headers, "stream-cursor", "Stream-Cursor") ?? void 0;
748
+ const body = new Uint8Array(await response.arrayBuffer());
749
+ const boundary = extractBoundary(response.headers.get("Content-Type") ?? "");
750
+ if (boundary != null && body.byteLength > 0) {
751
+ const parts = decodeMultipartMixed(boundary, body);
752
+ return {
753
+ nextOffset,
754
+ upToDate,
755
+ snapshot: parts.length > 0 ? parts[0].body : new Uint8Array(),
756
+ updates: parts.slice(1).flatMap((part) => decodeMultipartPartItems(part.body)),
757
+ streamCursor
758
+ };
759
+ }
760
+ if (body.byteLength > 0) throw new ProtocolError("bootstrap returned a non-multipart body");
761
+ return {
762
+ nextOffset,
763
+ upToDate,
764
+ snapshot: new Uint8Array(),
765
+ updates: [],
766
+ streamCursor
767
+ };
768
+ }
769
+ async readSnapshotOffsetValue() {
770
+ const response = await this.fetchWithTimeout(this.streamUrl, {
771
+ method: "HEAD",
772
+ headers: await this.resolveHeaders()
773
+ }, this.reconnectConfig.connectTimeoutMs, "connect");
774
+ if (response.status === 404) return null;
775
+ if (!response.ok) throw new HttpError(`head snapshot offset failed with status ${response.status}`, response.status);
776
+ const snapshotOffset = getHeader(response.headers, "stream-snapshot-offset", "Stream-Snapshot-Offset");
777
+ return snapshotOffset == null || snapshotOffset.length === 0 ? null : snapshotOffset;
778
+ }
779
+ async putSnapshotAt(snapshotOffset, snapshot) {
780
+ const response = await this.fetchWithTimeout(`${this.streamUrl}/snapshot/${encodeURIComponent(snapshotOffset)}`, {
781
+ method: "PUT",
782
+ headers: await this.resolveHeaders({ "Content-Type": "application/octet-stream" }),
783
+ body: bytesToArrayBuffer(snapshot)
784
+ }, this.reconnectConfig.connectTimeoutMs, "connect");
785
+ if (response.status === 200 || response.status === 204) return;
786
+ throw new HttpError(`snapshot upload failed with status ${response.status}`, response.status);
787
+ }
788
+ async appendUpdates(updates, producerSession) {
789
+ const response = await this.fetchWithTimeout(this.streamUrl, {
790
+ method: "POST",
791
+ headers: await this.resolveHeaders({
792
+ "Content-Type": BINARY_CONTENT_TYPE,
793
+ "Producer-Id": producerSession.producerId,
794
+ "Producer-Epoch": producerSession.producerEpoch.toString(),
795
+ "Producer-Seq": producerSession.nextProducerSeq.toString()
796
+ }),
797
+ body: bytesToArrayBuffer(encodeItems(updates))
798
+ }, this.reconnectConfig.connectTimeoutMs, "connect");
799
+ if (response.status === 200 || response.status === 204) {
800
+ const nextOffset = getHeader(response.headers, "stream-next-offset", "Stream-Next-Offset");
801
+ if (nextOffset == null) throw missingRequiredHeaderError("append", "Stream-Next-Offset", response.headers);
802
+ return {
803
+ kind: "ok",
804
+ ackEpoch: parseHeaderU64(getHeader(response.headers, "producer-epoch", "Producer-Epoch")) ?? producerSession.producerEpoch,
805
+ ackSeq: parseHeaderU64(getHeader(response.headers, "producer-seq", "Producer-Seq")) ?? producerSession.nextProducerSeq,
806
+ nextOffset
807
+ };
808
+ }
809
+ if (response.status === 403) {
810
+ const serverEpoch = parseHeaderU64(getHeader(response.headers, "producer-epoch", "Producer-Epoch"));
811
+ if (serverEpoch != null) return {
812
+ kind: "stale-epoch",
813
+ serverEpoch
814
+ };
815
+ }
816
+ if (response.status === 409) {
817
+ const expectedSeq = parseHeaderU64(getHeader(response.headers, "producer-expected-seq", "Producer-Expected-Seq"));
818
+ if (expectedSeq != null) return {
819
+ kind: "seq-gap",
820
+ expectedSeq
821
+ };
822
+ }
823
+ throw new HttpError(`append failed with status ${response.status}`, response.status);
824
+ }
825
+ async resolveHeaders(extra) {
826
+ const headers = new Headers(extra);
827
+ const token = this.authProvider == null ? void 0 : await this.authProvider();
828
+ if (token != null && token.length > 0) headers.set("Authorization", `Bearer ${token}`);
829
+ return headers;
830
+ }
831
+ async fetchWithTimeout(input, init, timeoutMs, timeoutType) {
832
+ if (!(timeoutMs > 0)) return this.fetchImpl(input, init);
833
+ const controller = new AbortController();
834
+ const signal = mergeAbortSignals(init.signal, controller.signal);
835
+ const timer = setTimeout(() => {
836
+ controller.abort(new TimeoutError(timeoutType, timeoutMs));
837
+ }, timeoutMs);
838
+ try {
839
+ return await this.fetchImpl(input, {
840
+ ...init,
841
+ signal
842
+ });
843
+ } catch (error) {
844
+ if (controller.signal.aborted && controller.signal.reason instanceof TimeoutError) throw controller.signal.reason;
845
+ throw error;
846
+ } finally {
847
+ clearTimeout(timer);
848
+ }
849
+ }
850
+ async readItemsSse(url, initialOffset, onControl, onOpen, signal) {
851
+ const opened = await this.openItemsSseSession(url, initialOffset, onOpen, signal);
852
+ if (opened.kind !== "ok") return opened;
853
+ const result = await opened.session.continue(onControl);
854
+ if (result.paused) await opened.session.cancel();
855
+ return {
856
+ kind: "ok",
857
+ latestOffset: result.latestOffset,
858
+ latestCursor: result.latestCursor
859
+ };
860
+ }
861
+ async openItemsSseSession(url, initialOffset, onOpen, signal) {
862
+ const response = await this.fetchWithTimeout(url, {
863
+ method: "GET",
864
+ headers: await this.resolveHeaders(),
865
+ signal
866
+ }, this.reconnectConfig.connectTimeoutMs, "connect");
867
+ if (response.status === 410) return { kind: "gone" };
868
+ if (response.status === 404) return { kind: "not-found" };
869
+ if (response.status === 400) {
870
+ const errorText = await response.text();
871
+ if (isSseUnsupportedErrorText(errorText)) return { kind: "unsupported" };
872
+ throw new HttpError(errorText.length > 0 ? `sse read failed with status 400: ${errorText}` : "sse read failed with status 400", response.status);
873
+ }
874
+ if (!response.ok) throw new HttpError(`sse read failed with status ${response.status}`, response.status);
875
+ if (!(response.headers.get("Content-Type") ?? "").toLowerCase().includes("text/event-stream")) throw new ProtocolError("sse read response must use text/event-stream");
876
+ onOpen?.();
877
+ if (response.body == null) return {
878
+ kind: "ok",
879
+ session: {
880
+ continue: async () => ({
881
+ kind: "ok",
882
+ latestOffset: initialOffset,
883
+ paused: false
884
+ }),
885
+ cancel: async () => {}
886
+ }
887
+ };
888
+ const reader = response.body.getReader();
889
+ const decoder = new TextDecoder();
890
+ let pendingText = "";
891
+ let currentEvent = "";
892
+ let dataLines = [];
893
+ let pendingDataEvents = [];
894
+ let latestOffset = initialOffset;
895
+ let latestCursor;
896
+ let exhausted = false;
897
+ return {
898
+ kind: "ok",
899
+ session: {
900
+ continue: async (nextOnControl) => {
901
+ if (exhausted) return {
902
+ kind: "ok",
903
+ latestOffset,
904
+ latestCursor,
905
+ paused: false
906
+ };
907
+ let stopRequested = false;
908
+ let pauseRequested = false;
909
+ const processEvent = async () => {
910
+ if (dataLines.length === 0) {
911
+ currentEvent = "";
912
+ return;
913
+ }
914
+ const eventType = currentEvent.length === 0 ? "message" : currentEvent;
915
+ const data = dataLines.join("\n");
916
+ currentEvent = "";
917
+ dataLines = [];
918
+ if (eventType === "data") {
919
+ pendingDataEvents.push(data);
920
+ return;
921
+ }
922
+ if (eventType !== "control") return;
923
+ const control = parseSseControlData(data);
924
+ const updates = [];
925
+ for (const eventData of pendingDataEvents) for (const payload of decodeSseItemPayloads(eventData)) updates.push(payload);
926
+ pendingDataEvents = [];
927
+ const handlerResult = await nextOnControl(updates, control.nextOffset, control.upToDate, control.streamCursor);
928
+ latestOffset = control.nextOffset;
929
+ if (control.streamCursor != null) latestCursor = control.streamCursor;
930
+ if (handlerResult === "stop") {
931
+ stopRequested = true;
932
+ exhausted = true;
933
+ try {
934
+ await reader.cancel();
935
+ } catch {}
936
+ return;
937
+ }
938
+ if (handlerResult === "pause") pauseRequested = true;
939
+ };
940
+ while (true) {
941
+ if (stopRequested || pauseRequested) return {
942
+ kind: "ok",
943
+ latestOffset,
944
+ latestCursor,
945
+ paused: pauseRequested
946
+ };
947
+ const { done, value } = await reader.read();
948
+ if (value != null) {
949
+ pendingText += decoder.decode(value, { stream: true });
950
+ while (true) {
951
+ const newline = pendingText.indexOf("\n");
952
+ if (newline < 0) break;
953
+ const rawLine = pendingText.slice(0, newline);
954
+ pendingText = pendingText.slice(newline + 1);
955
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
956
+ if (line.length === 0) {
957
+ await processEvent();
958
+ if (stopRequested || pauseRequested) break;
959
+ continue;
960
+ }
961
+ if (line.startsWith(":")) continue;
962
+ if (line.startsWith("event:")) {
963
+ currentEvent = line.slice(6).trim();
964
+ continue;
965
+ }
966
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
967
+ }
968
+ }
969
+ if (stopRequested || pauseRequested) return {
970
+ kind: "ok",
971
+ latestOffset,
972
+ latestCursor,
973
+ paused: pauseRequested
974
+ };
975
+ if (!done) continue;
976
+ exhausted = true;
977
+ pendingText += decoder.decode();
978
+ if (pendingText.length > 0) {
979
+ const line = pendingText.endsWith("\r") ? pendingText.slice(0, -1) : pendingText;
980
+ if (line.length > 0 && line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
981
+ }
982
+ await processEvent();
983
+ return {
984
+ kind: "ok",
985
+ latestOffset,
986
+ latestCursor,
987
+ paused: pauseRequested
988
+ };
989
+ }
990
+ },
991
+ cancel: async () => {
992
+ if (exhausted) return;
993
+ exhausted = true;
994
+ try {
995
+ await reader.cancel();
996
+ } catch {}
997
+ }
998
+ }
999
+ };
1000
+ }
1001
+ };
1002
+ //#endregion
1003
+ //#region src/transport-cursor.ts
1004
+ var TransportCursorManager = class {
1005
+ adapter;
1006
+ saveRemoteCursor;
1007
+ constructor(options) {
1008
+ this.adapter = options.adapter;
1009
+ this.saveRemoteCursor = options.saveRemoteCursor;
1010
+ }
1011
+ async applyBootstrapPayload(cursor, snapshot, updates, nextOffset) {
1012
+ let nextVersion = cursor.serverLowerBoundVersion;
1013
+ if (snapshot.byteLength > 0) nextVersion = await this.adapter.applySnapshot(snapshot);
1014
+ if (updates.length > 0) nextVersion = await this.adapter.applyRemoteUpdates([...updates], nextVersion);
1015
+ return await this.saveRemoteCursor({
1016
+ ...cursor,
1017
+ nextOffset,
1018
+ serverLowerBoundVersion: nextVersion
1019
+ }, "bootstrap");
1020
+ }
1021
+ async applyBootstrapItems(cursor, items, nextOffset) {
1022
+ const snapshot = items[0] ?? new Uint8Array();
1023
+ return await this.applyBootstrapPayload(cursor, snapshot, items.slice(1), nextOffset);
1024
+ }
1025
+ async applyRemoteItems(cursor, items, nextOffset) {
1026
+ return await this.applyRemotePayload(cursor, [...items], nextOffset, "remote");
1027
+ }
1028
+ async applyCatchupResult(cursor, result, onRemoteVersionApplied) {
1029
+ return await this.applyRemotePayload(cursor, result.updates, result.nextOffset, "remote", onRemoteVersionApplied);
1030
+ }
1031
+ async applyLiveResult(cursor, requestOffset, result, onRemoteVersionApplied) {
1032
+ const previousVersion = cursor.serverLowerBoundVersion;
1033
+ let nextVersion = previousVersion;
1034
+ if (result.updates.length > 0) {
1035
+ nextVersion = await this.adapter.applyRemoteUpdates([...result.updates], nextVersion);
1036
+ await onRemoteVersionApplied?.(previousVersion, nextVersion);
1037
+ }
1038
+ return await this.saveRemoteCursor({
1039
+ ...cursor,
1040
+ nextOffset: resolveNextOffsetAfterConcurrentAdvance(requestOffset, cursor.nextOffset, result.nextOffset),
1041
+ serverLowerBoundVersion: nextVersion
1042
+ }, "remote");
1043
+ }
1044
+ async applyRemotePayload(cursor, updates, nextOffset, source, onRemoteVersionApplied) {
1045
+ const previousVersion = cursor.serverLowerBoundVersion;
1046
+ let nextVersion = previousVersion;
1047
+ if (updates.length > 0) {
1048
+ nextVersion = await this.adapter.applyRemoteUpdates([...updates], nextVersion);
1049
+ await onRemoteVersionApplied?.(previousVersion, nextVersion);
1050
+ }
1051
+ return await this.saveRemoteCursor({
1052
+ ...cursor,
1053
+ nextOffset,
1054
+ serverLowerBoundVersion: nextVersion
1055
+ }, source);
1056
+ }
1057
+ };
1058
+ //#endregion
1059
+ //#region src/deque.ts
1060
+ const DEFAULT_COMPACT_THRESHOLD = 64;
1061
+ const DEFAULT_MIN_CAPACITY = 16;
1062
+ var Deque = class {
1063
+ buffer;
1064
+ head = 0;
1065
+ tail = 0;
1066
+ compactThreshold;
1067
+ minCapacity;
1068
+ constructor(values, options = {}) {
1069
+ this.compactThreshold = Math.max(1, options.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD);
1070
+ this.minCapacity = Math.max(1, options.minCapacity ?? DEFAULT_MIN_CAPACITY);
1071
+ const seed = values == null ? [] : Array.from(values);
1072
+ this.buffer = seed.slice();
1073
+ this.tail = seed.length;
1074
+ }
1075
+ get length() {
1076
+ return this.tail - this.head;
1077
+ }
1078
+ isEmpty() {
1079
+ return this.length === 0;
1080
+ }
1081
+ clear() {
1082
+ this.buffer = [];
1083
+ this.head = 0;
1084
+ this.tail = 0;
1085
+ }
1086
+ peekFront() {
1087
+ if (this.length === 0) return;
1088
+ const value = this.buffer[this.head];
1089
+ return value == null ? void 0 : value;
1090
+ }
1091
+ peekBack() {
1092
+ if (this.length === 0) return;
1093
+ const value = this.buffer[this.tail - 1];
1094
+ return value == null ? void 0 : value;
1095
+ }
1096
+ at(index) {
1097
+ const length = this.length;
1098
+ if (length === 0) return;
1099
+ let offset = index;
1100
+ if (offset < 0) offset += length;
1101
+ if (offset < 0 || offset >= length) return;
1102
+ const value = this.buffer[this.head + offset];
1103
+ return value == null ? void 0 : value;
1104
+ }
1105
+ pushBack(value) {
1106
+ this.ensureBackCapacity();
1107
+ this.buffer[this.tail] = value;
1108
+ this.tail += 1;
1109
+ return this.length;
1110
+ }
1111
+ pushFront(value) {
1112
+ if (this.head === 0) this.recenter(1, 0);
1113
+ this.head -= 1;
1114
+ this.buffer[this.head] = value;
1115
+ return this.length;
1116
+ }
1117
+ popFront() {
1118
+ if (this.length === 0) return;
1119
+ const value = this.buffer[this.head];
1120
+ this.buffer[this.head] = null;
1121
+ this.head += 1;
1122
+ this.compactIfNeeded();
1123
+ return value == null ? void 0 : value;
1124
+ }
1125
+ popBack() {
1126
+ if (this.length === 0) return;
1127
+ this.tail -= 1;
1128
+ const value = this.buffer[this.tail];
1129
+ this.buffer[this.tail] = null;
1130
+ this.compactIfNeeded();
1131
+ return value == null ? void 0 : value;
1132
+ }
1133
+ toArray() {
1134
+ const out = [];
1135
+ for (let i = this.head; i < this.tail; i += 1) out.push(this.buffer[i]);
1136
+ return out;
1137
+ }
1138
+ *[Symbol.iterator]() {
1139
+ for (let i = this.head; i < this.tail; i += 1) yield this.buffer[i];
1140
+ }
1141
+ ensureBackCapacity() {
1142
+ if (this.tail < this.buffer.length) return;
1143
+ if (this.head > 0) {
1144
+ this.compactToFront();
1145
+ if (this.tail < this.buffer.length) return;
1146
+ }
1147
+ this.recenter(0, 1);
1148
+ }
1149
+ recenter(extraFront, extraBack) {
1150
+ const length = this.length;
1151
+ const needed = length + extraFront + extraBack;
1152
+ const currentCapacity = this.buffer.length;
1153
+ let nextCapacity = Math.max(currentCapacity, this.minCapacity);
1154
+ if (nextCapacity < needed) nextCapacity = Math.max(nextCapacity * 2, needed, this.minCapacity);
1155
+ const free = nextCapacity - length;
1156
+ const nextHead = Math.max(extraFront, Math.floor(free / 2));
1157
+ const next = new Array(nextCapacity);
1158
+ for (let i = 0; i < length; i += 1) next[nextHead + i] = this.buffer[this.head + i];
1159
+ this.buffer = next;
1160
+ this.head = nextHead;
1161
+ this.tail = nextHead + length;
1162
+ }
1163
+ compactIfNeeded() {
1164
+ const length = this.length;
1165
+ if (length === 0) {
1166
+ this.clear();
1167
+ return;
1168
+ }
1169
+ if (this.head < this.compactThreshold || this.head < length) return;
1170
+ this.compactToFront();
1171
+ }
1172
+ compactToFront() {
1173
+ const length = this.length;
1174
+ this.buffer = this.buffer.slice(this.head, this.tail);
1175
+ this.head = 0;
1176
+ this.tail = length;
1177
+ }
1178
+ };
1179
+ //#endregion
1180
+ //#region src/transport-snapshot.ts
1181
+ function createTransportSnapshotState() {
1182
+ return {
1183
+ observedForeignWriter: false,
1184
+ taskScheduled: false,
1185
+ taskNeedsRerun: false
1186
+ };
1187
+ }
1188
+ /**
1189
+ * Manages debounce and upload gating for remote snapshots.
1190
+ *
1191
+ * All "is this state still current / closed / reconnecting / blocked by a
1192
+ * foreign writer?" checks live here so the runtime does not duplicate them.
1193
+ */
1194
+ var TransportSnapshotManager = class {
1195
+ adapter;
1196
+ config;
1197
+ readSnapshotOffsetValue;
1198
+ putSnapshotAt;
1199
+ enqueueExclusive;
1200
+ logDebug;
1201
+ logError;
1202
+ isCurrentState;
1203
+ constructor(options) {
1204
+ this.adapter = options.adapter;
1205
+ this.config = options.config;
1206
+ this.readSnapshotOffsetValue = options.readSnapshotOffsetValue;
1207
+ this.putSnapshotAt = options.putSnapshotAt;
1208
+ this.enqueueExclusive = options.enqueueExclusive;
1209
+ this.logDebug = options.logDebug;
1210
+ this.logError = options.logError;
1211
+ this.isCurrentState = options.isCurrentState;
1212
+ }
1213
+ dispose(state) {
1214
+ this.clearDebounceTimer(state.snapshot);
1215
+ }
1216
+ observeRemoteVersion(state, previousVersion, nextVersion) {
1217
+ const snapshot = state.snapshot;
1218
+ if (snapshot.debounceTimer == null) return;
1219
+ if (!jsonObjectEquals(previousVersion, nextVersion)) snapshot.observedForeignWriter = true;
1220
+ }
1221
+ schedule(state) {
1222
+ const config = this.config;
1223
+ if (config == null || state.closed) return;
1224
+ const snapshot = state.snapshot;
1225
+ this.clearDebounceTimer(snapshot);
1226
+ snapshot.observedForeignWriter = false;
1227
+ snapshot.debounceTimer = setTimeout(() => {
1228
+ this.handleDebounceExpiry(state);
1229
+ }, config.debounceMs);
1230
+ }
1231
+ clearDebounceTimer(snapshot) {
1232
+ if (snapshot.debounceTimer != null) {
1233
+ clearTimeout(snapshot.debounceTimer);
1234
+ snapshot.debounceTimer = void 0;
1235
+ }
1236
+ }
1237
+ async handleDebounceExpiry(state) {
1238
+ const snapshot = state.snapshot;
1239
+ snapshot.debounceTimer = void 0;
1240
+ if (this.getRunnableConfig(state) == null) return;
1241
+ if (snapshot.taskScheduled) {
1242
+ snapshot.taskNeedsRerun = true;
1243
+ return;
1244
+ }
1245
+ snapshot.taskScheduled = true;
1246
+ this.enqueueExclusive(async () => {
1247
+ await this.maybeUpload(state);
1248
+ }).catch((error) => {
1249
+ this.logError("snapshot upload task failed", error);
1250
+ }).finally(() => {
1251
+ snapshot.taskScheduled = false;
1252
+ if (snapshot.taskNeedsRerun && !state.closed) {
1253
+ snapshot.taskNeedsRerun = false;
1254
+ this.handleDebounceExpiry(state);
1255
+ }
1256
+ });
1257
+ }
1258
+ async maybeUpload(state) {
1259
+ const config = await this.resolveUploadConfig(state);
1260
+ if (config == null) return;
1261
+ const tailBytes = parseSnapshotByteOffset(state.cursor.nextOffset);
1262
+ if (tailBytes == null) {
1263
+ this.logDebug("skip snapshot upload: non-numeric offset token", { offset: state.cursor.nextOffset });
1264
+ return;
1265
+ }
1266
+ try {
1267
+ const remoteSnapshotOffset = await this.readSnapshotOffsetValue();
1268
+ const snapshotBytes = parseSnapshotByteOffset(remoteSnapshotOffset);
1269
+ if (snapshotBytes == null) {
1270
+ this.logDebug("skip snapshot upload: non-numeric snapshot offset", { offset: remoteSnapshotOffset });
1271
+ return;
1272
+ }
1273
+ if (tailBytes <= snapshotBytes) return;
1274
+ if (tailBytes - snapshotBytes < config.minBytesSinceRemoteSnapshot) return;
1275
+ const snapshot = this.adapter.exportSnapshot();
1276
+ await this.putSnapshotAt(state.cursor.nextOffset, snapshot);
1277
+ } catch (error) {
1278
+ this.logError("snapshot upload failed", error);
1279
+ }
1280
+ }
1281
+ getRunnableConfig(state) {
1282
+ const config = this.config;
1283
+ if (config == null || state.closed || !this.isCurrentState(state)) return;
1284
+ if (state.snapshot.observedForeignWriter || state.status === "reconnecting" || state.status === "disconnected" || state.status === "error") return;
1285
+ return config;
1286
+ }
1287
+ async resolveUploadConfig(state) {
1288
+ const config = this.getRunnableConfig(state);
1289
+ if (config == null) return;
1290
+ if (!await this.canUpload(config)) return;
1291
+ return config;
1292
+ }
1293
+ async canUpload(config) {
1294
+ try {
1295
+ return Boolean(await config.canUpload());
1296
+ } catch (error) {
1297
+ this.logError("snapshot upload permission check failed", error);
1298
+ return false;
1299
+ }
1300
+ }
1301
+ };
1302
+ //#endregion
1303
+ //#region src/transport-room.ts
1304
+ function createJoinState(cursor, liveMode = "sse") {
1305
+ return {
1306
+ refCount: 0,
1307
+ closed: false,
1308
+ status: "connecting",
1309
+ statusListeners: /* @__PURE__ */ new Set(),
1310
+ pendingLocal: new Deque(),
1311
+ flushingLocal: false,
1312
+ retryAttempt: 0,
1313
+ cursor,
1314
+ liveMode,
1315
+ snapshot: createTransportSnapshotState(),
1316
+ started: Promise.resolve()
1317
+ };
1318
+ }
1319
+ async function disposeJoinState(state, options) {
1320
+ state.closed = true;
1321
+ options.snapshotManager.dispose(state);
1322
+ const liveSseSession = state.liveSseSession;
1323
+ state.liveSseSession = void 0;
1324
+ const requestController = state.requestController;
1325
+ state.requestController = void 0;
1326
+ const unsubscribeLocal = state.unsubscribeLocal;
1327
+ state.unsubscribeLocal = void 0;
1328
+ requestController?.abort();
1329
+ unsubscribeLocal?.();
1330
+ options.onDisposed?.();
1331
+ await liveSseSession?.cancel();
1332
+ }
1333
+ //#endregion
1334
+ //#region src/transport-runtime.ts
1335
+ /**
1336
+ * Durable Streams transport runtime bound to one stream URL and one local CRDT adapter.
1337
+ */
1338
+ var StreamsCrdt = class StreamsCrdt {
1339
+ streamUrl;
1340
+ adapter;
1341
+ remoteCursorStore;
1342
+ createStreamIfMissing;
1343
+ client;
1344
+ cursorManager;
1345
+ reconnectConfig;
1346
+ debug;
1347
+ beforeRemoteCursorSave;
1348
+ snapshotManager;
1349
+ producerId = createProducerId();
1350
+ producerEpoch = 0;
1351
+ nextProducerSeq = 0;
1352
+ joinState;
1353
+ opQueue = Promise.resolve();
1354
+ /**
1355
+ * Creates a transport for one stream URL and one local adapter instance.
1356
+ */
1357
+ constructor(options) {
1358
+ this.streamUrl = options.streamUrl;
1359
+ this.adapter = options.adapter;
1360
+ const authProvider = normalizeAuthProvider(options.auth);
1361
+ this.createStreamIfMissing = options.createStreamIfMissing ?? false;
1362
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
1363
+ this.remoteCursorStore = options.remoteCursorStore ?? new InMemoryRemoteCursorStore();
1364
+ this.reconnectConfig = {
1365
+ ...DEFAULT_RECONNECT_CONFIG,
1366
+ ...options.reconnectConfig
1367
+ };
1368
+ this.debug = options.debug ?? false;
1369
+ this.beforeRemoteCursorSave = composeBeforeRemoteCursorSaveHooks(options.beforeRemoteCursorSave, ...options.beforeRemoteCursorSaveHooks ?? []);
1370
+ const snapshotUploadConfig = normalizeSnapshotUploadConfig(options.snapshotUpload);
1371
+ this.client = new StreamsTransportClient({
1372
+ streamUrl: this.streamUrl,
1373
+ adapter: this.adapter,
1374
+ authProvider,
1375
+ fetchImpl,
1376
+ reconnectConfig: this.reconnectConfig
1377
+ });
1378
+ this.cursorManager = new TransportCursorManager({
1379
+ adapter: this.adapter,
1380
+ saveRemoteCursor: (cursor, source) => this.saveRemoteCursor(cursor, source)
1381
+ });
1382
+ this.snapshotManager = new TransportSnapshotManager({
1383
+ adapter: this.adapter,
1384
+ config: snapshotUploadConfig,
1385
+ readSnapshotOffsetValue: () => this.readSnapshotOffsetValue(),
1386
+ putSnapshotAt: (snapshotOffset, snapshot) => this.putSnapshotAt(snapshotOffset, snapshot),
1387
+ enqueueExclusive: (task) => this.enqueueExclusive(task),
1388
+ logDebug: (message, detail) => this.logDebug(message, detail),
1389
+ logError: (message, error, detail) => this.logError(message, error, detail),
1390
+ isCurrentState: (state) => this.joinState === state
1391
+ });
1392
+ }
1393
+ /**
1394
+ * Stops any active live join and releases transport-owned state.
1395
+ */
1396
+ async close() {
1397
+ const state = this.joinState;
1398
+ if (state != null) await this.disposeJoinState(state);
1399
+ }
1400
+ /**
1401
+ * Reads the latest remote snapshot offset, if the server exposes one.
1402
+ */
1403
+ async getSnapshotOffset() {
1404
+ try {
1405
+ return {
1406
+ ok: true,
1407
+ value: await this.readSnapshotOffsetValue()
1408
+ };
1409
+ } catch (error) {
1410
+ return {
1411
+ ok: false,
1412
+ error: toTransportError(error)
1413
+ };
1414
+ }
1415
+ }
1416
+ /**
1417
+ * Explicitly creates the target stream.
1418
+ */
1419
+ async createStream() {
1420
+ try {
1421
+ return {
1422
+ ok: true,
1423
+ value: { created: await this.createStreamRequest() }
1424
+ };
1425
+ } catch (error) {
1426
+ return {
1427
+ ok: false,
1428
+ error: toTransportError(error)
1429
+ };
1430
+ }
1431
+ }
1432
+ /**
1433
+ * Deletes the target stream and clears persisted replay state when supported by the cursor store.
1434
+ */
1435
+ async deleteStream() {
1436
+ try {
1437
+ await this.close();
1438
+ return {
1439
+ ok: true,
1440
+ value: { deleted: await this.enqueueExclusive(async () => {
1441
+ const deleted = await this.deleteStreamRequest();
1442
+ await this.remoteCursorStore.delete?.(this.streamUrl);
1443
+ this.producerEpoch = 0;
1444
+ this.nextProducerSeq = 0;
1445
+ return deleted;
1446
+ }) }
1447
+ };
1448
+ } catch (error) {
1449
+ return {
1450
+ ok: false,
1451
+ error: toTransportError(error)
1452
+ };
1453
+ }
1454
+ }
1455
+ /**
1456
+ * Runs one bootstrap/catch-up cycle without entering live mode.
1457
+ */
1458
+ async sync() {
1459
+ try {
1460
+ return {
1461
+ ok: true,
1462
+ value: { cursor: await this.syncOnce() }
1463
+ };
1464
+ } catch (error) {
1465
+ return {
1466
+ ok: false,
1467
+ error: toTransportError(error)
1468
+ };
1469
+ }
1470
+ }
1471
+ /**
1472
+ * Replays remote state, flushes queued local updates, then enters live read mode.
1473
+ */
1474
+ async join(params) {
1475
+ const existing = this.joinState;
1476
+ if (existing != null) try {
1477
+ await existing.started;
1478
+ return {
1479
+ ok: true,
1480
+ value: this.createSubscription(existing, params)
1481
+ };
1482
+ } catch (error) {
1483
+ return {
1484
+ ok: false,
1485
+ error: toTransportError(error)
1486
+ };
1487
+ }
1488
+ const state = createJoinState(createInitialRemoteCursor({
1489
+ streamUrl: this.streamUrl,
1490
+ nextOffset: "-1",
1491
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1492
+ }));
1493
+ state.started = this.startJoin(state);
1494
+ this.joinState = state;
1495
+ try {
1496
+ await state.started;
1497
+ return {
1498
+ ok: true,
1499
+ value: this.createSubscription(state, params)
1500
+ };
1501
+ } catch (error) {
1502
+ await this.disposeJoinState(state);
1503
+ return {
1504
+ ok: false,
1505
+ error: toTransportError(error)
1506
+ };
1507
+ }
1508
+ }
1509
+ createSubscription(state, params) {
1510
+ state.refCount += 1;
1511
+ const statusListener = params?.onStatusChange;
1512
+ if (statusListener != null) {
1513
+ state.statusListeners.add(statusListener);
1514
+ statusListener(state.status);
1515
+ }
1516
+ let unsubscribed = false;
1517
+ return {
1518
+ unsubscribe: () => {
1519
+ if (unsubscribed) return;
1520
+ unsubscribed = true;
1521
+ if (statusListener != null) state.statusListeners.delete(statusListener);
1522
+ state.refCount = Math.max(0, state.refCount - 1);
1523
+ if (state.refCount !== 0 || state.closed) return;
1524
+ this.disposeJoinState(state);
1525
+ },
1526
+ get connected() {
1527
+ return state.status === "joined";
1528
+ },
1529
+ get status() {
1530
+ return state.status;
1531
+ },
1532
+ onStatusChange: (listener) => {
1533
+ state.statusListeners.add(listener);
1534
+ listener(state.status);
1535
+ return () => {
1536
+ state.statusListeners.delete(listener);
1537
+ };
1538
+ }
1539
+ };
1540
+ }
1541
+ async disposeJoinState(state) {
1542
+ await disposeJoinState(state, {
1543
+ snapshotManager: this.snapshotManager,
1544
+ onDisposed: () => {
1545
+ if (this.joinState === state) this.joinState = void 0;
1546
+ }
1547
+ });
1548
+ }
1549
+ async startJoin(state) {
1550
+ try {
1551
+ state.requestController ??= new AbortController();
1552
+ const joined = await this.performInitialJoinSync(state.liveMode, state.requestController.signal);
1553
+ state.cursor = joined.cursor;
1554
+ state.streamCursor = joined.streamCursor;
1555
+ state.liveMode = joined.liveMode;
1556
+ state.liveSseSession = joined.liveSseSession;
1557
+ const pendingLocal = this.exportUpdates(joined.localExportRefVersion);
1558
+ const hasQueuedPreSubscribeBatch = pendingLocal.batch != null;
1559
+ if (pendingLocal.batch != null) state.pendingLocal.pushBack(pendingLocal.batch);
1560
+ if (state.closed) return;
1561
+ state.unsubscribeLocal = this.adapter.subscribeLocalUpdates((batch) => {
1562
+ state.pendingLocal.pushBack(batch);
1563
+ this.flushPendingLocal(state);
1564
+ });
1565
+ if (hasQueuedPreSubscribeBatch) {
1566
+ await this.flushPendingLocal(state);
1567
+ if (state.status === "error" || state.status === "disconnected") throw new Error("join local sync failed before subscription became ready");
1568
+ }
1569
+ if (state.closed) return;
1570
+ this.setJoinStatus(state, "joined");
1571
+ this.runReadLoop(state);
1572
+ } catch (error) {
1573
+ if (state.closed) return;
1574
+ this.snapshotManager.dispose(state);
1575
+ this.logError("join first sync failed", error, { streamUrl: this.streamUrl });
1576
+ this.setJoinStatus(state, "error");
1577
+ throw error;
1578
+ }
1579
+ }
1580
+ async syncOnce() {
1581
+ const loaded = await this.remoteCursorStore.load(this.streamUrl);
1582
+ let cursor = loaded ?? createInitialRemoteCursor({
1583
+ streamUrl: this.streamUrl,
1584
+ nextOffset: "-1",
1585
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1586
+ });
1587
+ try {
1588
+ if (loaded == null) cursor = await this.bootstrapIntoState(cursor);
1589
+ else cursor = (await this.catchup(cursor)).cursor;
1590
+ } catch (error) {
1591
+ if (!isStreamNotFoundError(error) || !this.createStreamIfMissing) throw error;
1592
+ await this.createStreamRequest();
1593
+ cursor = await this.bootstrapIntoState({
1594
+ ...cursor,
1595
+ nextOffset: "-1",
1596
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1597
+ });
1598
+ }
1599
+ const localBatch = this.exportUpdates(cursor.serverLowerBoundVersion);
1600
+ if (localBatch.batch != null) {
1601
+ cursor = await this.appendLocalBatch(cursor, localBatch.batch);
1602
+ cursor = (await this.catchup(cursor)).cursor;
1603
+ }
1604
+ return cursor;
1605
+ }
1606
+ async performInitialJoinSync(preferredLiveMode, signal) {
1607
+ const loaded = await this.remoteCursorStore.load(this.streamUrl);
1608
+ let cursor = loaded ?? createInitialRemoteCursor({
1609
+ streamUrl: this.streamUrl,
1610
+ nextOffset: "-1",
1611
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1612
+ });
1613
+ let localExportRefVersion = cursor.serverLowerBoundVersion;
1614
+ let streamCursor;
1615
+ let liveMode = preferredLiveMode;
1616
+ let liveSseSession;
1617
+ try {
1618
+ if (loaded == null) {
1619
+ const joined = await this.bootstrapIntoJoinedState(cursor, liveMode, signal);
1620
+ cursor = joined.cursor;
1621
+ streamCursor = joined.streamCursor;
1622
+ liveMode = joined.liveMode;
1623
+ liveSseSession = joined.liveSseSession;
1624
+ } else {
1625
+ const caughtUp = await this.catchup(cursor);
1626
+ cursor = caughtUp.cursor;
1627
+ streamCursor = caughtUp.streamCursor;
1628
+ }
1629
+ } catch (error) {
1630
+ if (!isStreamNotFoundError(error) || !this.createStreamIfMissing) throw error;
1631
+ await this.createStreamRequest();
1632
+ const joined = await this.bootstrapIntoJoinedState({
1633
+ ...cursor,
1634
+ nextOffset: "-1",
1635
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1636
+ }, liveMode, signal);
1637
+ cursor = joined.cursor;
1638
+ streamCursor = joined.streamCursor;
1639
+ liveMode = joined.liveMode;
1640
+ liveSseSession = joined.liveSseSession;
1641
+ }
1642
+ localExportRefVersion = cursor.serverLowerBoundVersion;
1643
+ const exportedLocal = this.exportUpdates(localExportRefVersion);
1644
+ localExportRefVersion = exportedLocal.nextRefVersion;
1645
+ if (exportedLocal.batch != null) {
1646
+ cursor = await this.appendLocalBatch(cursor, exportedLocal.batch);
1647
+ if (liveSseSession != null) {
1648
+ const continued = await this.continueJoinedSseSessionUntilCaughtUp(liveSseSession, cursor);
1649
+ cursor = continued.cursor;
1650
+ streamCursor = continued.streamCursor;
1651
+ liveSseSession = continued.liveSseSession;
1652
+ } else {
1653
+ const caughtUp = await this.catchup(cursor);
1654
+ cursor = caughtUp.cursor;
1655
+ streamCursor = caughtUp.streamCursor;
1656
+ }
1657
+ }
1658
+ localExportRefVersion = cursor.serverLowerBoundVersion;
1659
+ return {
1660
+ cursor,
1661
+ streamCursor,
1662
+ liveMode,
1663
+ liveSseSession,
1664
+ localExportRefVersion
1665
+ };
1666
+ }
1667
+ async bootstrapIntoState(cursor) {
1668
+ const result = await this.bootstrap(this.streamUrl);
1669
+ const saved = await this.cursorManager.applyBootstrapPayload(cursor, result.snapshot, result.updates, result.nextOffset);
1670
+ if (!result.upToDate) return (await this.catchup(saved)).cursor;
1671
+ return saved;
1672
+ }
1673
+ async bootstrapIntoJoinedState(cursor, preferredLiveMode, signal) {
1674
+ if (preferredLiveMode === "sse") {
1675
+ const sseResult = await this.bootstrapIntoJoinedStateViaSse(cursor, signal);
1676
+ if (sseResult.kind === "ok") return {
1677
+ ...sseResult,
1678
+ liveMode: "sse"
1679
+ };
1680
+ }
1681
+ return {
1682
+ ...await this.bootstrapIntoJoinedStateViaLongPoll(cursor),
1683
+ liveMode: "long-poll"
1684
+ };
1685
+ }
1686
+ async bootstrapIntoJoinedStateViaSse(cursor, signal) {
1687
+ let current = cursor;
1688
+ let currentStreamCursor;
1689
+ let expectBootstrapFrame = true;
1690
+ let caughtUp = false;
1691
+ const opened = await this.openBootstrapSseSession(this.streamUrl, signal);
1692
+ if (opened.kind === "unsupported") return { kind: "unsupported" };
1693
+ if (opened.kind === "not-found") throw new HttpError("bootstrap live sse failed with status 404", 404);
1694
+ if (opened.kind === "gone") return {
1695
+ kind: "ok",
1696
+ cursor: await this.bootstrapIntoState({
1697
+ ...current,
1698
+ nextOffset: "-1",
1699
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1700
+ })
1701
+ };
1702
+ const result = await opened.session.continue(async (items, nextOffset, upToDate, streamCursor) => {
1703
+ if (expectBootstrapFrame) {
1704
+ current = await this.applyBootstrapItems(current, items, nextOffset);
1705
+ expectBootstrapFrame = false;
1706
+ } else current = await this.applyRemoteItems(current, items, nextOffset);
1707
+ if (streamCursor != null) currentStreamCursor = streamCursor;
1708
+ if (upToDate) {
1709
+ caughtUp = true;
1710
+ return "pause";
1711
+ }
1712
+ });
1713
+ if (!caughtUp || !result.paused) {
1714
+ const resumed = await this.catchup(current, void 0, currentStreamCursor);
1715
+ await opened.session.cancel();
1716
+ return {
1717
+ kind: "ok",
1718
+ cursor: resumed.cursor,
1719
+ streamCursor: resumed.streamCursor
1720
+ };
1721
+ }
1722
+ return {
1723
+ kind: "ok",
1724
+ cursor: current,
1725
+ streamCursor: currentStreamCursor,
1726
+ liveSseSession: opened.session
1727
+ };
1728
+ }
1729
+ async continueJoinedSseSessionUntilCaughtUp(session, cursor) {
1730
+ let current = cursor;
1731
+ let currentStreamCursor;
1732
+ let caughtUp = false;
1733
+ const result = await session.continue(async (items, nextOffset, upToDate, streamCursor) => {
1734
+ current = await this.applyRemoteItems(current, items, nextOffset);
1735
+ if (streamCursor != null) currentStreamCursor = streamCursor;
1736
+ if (upToDate) {
1737
+ caughtUp = true;
1738
+ return "pause";
1739
+ }
1740
+ });
1741
+ if (!caughtUp || !result.paused) {
1742
+ const resumed = await this.catchup(current, void 0, currentStreamCursor);
1743
+ await session.cancel();
1744
+ return {
1745
+ cursor: resumed.cursor,
1746
+ streamCursor: resumed.streamCursor
1747
+ };
1748
+ }
1749
+ return {
1750
+ cursor: current,
1751
+ streamCursor: currentStreamCursor,
1752
+ liveSseSession: session
1753
+ };
1754
+ }
1755
+ async bootstrapIntoJoinedStateViaLongPoll(cursor) {
1756
+ let current = cursor;
1757
+ let currentStreamCursor;
1758
+ let expectBootstrapFrame = true;
1759
+ while (true) {
1760
+ const response = await this.readBootstrapLongPoll(this.streamUrl, currentStreamCursor);
1761
+ if (response.kind === "gone") return { cursor: await this.bootstrapIntoState({
1762
+ ...current,
1763
+ nextOffset: "-1",
1764
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1765
+ }) };
1766
+ const result = response.value;
1767
+ if (expectBootstrapFrame) {
1768
+ current = await this.applyBootstrapItems(current, result.items, result.nextOffset);
1769
+ expectBootstrapFrame = false;
1770
+ } else current = await this.applyRemoteItems(current, result.items, result.nextOffset);
1771
+ currentStreamCursor = result.streamCursor;
1772
+ if (result.upToDate) return {
1773
+ cursor: current,
1774
+ streamCursor: currentStreamCursor
1775
+ };
1776
+ }
1777
+ }
1778
+ async catchup(cursor, live, streamCursor, onRemoteVersionApplied) {
1779
+ let current = cursor;
1780
+ let currentStreamCursor = streamCursor;
1781
+ while (true) {
1782
+ const requestOffset = current.nextOffset;
1783
+ const response = await this.readFromOffset(this.streamUrl, requestOffset, currentStreamCursor, live);
1784
+ if (response.kind === "gone") return { cursor: await this.bootstrapIntoState({
1785
+ ...current,
1786
+ nextOffset: "-1",
1787
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1788
+ }) };
1789
+ const result = response.value;
1790
+ if (isReadNoProgress(requestOffset, result)) throw new Error(`catch-up made no progress at offset ${requestOffset}; server returned empty updates and unchanged next offset with up_to_date=false`);
1791
+ current = await this.cursorManager.applyCatchupResult(current, result, onRemoteVersionApplied);
1792
+ currentStreamCursor = result.streamCursor;
1793
+ if (result.upToDate) return {
1794
+ cursor: current,
1795
+ streamCursor: currentStreamCursor
1796
+ };
1797
+ }
1798
+ }
1799
+ async appendLocalBatch(cursor, batch) {
1800
+ let producerSession = {
1801
+ producerId: this.producerId,
1802
+ producerEpoch: this.producerEpoch,
1803
+ nextProducerSeq: this.nextProducerSeq
1804
+ };
1805
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1806
+ const result = await this.appendUpdates(this.streamUrl, batch.updates, producerSession);
1807
+ if (result.kind === "ok") {
1808
+ this.producerEpoch = result.ackEpoch;
1809
+ this.nextProducerSeq = result.ackSeq + 1;
1810
+ const next = {
1811
+ ...cursor,
1812
+ nextOffset: result.nextOffset,
1813
+ serverLowerBoundVersion: this.adapter.mergeVersions(cursor.serverLowerBoundVersion, batch.version)
1814
+ };
1815
+ return await this.saveRemoteCursor(next, "local");
1816
+ }
1817
+ if (result.kind === "seq-gap") {
1818
+ producerSession = {
1819
+ ...producerSession,
1820
+ nextProducerSeq: result.expectedSeq
1821
+ };
1822
+ continue;
1823
+ }
1824
+ producerSession = {
1825
+ ...producerSession,
1826
+ producerEpoch: result.serverEpoch + 1,
1827
+ nextProducerSeq: 0
1828
+ };
1829
+ }
1830
+ throw new Error("append retry budget exhausted");
1831
+ }
1832
+ exportUpdates(fromVersion) {
1833
+ const exported = this.adapter.exportUpdates(fromVersion);
1834
+ if (exported == null) return { nextRefVersion: fromVersion };
1835
+ const nextRefVersion = this.adapter.mergeVersions(fromVersion, exported.version);
1836
+ if (exported.updates.length === 0) return { nextRefVersion };
1837
+ return {
1838
+ batch: exported,
1839
+ nextRefVersion
1840
+ };
1841
+ }
1842
+ async applyBootstrapItems(cursor, items, nextOffset) {
1843
+ return await this.cursorManager.applyBootstrapItems(cursor, items, nextOffset);
1844
+ }
1845
+ async applyRemoteItems(cursor, items, nextOffset) {
1846
+ return await this.cursorManager.applyRemoteItems(cursor, items, nextOffset);
1847
+ }
1848
+ async runReadLoop(state) {
1849
+ state.requestController ??= new AbortController();
1850
+ while (!state.closed) try {
1851
+ if (state.liveMode === "long-poll") {
1852
+ const requestOffset = state.cursor.nextOffset;
1853
+ const response = await this.readFromOffset(this.streamUrl, requestOffset, state.streamCursor, "long-poll", state.requestController?.signal);
1854
+ if (response.kind === "gone") {
1855
+ state.cursor = await this.bootstrapIntoState({
1856
+ ...state.cursor,
1857
+ nextOffset: "-1",
1858
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1859
+ });
1860
+ state.streamCursor = void 0;
1861
+ state.retryAttempt = 0;
1862
+ continue;
1863
+ }
1864
+ const result = response.value;
1865
+ if (isReadNoProgress(requestOffset, result)) throw new Error(`catch-up made no progress at offset ${requestOffset}; server returned empty updates and unchanged next offset with up_to_date=false`);
1866
+ await this.enqueueExclusive(async () => {
1867
+ if (state.closed) return;
1868
+ state.cursor = await this.cursorManager.applyLiveResult(state.cursor, requestOffset, result, (previousVersion, nextVersion) => {
1869
+ this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
1870
+ });
1871
+ if (result.streamCursor != null) state.streamCursor = result.streamCursor;
1872
+ });
1873
+ if (state.closed) return;
1874
+ state.retryAttempt = 0;
1875
+ this.setJoinStatus(state, "joined");
1876
+ await sleep(0);
1877
+ continue;
1878
+ }
1879
+ if (state.liveSseSession != null) {
1880
+ const session = state.liveSseSession;
1881
+ const sseRequestOffset = state.cursor.nextOffset;
1882
+ const sseResult = await session.continue(async (updates, nextOffset, _upToDate, streamCursor) => {
1883
+ await this.enqueueExclusive(async () => {
1884
+ if (state.closed) return;
1885
+ state.cursor = await this.cursorManager.applyLiveResult(state.cursor, sseRequestOffset, {
1886
+ nextOffset,
1887
+ updates
1888
+ }, (previousVersion, nextVersion) => {
1889
+ this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
1890
+ });
1891
+ if (streamCursor != null) state.streamCursor = streamCursor;
1892
+ });
1893
+ this.setJoinStatus(state, "joined");
1894
+ });
1895
+ if (state.closed) return;
1896
+ state.liveSseSession = void 0;
1897
+ state.retryAttempt = 0;
1898
+ state.streamCursor = sseResult.latestCursor;
1899
+ await sleep(0);
1900
+ continue;
1901
+ }
1902
+ const sseRequestOffset = state.cursor.nextOffset;
1903
+ const sseResult = await this.readFromOffsetSseItems(this.streamUrl, sseRequestOffset, state.streamCursor, async (updates, nextOffset, _upToDate, streamCursor) => {
1904
+ await this.enqueueExclusive(async () => {
1905
+ if (state.closed) return;
1906
+ state.cursor = await this.cursorManager.applyLiveResult(state.cursor, sseRequestOffset, {
1907
+ nextOffset,
1908
+ updates
1909
+ }, (previousVersion, nextVersion) => {
1910
+ this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
1911
+ });
1912
+ if (streamCursor != null) state.streamCursor = streamCursor;
1913
+ });
1914
+ this.setJoinStatus(state, "joined");
1915
+ }, () => {
1916
+ this.setJoinStatus(state, "joined");
1917
+ }, state.requestController?.signal);
1918
+ if (state.closed) return;
1919
+ if (sseResult.kind === "ok") {
1920
+ state.retryAttempt = 0;
1921
+ state.streamCursor = sseResult.latestCursor;
1922
+ await sleep(0);
1923
+ continue;
1924
+ }
1925
+ if (sseResult.kind === "unsupported") {
1926
+ state.liveMode = "long-poll";
1927
+ await sleep(0);
1928
+ continue;
1929
+ }
1930
+ if (sseResult.kind === "not-found") {
1931
+ this.logError("live read failed", new HttpError("stream not found", 404), {
1932
+ streamUrl: this.streamUrl,
1933
+ code: "stream_not_found"
1934
+ });
1935
+ this.setJoinStatus(state, "error");
1936
+ return;
1937
+ }
1938
+ state.cursor = await this.bootstrapIntoState({
1939
+ ...state.cursor,
1940
+ nextOffset: "-1",
1941
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1942
+ });
1943
+ state.streamCursor = void 0;
1944
+ state.retryAttempt = 0;
1945
+ } catch (error) {
1946
+ if (state.closed) return;
1947
+ const liveSseSession = state.liveSseSession;
1948
+ state.liveSseSession = void 0;
1949
+ await liveSseSession?.cancel();
1950
+ const classified = classifyFetchError(error);
1951
+ if (!classified.retriable || isAuthOrProtocolError(error)) {
1952
+ this.logError("live read failed", error, {
1953
+ streamUrl: this.streamUrl,
1954
+ code: classified.code
1955
+ });
1956
+ this.setJoinStatus(state, "error");
1957
+ return;
1958
+ }
1959
+ this.setJoinStatus(state, "reconnecting");
1960
+ const maxAttempts = this.reconnectConfig.maxAttempts;
1961
+ if (state.retryAttempt >= maxAttempts) {
1962
+ this.logError("live read reconnect exhausted", error, {
1963
+ streamUrl: this.streamUrl,
1964
+ attempts: state.retryAttempt
1965
+ });
1966
+ this.setJoinStatus(state, "disconnected");
1967
+ return;
1968
+ }
1969
+ const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig);
1970
+ state.retryAttempt += 1;
1971
+ await sleep(delay);
1972
+ }
1973
+ }
1974
+ /** Maximum encoded size (bytes) for a single batched append. */
1975
+ static MAX_BATCH_BYTES = 256 * 1024;
1976
+ /**
1977
+ * Drain up to `MAX_BATCH_BYTES` worth of queued batches from the front of
1978
+ * `pendingLocal` and merge them into a single `CrdtUpdateBatch`.
1979
+ *
1980
+ * Returns `undefined` when the queue is empty.
1981
+ */
1982
+ drainPendingBatch(state) {
1983
+ const first = state.pendingLocal.peekFront();
1984
+ if (first == null) return;
1985
+ const maxBytes = StreamsCrdt.MAX_BATCH_BYTES;
1986
+ let totalBytes = first.updates.reduce((sum, u) => sum + 4 + u.byteLength, 0);
1987
+ let mergedVersion = first.version;
1988
+ const allUpdates = Array.from(first.updates);
1989
+ let count = 1;
1990
+ while (count < state.pendingLocal.length) {
1991
+ const next = state.pendingLocal.at(count);
1992
+ const nextBytes = next.updates.reduce((sum, u) => sum + 4 + u.byteLength, 0);
1993
+ if (totalBytes + nextBytes > maxBytes) break;
1994
+ allUpdates.push(...next.updates);
1995
+ totalBytes += nextBytes;
1996
+ mergedVersion = this.adapter.mergeVersions(mergedVersion, next.version);
1997
+ count += 1;
1998
+ }
1999
+ if (count === 1) return {
2000
+ batch: first,
2001
+ count: 1
2002
+ };
2003
+ return {
2004
+ batch: {
2005
+ updates: allUpdates,
2006
+ version: mergedVersion
2007
+ },
2008
+ count
2009
+ };
2010
+ }
2011
+ async flushPendingLocal(state) {
2012
+ if (state.flushingLocal || state.closed) return;
2013
+ state.flushingLocal = true;
2014
+ try {
2015
+ while (!state.closed) {
2016
+ const drained = this.drainPendingBatch(state);
2017
+ if (drained == null) return;
2018
+ let attempts = 0;
2019
+ while (!state.closed) try {
2020
+ state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch));
2021
+ for (let i = 0; i < drained.count; i += 1) state.pendingLocal.popFront();
2022
+ this.setJoinStatus(state, "joined");
2023
+ this.snapshotManager.schedule(state);
2024
+ break;
2025
+ } catch (error) {
2026
+ const classified = classifyFetchError(error);
2027
+ if (!classified.retriable || isAuthOrProtocolError(error)) {
2028
+ this.logError("local append failed", error, {
2029
+ streamUrl: this.streamUrl,
2030
+ code: classified.code
2031
+ });
2032
+ this.setJoinStatus(state, "error");
2033
+ return;
2034
+ }
2035
+ const maxAttempts = this.reconnectConfig.maxAttempts;
2036
+ if (attempts >= maxAttempts) {
2037
+ this.setJoinStatus(state, "error");
2038
+ return;
2039
+ }
2040
+ this.setJoinStatus(state, "reconnecting");
2041
+ const delay = computeRetryDelay(attempts, this.reconnectConfig);
2042
+ attempts += 1;
2043
+ await sleep(delay);
2044
+ }
2045
+ }
2046
+ } finally {
2047
+ state.flushingLocal = false;
2048
+ }
2049
+ }
2050
+ async saveRemoteCursor(cursor, source) {
2051
+ return await persistRemoteCursor({
2052
+ remoteCursorStore: this.remoteCursorStore,
2053
+ cursor,
2054
+ source,
2055
+ beforeRemoteCursorSave: this.beforeRemoteCursorSave
2056
+ });
2057
+ }
2058
+ setJoinStatus(state, status) {
2059
+ if (state.status === status) return;
2060
+ state.status = status;
2061
+ for (const listener of state.statusListeners) listener(status);
2062
+ }
2063
+ async createStreamRequest() {
2064
+ return await this.client.createStreamRequest();
2065
+ }
2066
+ async deleteStreamRequest() {
2067
+ return await this.client.deleteStreamRequest();
2068
+ }
2069
+ async readFromOffsetSseItems(_streamUrl, offset, cursor, onControl, onOpen, signal) {
2070
+ return await this.client.readFromOffsetSseItems(offset, cursor, onControl, onOpen, signal);
2071
+ }
2072
+ async openBootstrapSseSession(_streamUrl, signal) {
2073
+ return await this.client.openBootstrapSseSession(signal);
2074
+ }
2075
+ async readBootstrapLongPoll(_streamUrl, cursor, signal) {
2076
+ return await this.client.readBootstrapLongPoll(cursor, signal);
2077
+ }
2078
+ async readFromOffset(_streamUrl, offset, cursor, live, signal) {
2079
+ return await this.client.readFromOffset(offset, cursor, live, signal);
2080
+ }
2081
+ async bootstrap(_streamUrl) {
2082
+ return await this.client.bootstrap();
2083
+ }
2084
+ async readSnapshotOffsetValue() {
2085
+ return await this.client.readSnapshotOffsetValue();
2086
+ }
2087
+ async putSnapshotAt(snapshotOffset, snapshot) {
2088
+ await this.client.putSnapshotAt(snapshotOffset, snapshot);
2089
+ }
2090
+ async appendUpdates(_streamUrl, updates, producerSession) {
2091
+ return await this.client.appendUpdates(updates, producerSession);
2092
+ }
2093
+ enqueueExclusive(task) {
2094
+ const run = this.opQueue.then(task, task);
2095
+ this.opQueue = run.then(() => void 0, () => void 0);
2096
+ return run;
2097
+ }
2098
+ logDebug(message, detail) {
2099
+ if (!this.debug) return;
2100
+ if (detail == null) {
2101
+ console.debug(`[streams-crdt] ${message}`);
2102
+ return;
2103
+ }
2104
+ console.debug(`[streams-crdt] ${message}`, detail);
2105
+ }
2106
+ logError(message, error, detail) {
2107
+ const payload = error == null ? detail : {
2108
+ ...detail,
2109
+ error: error instanceof Error ? error.message : String(error)
2110
+ };
2111
+ if (payload == null) {
2112
+ console.error(`[streams-crdt] ${message}`);
2113
+ return;
2114
+ }
2115
+ console.error(`[streams-crdt] ${message}`, payload);
2116
+ }
2117
+ };
2118
+ //#endregion
2119
+ //#region src/stream-id.ts
2120
+ const MAX_STREAM_ID_BYTES = 512;
2121
+ const textEncoder = new TextEncoder();
2122
+ const DEFAULT_BASE_URL = "https://streams-api.loro.dev";
2123
+ function utf8ByteLength(value) {
2124
+ return textEncoder.encode(value).byteLength;
2125
+ }
2126
+ /**
2127
+ * Validates a stream id accepted by `createStreamUrl()`.
2128
+ */
2129
+ function isValidRillId(id) {
2130
+ return id.length > 0 && utf8ByteLength(id) <= MAX_STREAM_ID_BYTES && !id.includes("/") && !id.includes("\0") && !id.includes("..");
2131
+ }
2132
+ /**
2133
+ * Validates a bucket id accepted by `createStreamUrl()`.
2134
+ */
2135
+ function isValidBucketId(id) {
2136
+ return isValidRillId(id);
2137
+ }
2138
+ /**
2139
+ * Builds a Durable Streams HTTP URL for one `(bucketId, streamId)` pair.
2140
+ */
2141
+ function createStreamUrl(input) {
2142
+ const baseUrl = (input.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/g, "");
2143
+ if (!isValidBucketId(input.bucketId)) throw new Error(`invalid bucketId: ${JSON.stringify(input.bucketId)} (must be non-empty UTF-8, <= ${MAX_STREAM_ID_BYTES} bytes, and exclude "/", "\\0", "..")`);
2144
+ if (!isValidRillId(input.streamId)) throw new Error(`invalid streamId: ${JSON.stringify(input.streamId)} (must be non-empty UTF-8, <= ${MAX_STREAM_ID_BYTES} bytes, and exclude "/", "\\0", "..")`);
2145
+ const streamId = input.streamId;
2146
+ return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
2147
+ }
2148
+ //#endregion
2149
+ export { InMemoryRemoteCursorStore as a, StreamsCrdt as i, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t };
2150
+
2151
+ //# sourceMappingURL=stream-id-CIhmveJD.js.map