@loro-dev/streams-crdt 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +305 -1
- package/dist/flock.d.ts +2 -2
- package/dist/flock.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/loro.d.ts +2 -2
- package/dist/loro.js +2 -2
- package/dist/{stream-id-Do4h2lOD.d.ts → stream-id-CS7SzuLW.d.ts} +163 -6
- package/dist/{stream-id-DBym_2Mj.js → stream-id-CbVwCS3g.js} +915 -280
- package/dist/stream-id-CbVwCS3g.js.map +1 -0
- package/package.json +5 -3
- package/dist/stream-id-DBym_2Mj.js.map +0 -1
|
@@ -184,7 +184,8 @@ const DEFAULT_RECONNECT_CONFIG = {
|
|
|
184
184
|
jitterFraction: .2,
|
|
185
185
|
maxAttempts: Infinity,
|
|
186
186
|
connectTimeoutMs: 1e4,
|
|
187
|
-
pollTimeoutMs: 3e4
|
|
187
|
+
pollTimeoutMs: 3e4,
|
|
188
|
+
liveInactivityTimeoutMs: 45e3
|
|
188
189
|
};
|
|
189
190
|
/**
|
|
190
191
|
* Apply jitter of +/- fraction to a base delay.
|
|
@@ -205,6 +206,127 @@ function computeRetryDelay(attempt, config = DEFAULT_RECONNECT_CONFIG) {
|
|
|
205
206
|
return applyJitter(base, jitterFraction);
|
|
206
207
|
}
|
|
207
208
|
//#endregion
|
|
209
|
+
//#region src/deque.ts
|
|
210
|
+
const DEFAULT_COMPACT_THRESHOLD = 64;
|
|
211
|
+
const DEFAULT_MIN_CAPACITY = 16;
|
|
212
|
+
var Deque = class {
|
|
213
|
+
buffer;
|
|
214
|
+
head = 0;
|
|
215
|
+
tail = 0;
|
|
216
|
+
compactThreshold;
|
|
217
|
+
minCapacity;
|
|
218
|
+
constructor(values, options = {}) {
|
|
219
|
+
this.compactThreshold = Math.max(1, options.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD);
|
|
220
|
+
this.minCapacity = Math.max(1, options.minCapacity ?? DEFAULT_MIN_CAPACITY);
|
|
221
|
+
const seed = values == null ? [] : Array.from(values);
|
|
222
|
+
this.buffer = seed.slice();
|
|
223
|
+
this.tail = seed.length;
|
|
224
|
+
}
|
|
225
|
+
get length() {
|
|
226
|
+
return this.tail - this.head;
|
|
227
|
+
}
|
|
228
|
+
isEmpty() {
|
|
229
|
+
return this.length === 0;
|
|
230
|
+
}
|
|
231
|
+
clear() {
|
|
232
|
+
this.buffer = [];
|
|
233
|
+
this.head = 0;
|
|
234
|
+
this.tail = 0;
|
|
235
|
+
}
|
|
236
|
+
peekFront() {
|
|
237
|
+
if (this.length === 0) return;
|
|
238
|
+
const value = this.buffer[this.head];
|
|
239
|
+
return value == null ? void 0 : value;
|
|
240
|
+
}
|
|
241
|
+
peekBack() {
|
|
242
|
+
if (this.length === 0) return;
|
|
243
|
+
const value = this.buffer[this.tail - 1];
|
|
244
|
+
return value == null ? void 0 : value;
|
|
245
|
+
}
|
|
246
|
+
at(index) {
|
|
247
|
+
const length = this.length;
|
|
248
|
+
if (length === 0) return;
|
|
249
|
+
let offset = index;
|
|
250
|
+
if (offset < 0) offset += length;
|
|
251
|
+
if (offset < 0 || offset >= length) return;
|
|
252
|
+
const value = this.buffer[this.head + offset];
|
|
253
|
+
return value == null ? void 0 : value;
|
|
254
|
+
}
|
|
255
|
+
pushBack(value) {
|
|
256
|
+
this.ensureBackCapacity();
|
|
257
|
+
this.buffer[this.tail] = value;
|
|
258
|
+
this.tail += 1;
|
|
259
|
+
return this.length;
|
|
260
|
+
}
|
|
261
|
+
pushFront(value) {
|
|
262
|
+
if (this.head === 0) this.recenter(1, 0);
|
|
263
|
+
this.head -= 1;
|
|
264
|
+
this.buffer[this.head] = value;
|
|
265
|
+
return this.length;
|
|
266
|
+
}
|
|
267
|
+
popFront() {
|
|
268
|
+
if (this.length === 0) return;
|
|
269
|
+
const value = this.buffer[this.head];
|
|
270
|
+
this.buffer[this.head] = null;
|
|
271
|
+
this.head += 1;
|
|
272
|
+
this.compactIfNeeded();
|
|
273
|
+
return value == null ? void 0 : value;
|
|
274
|
+
}
|
|
275
|
+
popBack() {
|
|
276
|
+
if (this.length === 0) return;
|
|
277
|
+
this.tail -= 1;
|
|
278
|
+
const value = this.buffer[this.tail];
|
|
279
|
+
this.buffer[this.tail] = null;
|
|
280
|
+
this.compactIfNeeded();
|
|
281
|
+
return value == null ? void 0 : value;
|
|
282
|
+
}
|
|
283
|
+
toArray() {
|
|
284
|
+
const out = [];
|
|
285
|
+
for (let i = this.head; i < this.tail; i += 1) out.push(this.buffer[i]);
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
*[Symbol.iterator]() {
|
|
289
|
+
for (let i = this.head; i < this.tail; i += 1) yield this.buffer[i];
|
|
290
|
+
}
|
|
291
|
+
ensureBackCapacity() {
|
|
292
|
+
if (this.tail < this.buffer.length) return;
|
|
293
|
+
if (this.head > 0) {
|
|
294
|
+
this.compactToFront();
|
|
295
|
+
if (this.tail < this.buffer.length) return;
|
|
296
|
+
}
|
|
297
|
+
this.recenter(0, 1);
|
|
298
|
+
}
|
|
299
|
+
recenter(extraFront, extraBack) {
|
|
300
|
+
const length = this.length;
|
|
301
|
+
const needed = length + extraFront + extraBack;
|
|
302
|
+
const currentCapacity = this.buffer.length;
|
|
303
|
+
let nextCapacity = Math.max(currentCapacity, this.minCapacity);
|
|
304
|
+
if (nextCapacity < needed) nextCapacity = Math.max(nextCapacity * 2, needed, this.minCapacity);
|
|
305
|
+
const free = nextCapacity - length;
|
|
306
|
+
const nextHead = Math.max(extraFront, Math.floor(free / 2));
|
|
307
|
+
const next = new Array(nextCapacity);
|
|
308
|
+
for (let i = 0; i < length; i += 1) next[nextHead + i] = this.buffer[this.head + i];
|
|
309
|
+
this.buffer = next;
|
|
310
|
+
this.head = nextHead;
|
|
311
|
+
this.tail = nextHead + length;
|
|
312
|
+
}
|
|
313
|
+
compactIfNeeded() {
|
|
314
|
+
const length = this.length;
|
|
315
|
+
if (length === 0) {
|
|
316
|
+
this.clear();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (this.head < this.compactThreshold || this.head < length) return;
|
|
320
|
+
this.compactToFront();
|
|
321
|
+
}
|
|
322
|
+
compactToFront() {
|
|
323
|
+
const length = this.length;
|
|
324
|
+
this.buffer = this.buffer.slice(this.head, this.tail);
|
|
325
|
+
this.head = 0;
|
|
326
|
+
this.tail = length;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
//#endregion
|
|
208
330
|
//#region src/items.ts
|
|
209
331
|
const BINARY_CONTENT_TYPE = "application/octet-stream";
|
|
210
332
|
function encodeItems(items) {
|
|
@@ -235,6 +357,290 @@ function decodeItems(payload) {
|
|
|
235
357
|
return out;
|
|
236
358
|
}
|
|
237
359
|
//#endregion
|
|
360
|
+
//#region src/payload-protection.ts
|
|
361
|
+
const MAGIC = new Uint8Array([
|
|
362
|
+
76,
|
|
363
|
+
83,
|
|
364
|
+
67,
|
|
365
|
+
69
|
|
366
|
+
]);
|
|
367
|
+
const ENVELOPE_VERSION = 1;
|
|
368
|
+
const SUITE_AES_256_GCM = 1;
|
|
369
|
+
const UPDATE_BATCH_KIND = 1;
|
|
370
|
+
const SNAPSHOT_KIND = 2;
|
|
371
|
+
const AES_GCM_NONCE_BYTES = 12;
|
|
372
|
+
const HEADER_BYTES = 10;
|
|
373
|
+
const AES_GCM_TAG_BITS = 128;
|
|
374
|
+
const RAW_AES_256_KEY_BYTES = 32;
|
|
375
|
+
const textEncoder$1 = new TextEncoder();
|
|
376
|
+
const textDecoder = new TextDecoder();
|
|
377
|
+
var PayloadProtectionError = class extends Error {
|
|
378
|
+
reason;
|
|
379
|
+
keyId;
|
|
380
|
+
constructor(reason, message, options = {}) {
|
|
381
|
+
super(message, options.cause == null ? void 0 : { cause: options.cause });
|
|
382
|
+
this.name = "PayloadProtectionError";
|
|
383
|
+
this.reason = reason;
|
|
384
|
+
this.keyId = options.keyId;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
var PayloadProtectionRuntime = class {
|
|
388
|
+
readPolicy;
|
|
389
|
+
writePolicy;
|
|
390
|
+
scope;
|
|
391
|
+
writeKey;
|
|
392
|
+
readKeys;
|
|
393
|
+
importedWriteKey;
|
|
394
|
+
importedReadKeys = /* @__PURE__ */ new Map();
|
|
395
|
+
constructor(options) {
|
|
396
|
+
this.readPolicy = options.readPolicy;
|
|
397
|
+
this.writePolicy = options.writePolicy;
|
|
398
|
+
this.scope = options.scope;
|
|
399
|
+
this.writeKey = options.writeKey;
|
|
400
|
+
this.readKeys = options.readKeys;
|
|
401
|
+
}
|
|
402
|
+
async encodeUpdateItems(clearUpdates) {
|
|
403
|
+
if (this.writePolicy === "plaintext") return clearUpdates;
|
|
404
|
+
return [await this.encryptPayload("update_batch", encodeItems(clearUpdates))];
|
|
405
|
+
}
|
|
406
|
+
measureEncodedUpdateItemsByteLength(clearUpdates) {
|
|
407
|
+
if (this.writePolicy === "plaintext") return encodedItemsByteLength(clearUpdates);
|
|
408
|
+
const writeKey = this.writeKey;
|
|
409
|
+
if (writeKey == null || this.scope == null) throw new PayloadProtectionError("encrypt_failed", "E2EE write encryption requires encryption.writeKey and encryption.scope");
|
|
410
|
+
const clearBatchBytes = encodedItemsByteLength(clearUpdates);
|
|
411
|
+
const keyIdBytes = textEncoder$1.encode(writeKey.id);
|
|
412
|
+
if (keyIdBytes.byteLength === 0 || keyIdBytes.byteLength > 255) throw new PayloadProtectionError("encrypt_failed", "E2EE key id must be 1..255 UTF-8 bytes", { keyId: writeKey.id });
|
|
413
|
+
return 4 + (HEADER_BYTES + keyIdBytes.byteLength + AES_GCM_NONCE_BYTES + clearBatchBytes + AES_GCM_TAG_BITS / 8);
|
|
414
|
+
}
|
|
415
|
+
async decodeUpdateItems(wireItems) {
|
|
416
|
+
const out = [];
|
|
417
|
+
for (const item of wireItems) {
|
|
418
|
+
if (isEncryptedEnvelope(item)) {
|
|
419
|
+
const clearBatch = await this.decryptPayload("update_batch", item);
|
|
420
|
+
out.push(...decodeItems(clearBatch));
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
this.assertPlaintextAllowed("update batch");
|
|
424
|
+
out.push(item);
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
async encodeSnapshot(snapshot) {
|
|
429
|
+
if (this.writePolicy === "plaintext") return snapshot;
|
|
430
|
+
return await this.encryptPayload("snapshot", snapshot);
|
|
431
|
+
}
|
|
432
|
+
async decodeSnapshot(snapshot) {
|
|
433
|
+
if (isEncryptedEnvelope(snapshot)) return await this.decryptPayload("snapshot", snapshot);
|
|
434
|
+
this.assertPlaintextAllowed("snapshot");
|
|
435
|
+
return snapshot;
|
|
436
|
+
}
|
|
437
|
+
async encryptPayload(kind, plaintext) {
|
|
438
|
+
const writeKey = this.writeKey;
|
|
439
|
+
if (writeKey == null || this.scope == null) throw new PayloadProtectionError("encrypt_failed", "E2EE write encryption requires encryption.writeKey and encryption.scope");
|
|
440
|
+
try {
|
|
441
|
+
const key = await (this.importedWriteKey ??= importAesGcmKey(writeKey.key));
|
|
442
|
+
const nonce = createNonce();
|
|
443
|
+
return concatBytes(createEnvelopeHeader(kind, writeKey.id, nonce), new Uint8Array(await getSubtleCrypto().encrypt({
|
|
444
|
+
name: "AES-GCM",
|
|
445
|
+
iv: toArrayBuffer(nonce),
|
|
446
|
+
tagLength: AES_GCM_TAG_BITS,
|
|
447
|
+
additionalData: toArrayBuffer(createAad({
|
|
448
|
+
kind,
|
|
449
|
+
keyId: writeKey.id,
|
|
450
|
+
scope: this.scope
|
|
451
|
+
}))
|
|
452
|
+
}, key, toArrayBuffer(plaintext))));
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (error instanceof PayloadProtectionError) throw error;
|
|
455
|
+
throw new PayloadProtectionError("encrypt_failed", "failed to encrypt streams-crdt payload", {
|
|
456
|
+
keyId: writeKey.id,
|
|
457
|
+
cause: error
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
async decryptPayload(expectedKind, envelope) {
|
|
462
|
+
const parsed = parseEnvelope(envelope);
|
|
463
|
+
if (parsed.kind !== expectedKind) throw new PayloadProtectionError("wrong_payload_kind", `expected ${expectedKind} payload, got ${parsed.kind}`, { keyId: parsed.keyId });
|
|
464
|
+
if (this.scope == null) throw new PayloadProtectionError("missing_read_key", "E2EE read decryption requires encryption.scope", { keyId: parsed.keyId });
|
|
465
|
+
const key = await this.getReadKey(parsed.keyId);
|
|
466
|
+
try {
|
|
467
|
+
return new Uint8Array(await getSubtleCrypto().decrypt({
|
|
468
|
+
name: "AES-GCM",
|
|
469
|
+
iv: toArrayBuffer(parsed.nonce),
|
|
470
|
+
tagLength: AES_GCM_TAG_BITS,
|
|
471
|
+
additionalData: toArrayBuffer(createAad({
|
|
472
|
+
kind: parsed.kind,
|
|
473
|
+
keyId: parsed.keyId,
|
|
474
|
+
scope: this.scope
|
|
475
|
+
}))
|
|
476
|
+
}, key, toArrayBuffer(parsed.ciphertext)));
|
|
477
|
+
} catch (error) {
|
|
478
|
+
throw new PayloadProtectionError("decrypt_failed", "failed to decrypt streams-crdt payload", {
|
|
479
|
+
keyId: parsed.keyId,
|
|
480
|
+
cause: error
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async getReadKey(keyId) {
|
|
485
|
+
const cached = this.importedReadKeys.get(keyId);
|
|
486
|
+
if (cached != null) return await cached;
|
|
487
|
+
const imported = importAesGcmKey((await this.resolveReadKey(keyId)).key);
|
|
488
|
+
this.importedReadKeys.set(keyId, imported);
|
|
489
|
+
return await imported;
|
|
490
|
+
}
|
|
491
|
+
async resolveReadKey(keyId) {
|
|
492
|
+
for (const key of await this.resolveReadKeys()) if (key.id === keyId) return key;
|
|
493
|
+
throw new PayloadProtectionError("missing_read_key", `missing read key for protected streams-crdt payload '${keyId}'`, { keyId });
|
|
494
|
+
}
|
|
495
|
+
async resolveReadKeys() {
|
|
496
|
+
const readKeys = typeof this.readKeys === "function" ? await this.readKeys() : this.readKeys;
|
|
497
|
+
if (readKeys != null) return readKeys;
|
|
498
|
+
return this.writeKey == null ? [] : [this.writeKey];
|
|
499
|
+
}
|
|
500
|
+
assertPlaintextAllowed(label) {
|
|
501
|
+
if (this.readPolicy === "allow-plaintext") return;
|
|
502
|
+
throw new PayloadProtectionError("plaintext_forbidden", `received plaintext streams-crdt ${label} while readPolicy is encrypted-only`);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
function normalizePayloadProtection(options, label = "payloadProtection") {
|
|
506
|
+
if (options == null) return;
|
|
507
|
+
const readPolicy = options.readPolicy ?? "encrypted-only";
|
|
508
|
+
const writePolicy = options.writePolicy ?? "encrypt";
|
|
509
|
+
if (readPolicy !== "encrypted-only" && readPolicy !== "allow-plaintext") throw new Error(`${label}.readPolicy must be encrypted-only or allow-plaintext`);
|
|
510
|
+
if (writePolicy !== "encrypt" && writePolicy !== "plaintext") throw new Error(`${label}.writePolicy must be encrypt or plaintext`);
|
|
511
|
+
const encryption = options.encryption;
|
|
512
|
+
if (encryption != null && encryption.scope == null) throw new Error(`${label}.encryption.scope is required when encryption is configured`);
|
|
513
|
+
if (writePolicy === "encrypt" && encryption?.writeKey == null) throw new Error(`${label}.encryption.writeKey is required when writePolicy is encrypt`);
|
|
514
|
+
if (writePolicy === "encrypt" && encryption?.scope == null) throw new Error(`${label}.encryption.scope is required when writePolicy is encrypt`);
|
|
515
|
+
validateKey(encryption?.writeKey, `${label}.encryption.writeKey`);
|
|
516
|
+
const readKeys = normalizeReadKeys(encryption?.readKeys, `${label}.encryption.readKeys`);
|
|
517
|
+
return new PayloadProtectionRuntime({
|
|
518
|
+
readPolicy,
|
|
519
|
+
writePolicy,
|
|
520
|
+
scope: encryption?.scope,
|
|
521
|
+
writeKey: encryption?.writeKey,
|
|
522
|
+
readKeys
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
function isPayloadProtectionError(error) {
|
|
526
|
+
return error instanceof PayloadProtectionError;
|
|
527
|
+
}
|
|
528
|
+
function isEncryptedEnvelope(payload) {
|
|
529
|
+
return payload.byteLength >= MAGIC.byteLength && MAGIC.every((byte, index) => payload[index] === byte);
|
|
530
|
+
}
|
|
531
|
+
function createEnvelopeHeader(kind, keyId, nonce) {
|
|
532
|
+
const keyIdBytes = textEncoder$1.encode(keyId);
|
|
533
|
+
if (keyIdBytes.byteLength === 0 || keyIdBytes.byteLength > 255) throw new PayloadProtectionError("encrypt_failed", "E2EE key id must be 1..255 UTF-8 bytes", { keyId });
|
|
534
|
+
if (nonce.byteLength !== AES_GCM_NONCE_BYTES) throw new PayloadProtectionError("encrypt_failed", `AES-GCM nonce must be ${AES_GCM_NONCE_BYTES} bytes`, { keyId });
|
|
535
|
+
const header = new Uint8Array(HEADER_BYTES + keyIdBytes.byteLength + nonce.byteLength);
|
|
536
|
+
header.set(MAGIC, 0);
|
|
537
|
+
header[4] = ENVELOPE_VERSION;
|
|
538
|
+
header[5] = encodeKind(kind);
|
|
539
|
+
header[6] = SUITE_AES_256_GCM;
|
|
540
|
+
header[7] = keyIdBytes.byteLength;
|
|
541
|
+
header[8] = nonce.byteLength;
|
|
542
|
+
header[9] = 0;
|
|
543
|
+
header.set(keyIdBytes, HEADER_BYTES);
|
|
544
|
+
header.set(nonce, HEADER_BYTES + keyIdBytes.byteLength);
|
|
545
|
+
return header;
|
|
546
|
+
}
|
|
547
|
+
function parseEnvelope(payload) {
|
|
548
|
+
if (!isEncryptedEnvelope(payload) || payload.byteLength < HEADER_BYTES) throw new PayloadProtectionError("invalid_envelope", "invalid streams-crdt encrypted payload envelope");
|
|
549
|
+
const version = payload[4];
|
|
550
|
+
const suite = payload[6];
|
|
551
|
+
const keyIdLen = payload[7] ?? 0;
|
|
552
|
+
const nonceLen = payload[8] ?? 0;
|
|
553
|
+
const reserved = payload[9];
|
|
554
|
+
if (version !== ENVELOPE_VERSION || suite !== SUITE_AES_256_GCM || nonceLen !== AES_GCM_NONCE_BYTES || reserved !== 0) throw new PayloadProtectionError("invalid_envelope", "unsupported streams-crdt encrypted payload envelope");
|
|
555
|
+
const kind = decodeKind(payload[5]);
|
|
556
|
+
const keyIdStart = HEADER_BYTES;
|
|
557
|
+
const nonceStart = keyIdStart + keyIdLen;
|
|
558
|
+
const ciphertextStart = nonceStart + nonceLen;
|
|
559
|
+
if (keyIdLen === 0 || payload.byteLength <= ciphertextStart) throw new PayloadProtectionError("invalid_envelope", "truncated streams-crdt encrypted payload envelope");
|
|
560
|
+
return {
|
|
561
|
+
kind,
|
|
562
|
+
keyId: textDecoder.decode(payload.slice(keyIdStart, nonceStart)),
|
|
563
|
+
nonce: payload.slice(nonceStart, ciphertextStart),
|
|
564
|
+
ciphertext: payload.slice(ciphertextStart)
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function encodeKind(kind) {
|
|
568
|
+
return kind === "update_batch" ? UPDATE_BATCH_KIND : SNAPSHOT_KIND;
|
|
569
|
+
}
|
|
570
|
+
function decodeKind(encoded) {
|
|
571
|
+
if (encoded === UPDATE_BATCH_KIND) return "update_batch";
|
|
572
|
+
if (encoded === SNAPSHOT_KIND) return "snapshot";
|
|
573
|
+
throw new PayloadProtectionError("invalid_envelope", "unsupported streams-crdt encrypted payload kind");
|
|
574
|
+
}
|
|
575
|
+
function createNonce() {
|
|
576
|
+
const nonce = new Uint8Array(AES_GCM_NONCE_BYTES);
|
|
577
|
+
getCrypto().getRandomValues(nonce);
|
|
578
|
+
return nonce;
|
|
579
|
+
}
|
|
580
|
+
function createAad(input) {
|
|
581
|
+
return textEncoder$1.encode(JSON.stringify({
|
|
582
|
+
keyId: input.keyId,
|
|
583
|
+
kind: input.kind,
|
|
584
|
+
protocol: "loro-streams-crdt-payload-protection",
|
|
585
|
+
scope: normalizeScope(input.scope),
|
|
586
|
+
suite: "aes_256_gcm",
|
|
587
|
+
version: ENVELOPE_VERSION
|
|
588
|
+
}));
|
|
589
|
+
}
|
|
590
|
+
function normalizeScope(scope) {
|
|
591
|
+
return typeof scope === "string" ? scope : `bucket:${scope.bucketId}\nstream:${scope.streamId}`;
|
|
592
|
+
}
|
|
593
|
+
function concatBytes(left, right) {
|
|
594
|
+
const out = new Uint8Array(left.byteLength + right.byteLength);
|
|
595
|
+
out.set(left);
|
|
596
|
+
out.set(right, left.byteLength);
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
function encodedItemsByteLength(items) {
|
|
600
|
+
return items.reduce((sum, item) => sum + 4 + item.byteLength, 0);
|
|
601
|
+
}
|
|
602
|
+
async function importAesGcmKey(key) {
|
|
603
|
+
if (isCryptoKey(key)) return key;
|
|
604
|
+
validateRawKey(key);
|
|
605
|
+
return await getSubtleCrypto().importKey("raw", toArrayBuffer(key), "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
606
|
+
}
|
|
607
|
+
function toArrayBuffer(bytes) {
|
|
608
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
609
|
+
}
|
|
610
|
+
function validateKey(key, label) {
|
|
611
|
+
if (key == null) return;
|
|
612
|
+
if (key.id.trim().length === 0) throw new Error(`${label}.id must be non-empty`);
|
|
613
|
+
if (key.key instanceof Uint8Array) validateRawKey(key.key);
|
|
614
|
+
}
|
|
615
|
+
function normalizeReadKeys(readKeys, label) {
|
|
616
|
+
if (readKeys == null) return;
|
|
617
|
+
if (typeof readKeys === "function") return async () => {
|
|
618
|
+
const resolved = await readKeys();
|
|
619
|
+
validateKeys(resolved, label);
|
|
620
|
+
return resolved;
|
|
621
|
+
};
|
|
622
|
+
validateKeys(readKeys, label);
|
|
623
|
+
return readKeys;
|
|
624
|
+
}
|
|
625
|
+
function validateKeys(keys, label) {
|
|
626
|
+
for (const [index, key] of keys.entries()) validateKey(key, `${label}[${index}]`);
|
|
627
|
+
}
|
|
628
|
+
function isCryptoKey(key) {
|
|
629
|
+
return typeof CryptoKey !== "undefined" && key instanceof CryptoKey;
|
|
630
|
+
}
|
|
631
|
+
function validateRawKey(key) {
|
|
632
|
+
if (key.byteLength !== RAW_AES_256_KEY_BYTES) throw new Error(`E2EE raw AES-GCM keys must be ${RAW_AES_256_KEY_BYTES} bytes`);
|
|
633
|
+
}
|
|
634
|
+
function getCrypto() {
|
|
635
|
+
if (globalThis.crypto == null) throw new PayloadProtectionError("encrypt_failed", "Web Crypto is unavailable in this runtime");
|
|
636
|
+
return globalThis.crypto;
|
|
637
|
+
}
|
|
638
|
+
function getSubtleCrypto() {
|
|
639
|
+
const subtle = getCrypto().subtle;
|
|
640
|
+
if (subtle == null) throw new PayloadProtectionError("encrypt_failed", "SubtleCrypto is unavailable in this runtime");
|
|
641
|
+
return subtle;
|
|
642
|
+
}
|
|
643
|
+
//#endregion
|
|
238
644
|
//#region src/transport-internal.ts
|
|
239
645
|
function createProducerId() {
|
|
240
646
|
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
@@ -381,6 +787,12 @@ function classifyFetchError(err) {
|
|
|
381
787
|
retriable: true,
|
|
382
788
|
atMs: Date.now()
|
|
383
789
|
};
|
|
790
|
+
if (isPayloadProtectionError(err)) return {
|
|
791
|
+
code: "protocol",
|
|
792
|
+
message: err.message,
|
|
793
|
+
retriable: false,
|
|
794
|
+
atMs: Date.now()
|
|
795
|
+
};
|
|
384
796
|
return {
|
|
385
797
|
code: "internal",
|
|
386
798
|
message,
|
|
@@ -429,6 +841,13 @@ function toTransportError(error) {
|
|
|
429
841
|
retryable: true,
|
|
430
842
|
message: error.message
|
|
431
843
|
};
|
|
844
|
+
if (isPayloadProtectionError(error)) return {
|
|
845
|
+
code: "payload_protection_error",
|
|
846
|
+
retryable: false,
|
|
847
|
+
reason: error.reason,
|
|
848
|
+
keyId: error.keyId,
|
|
849
|
+
message: error.message
|
|
850
|
+
};
|
|
432
851
|
if (error instanceof TypeError) return {
|
|
433
852
|
code: "network_error",
|
|
434
853
|
retryable: true,
|
|
@@ -677,13 +1096,29 @@ var StreamsTransportClient = class {
|
|
|
677
1096
|
async readItemsSse(initialOffset, cursor, onControl, onOpen, signal) {
|
|
678
1097
|
const opened = await this.openItemsSseSession(initialOffset, cursor, onOpen, signal);
|
|
679
1098
|
if (opened.kind !== "ok") return opened;
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1099
|
+
let abortHandler;
|
|
1100
|
+
const abortRace = signal != null && !signal.aborted ? new Promise((_, reject) => {
|
|
1101
|
+
abortHandler = () => {
|
|
1102
|
+
opened.session.cancel();
|
|
1103
|
+
reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
1104
|
+
};
|
|
1105
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
1106
|
+
}) : null;
|
|
1107
|
+
try {
|
|
1108
|
+
if (signal?.aborted) {
|
|
1109
|
+
await opened.session.cancel();
|
|
1110
|
+
throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
1111
|
+
}
|
|
1112
|
+
const result = abortRace != null ? await Promise.race([opened.session.continue(onControl), abortRace]) : await opened.session.continue(onControl);
|
|
1113
|
+
if (result.paused) await opened.session.cancel();
|
|
1114
|
+
return {
|
|
1115
|
+
kind: "ok",
|
|
1116
|
+
latestOffset: result.latestOffset,
|
|
1117
|
+
latestCursor: result.latestCursor
|
|
1118
|
+
};
|
|
1119
|
+
} finally {
|
|
1120
|
+
if (abortHandler != null && signal != null) signal.removeEventListener("abort", abortHandler);
|
|
1121
|
+
}
|
|
687
1122
|
}
|
|
688
1123
|
async openItemsSseSession(initialOffset, cursor, onOpen, signal) {
|
|
689
1124
|
const result = await this.client.openSseSession({
|
|
@@ -765,16 +1200,19 @@ var StreamsTransportClient = class {
|
|
|
765
1200
|
var TransportCursorManager = class {
|
|
766
1201
|
adapter;
|
|
767
1202
|
decodeSnapshot;
|
|
1203
|
+
decodeUpdateItems;
|
|
768
1204
|
saveRemoteCursor;
|
|
769
1205
|
constructor(options) {
|
|
770
1206
|
this.adapter = options.adapter;
|
|
771
1207
|
this.decodeSnapshot = options.decodeSnapshot;
|
|
1208
|
+
this.decodeUpdateItems = options.decodeUpdateItems;
|
|
772
1209
|
this.saveRemoteCursor = options.saveRemoteCursor;
|
|
773
1210
|
}
|
|
774
1211
|
async applyBootstrapPayload(cursor, snapshot, updates, nextOffset) {
|
|
775
1212
|
let nextVersion = cursor.serverLowerBoundVersion;
|
|
776
1213
|
if (snapshot.byteLength > 0) nextVersion = await this.adapter.applySnapshot(this.decodeSnapshot == null ? snapshot : await this.decodeSnapshot(snapshot));
|
|
777
|
-
|
|
1214
|
+
const clearUpdates = await this.decodeRemoteUpdateItems(updates);
|
|
1215
|
+
if (clearUpdates.length > 0) nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
|
|
778
1216
|
return await this.finalizeCursor({
|
|
779
1217
|
...cursor,
|
|
780
1218
|
nextOffset,
|
|
@@ -794,8 +1232,9 @@ var TransportCursorManager = class {
|
|
|
794
1232
|
async applyLiveResult(cursor, requestOffset, result, onRemoteVersionApplied) {
|
|
795
1233
|
const previousVersion = cursor.serverLowerBoundVersion;
|
|
796
1234
|
let nextVersion = previousVersion;
|
|
797
|
-
|
|
798
|
-
|
|
1235
|
+
const clearUpdates = await this.decodeRemoteUpdateItems(result.updates);
|
|
1236
|
+
if (clearUpdates.length > 0) {
|
|
1237
|
+
nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
|
|
799
1238
|
await onRemoteVersionApplied?.(previousVersion, nextVersion);
|
|
800
1239
|
}
|
|
801
1240
|
return await this.finalizeCursor({
|
|
@@ -807,8 +1246,9 @@ var TransportCursorManager = class {
|
|
|
807
1246
|
async applyRemotePayload(cursor, updates, nextOffset, source, onRemoteVersionApplied) {
|
|
808
1247
|
const previousVersion = cursor.serverLowerBoundVersion;
|
|
809
1248
|
let nextVersion = previousVersion;
|
|
810
|
-
|
|
811
|
-
|
|
1249
|
+
const clearUpdates = await this.decodeRemoteUpdateItems(updates);
|
|
1250
|
+
if (clearUpdates.length > 0) {
|
|
1251
|
+
nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
|
|
812
1252
|
await onRemoteVersionApplied?.(previousVersion, nextVersion);
|
|
813
1253
|
}
|
|
814
1254
|
return await this.finalizeCursor({
|
|
@@ -820,126 +1260,9 @@ var TransportCursorManager = class {
|
|
|
820
1260
|
async finalizeCursor(cursor, source) {
|
|
821
1261
|
return await this.saveRemoteCursor(cursor, source);
|
|
822
1262
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
const DEFAULT_COMPACT_THRESHOLD = 64;
|
|
827
|
-
const DEFAULT_MIN_CAPACITY = 16;
|
|
828
|
-
var Deque = class {
|
|
829
|
-
buffer;
|
|
830
|
-
head = 0;
|
|
831
|
-
tail = 0;
|
|
832
|
-
compactThreshold;
|
|
833
|
-
minCapacity;
|
|
834
|
-
constructor(values, options = {}) {
|
|
835
|
-
this.compactThreshold = Math.max(1, options.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD);
|
|
836
|
-
this.minCapacity = Math.max(1, options.minCapacity ?? DEFAULT_MIN_CAPACITY);
|
|
837
|
-
const seed = values == null ? [] : Array.from(values);
|
|
838
|
-
this.buffer = seed.slice();
|
|
839
|
-
this.tail = seed.length;
|
|
840
|
-
}
|
|
841
|
-
get length() {
|
|
842
|
-
return this.tail - this.head;
|
|
843
|
-
}
|
|
844
|
-
isEmpty() {
|
|
845
|
-
return this.length === 0;
|
|
846
|
-
}
|
|
847
|
-
clear() {
|
|
848
|
-
this.buffer = [];
|
|
849
|
-
this.head = 0;
|
|
850
|
-
this.tail = 0;
|
|
851
|
-
}
|
|
852
|
-
peekFront() {
|
|
853
|
-
if (this.length === 0) return;
|
|
854
|
-
const value = this.buffer[this.head];
|
|
855
|
-
return value == null ? void 0 : value;
|
|
856
|
-
}
|
|
857
|
-
peekBack() {
|
|
858
|
-
if (this.length === 0) return;
|
|
859
|
-
const value = this.buffer[this.tail - 1];
|
|
860
|
-
return value == null ? void 0 : value;
|
|
861
|
-
}
|
|
862
|
-
at(index) {
|
|
863
|
-
const length = this.length;
|
|
864
|
-
if (length === 0) return;
|
|
865
|
-
let offset = index;
|
|
866
|
-
if (offset < 0) offset += length;
|
|
867
|
-
if (offset < 0 || offset >= length) return;
|
|
868
|
-
const value = this.buffer[this.head + offset];
|
|
869
|
-
return value == null ? void 0 : value;
|
|
870
|
-
}
|
|
871
|
-
pushBack(value) {
|
|
872
|
-
this.ensureBackCapacity();
|
|
873
|
-
this.buffer[this.tail] = value;
|
|
874
|
-
this.tail += 1;
|
|
875
|
-
return this.length;
|
|
876
|
-
}
|
|
877
|
-
pushFront(value) {
|
|
878
|
-
if (this.head === 0) this.recenter(1, 0);
|
|
879
|
-
this.head -= 1;
|
|
880
|
-
this.buffer[this.head] = value;
|
|
881
|
-
return this.length;
|
|
882
|
-
}
|
|
883
|
-
popFront() {
|
|
884
|
-
if (this.length === 0) return;
|
|
885
|
-
const value = this.buffer[this.head];
|
|
886
|
-
this.buffer[this.head] = null;
|
|
887
|
-
this.head += 1;
|
|
888
|
-
this.compactIfNeeded();
|
|
889
|
-
return value == null ? void 0 : value;
|
|
890
|
-
}
|
|
891
|
-
popBack() {
|
|
892
|
-
if (this.length === 0) return;
|
|
893
|
-
this.tail -= 1;
|
|
894
|
-
const value = this.buffer[this.tail];
|
|
895
|
-
this.buffer[this.tail] = null;
|
|
896
|
-
this.compactIfNeeded();
|
|
897
|
-
return value == null ? void 0 : value;
|
|
898
|
-
}
|
|
899
|
-
toArray() {
|
|
900
|
-
const out = [];
|
|
901
|
-
for (let i = this.head; i < this.tail; i += 1) out.push(this.buffer[i]);
|
|
902
|
-
return out;
|
|
903
|
-
}
|
|
904
|
-
*[Symbol.iterator]() {
|
|
905
|
-
for (let i = this.head; i < this.tail; i += 1) yield this.buffer[i];
|
|
906
|
-
}
|
|
907
|
-
ensureBackCapacity() {
|
|
908
|
-
if (this.tail < this.buffer.length) return;
|
|
909
|
-
if (this.head > 0) {
|
|
910
|
-
this.compactToFront();
|
|
911
|
-
if (this.tail < this.buffer.length) return;
|
|
912
|
-
}
|
|
913
|
-
this.recenter(0, 1);
|
|
914
|
-
}
|
|
915
|
-
recenter(extraFront, extraBack) {
|
|
916
|
-
const length = this.length;
|
|
917
|
-
const needed = length + extraFront + extraBack;
|
|
918
|
-
const currentCapacity = this.buffer.length;
|
|
919
|
-
let nextCapacity = Math.max(currentCapacity, this.minCapacity);
|
|
920
|
-
if (nextCapacity < needed) nextCapacity = Math.max(nextCapacity * 2, needed, this.minCapacity);
|
|
921
|
-
const free = nextCapacity - length;
|
|
922
|
-
const nextHead = Math.max(extraFront, Math.floor(free / 2));
|
|
923
|
-
const next = new Array(nextCapacity);
|
|
924
|
-
for (let i = 0; i < length; i += 1) next[nextHead + i] = this.buffer[this.head + i];
|
|
925
|
-
this.buffer = next;
|
|
926
|
-
this.head = nextHead;
|
|
927
|
-
this.tail = nextHead + length;
|
|
928
|
-
}
|
|
929
|
-
compactIfNeeded() {
|
|
930
|
-
const length = this.length;
|
|
931
|
-
if (length === 0) {
|
|
932
|
-
this.clear();
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
if (this.head < this.compactThreshold || this.head < length) return;
|
|
936
|
-
this.compactToFront();
|
|
937
|
-
}
|
|
938
|
-
compactToFront() {
|
|
939
|
-
const length = this.length;
|
|
940
|
-
this.buffer = this.buffer.slice(this.head, this.tail);
|
|
941
|
-
this.head = 0;
|
|
942
|
-
this.tail = length;
|
|
1263
|
+
async decodeRemoteUpdateItems(items) {
|
|
1264
|
+
if (items.length === 0) return [];
|
|
1265
|
+
return this.decodeUpdateItems == null ? [...items] : await this.decodeUpdateItems(items);
|
|
943
1266
|
}
|
|
944
1267
|
};
|
|
945
1268
|
//#endregion
|
|
@@ -1074,6 +1397,8 @@ function createJoinState(cursor, liveMode = "sse") {
|
|
|
1074
1397
|
refCount: 0,
|
|
1075
1398
|
closed: false,
|
|
1076
1399
|
status: "connecting",
|
|
1400
|
+
readSubStatus: "connecting",
|
|
1401
|
+
writeSubStatus: "ok",
|
|
1077
1402
|
statusListeners: /* @__PURE__ */ new Set(),
|
|
1078
1403
|
pendingLocal: new Deque(),
|
|
1079
1404
|
flushingLocal: false,
|
|
@@ -1083,7 +1408,8 @@ function createJoinState(cursor, liveMode = "sse") {
|
|
|
1083
1408
|
liveMode,
|
|
1084
1409
|
snapshot: createTransportSnapshotState(),
|
|
1085
1410
|
started: Promise.resolve(),
|
|
1086
|
-
syncWaiters: []
|
|
1411
|
+
syncWaiters: [],
|
|
1412
|
+
initialSyncFailed: false
|
|
1087
1413
|
};
|
|
1088
1414
|
}
|
|
1089
1415
|
async function disposeJoinState(state, options) {
|
|
@@ -1094,6 +1420,7 @@ async function disposeJoinState(state, options) {
|
|
|
1094
1420
|
const unsubscribeLocal = state.unsubscribeLocal;
|
|
1095
1421
|
state.unsubscribeLocal = void 0;
|
|
1096
1422
|
requestController?.abort();
|
|
1423
|
+
state.retrySleepController.abort();
|
|
1097
1424
|
unsubscribeLocal?.();
|
|
1098
1425
|
const disposedError = /* @__PURE__ */ new Error("subscription closed");
|
|
1099
1426
|
for (const waiter of state.syncWaiters.splice(0)) waiter.reject(disposedError);
|
|
@@ -1113,6 +1440,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1113
1440
|
cursorManager;
|
|
1114
1441
|
volatileCursorManager;
|
|
1115
1442
|
decodeSnapshot;
|
|
1443
|
+
encodeSnapshot;
|
|
1444
|
+
decodeUpdateItems;
|
|
1445
|
+
encodeUpdateItems;
|
|
1446
|
+
measureUpdateItemsByteLength;
|
|
1116
1447
|
reconnectConfig;
|
|
1117
1448
|
debug;
|
|
1118
1449
|
beforeRemoteCursorSave;
|
|
@@ -1120,6 +1451,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1120
1451
|
producerId = createProducerId();
|
|
1121
1452
|
producerEpoch = 0;
|
|
1122
1453
|
nextProducerSeq = 0;
|
|
1454
|
+
pendingWriteOnly = new Deque();
|
|
1455
|
+
writeOnlyCursor;
|
|
1456
|
+
unsubscribeWriteOnlyLocal;
|
|
1457
|
+
writeOnlyDisabledReason;
|
|
1123
1458
|
joinState;
|
|
1124
1459
|
opQueue = Promise.resolve();
|
|
1125
1460
|
/**
|
|
@@ -1144,8 +1479,17 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1144
1479
|
this.debug = options.debug ?? false;
|
|
1145
1480
|
this.beforeRemoteCursorSave = composeBeforeRemoteCursorSaveHooks(options.beforeRemoteCursorSave, ...options.beforeRemoteCursorSaveHooks ?? []);
|
|
1146
1481
|
const snapshotCodec = normalizeSnapshotCodec(options.snapshotCodec);
|
|
1482
|
+
if (options.e2ee != null && options.payloadProtection != null) throw new Error("Specify only one of e2ee or payloadProtection");
|
|
1483
|
+
const payloadProtectionOption = options.e2ee ?? options.payloadProtection;
|
|
1484
|
+
const payloadProtectionLabel = options.e2ee != null ? "e2ee" : "payloadProtection";
|
|
1485
|
+
const payloadProtection = normalizePayloadProtection(payloadProtectionOption, payloadProtectionLabel);
|
|
1486
|
+
if (payloadProtection?.writePolicy === "plaintext" && options.snapshotUpload != null && options.snapshotUpload.enabled !== false) throw new Error(`snapshotUpload is not supported when ${payloadProtectionLabel}.writePolicy is plaintext`);
|
|
1147
1487
|
const snapshotUploadConfig = normalizeSnapshotUploadConfig(options.snapshotUpload);
|
|
1148
|
-
this.decodeSnapshot = snapshotCodec?.decompress;
|
|
1488
|
+
this.decodeSnapshot = this.composeSnapshotDecoder(payloadProtection, snapshotCodec?.decompress);
|
|
1489
|
+
this.encodeSnapshot = this.composeSnapshotEncoder(payloadProtection, snapshotCodec?.compress);
|
|
1490
|
+
this.decodeUpdateItems = payloadProtection == null ? void 0 : (items) => payloadProtection.decodeUpdateItems(items);
|
|
1491
|
+
this.encodeUpdateItems = payloadProtection == null ? void 0 : (updates) => payloadProtection.encodeUpdateItems(updates);
|
|
1492
|
+
this.measureUpdateItemsByteLength = payloadProtection == null ? void 0 : (updates) => payloadProtection.measureEncodedUpdateItemsByteLength(updates);
|
|
1149
1493
|
this.client = new StreamsTransportClient({
|
|
1150
1494
|
streamUrl: this.streamUrl,
|
|
1151
1495
|
adapter: this.adapter,
|
|
@@ -1158,7 +1502,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1158
1502
|
this.snapshotManager = new TransportSnapshotManager({
|
|
1159
1503
|
adapter: this.adapter,
|
|
1160
1504
|
config: snapshotUploadConfig,
|
|
1161
|
-
encodeSnapshot:
|
|
1505
|
+
encodeSnapshot: this.encodeSnapshot,
|
|
1162
1506
|
readSnapshotOffsetValue: () => this.client.readSnapshotOffsetValue(),
|
|
1163
1507
|
putSnapshotAt: (snapshotOffset, snapshot) => this.client.putSnapshotAt(snapshotOffset, snapshot),
|
|
1164
1508
|
enqueueExclusive: (task) => this.enqueueExclusive(task),
|
|
@@ -1166,11 +1510,31 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1166
1510
|
logError: (message, error, detail) => this.logError(message, error, detail),
|
|
1167
1511
|
isCurrentState: (state) => this.joinState === state
|
|
1168
1512
|
});
|
|
1513
|
+
this.writeOnlyCursor = this.createInitialCursor();
|
|
1514
|
+
this.unsubscribeWriteOnlyLocal = this.adapter.subscribeLocalUpdates((batch) => {
|
|
1515
|
+
if (this.writeOnlyDisabledReason != null) return;
|
|
1516
|
+
this.pendingWriteOnly.pushBack(batch);
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
composeSnapshotDecoder(payloadProtection, decompressSnapshot) {
|
|
1520
|
+
if (payloadProtection == null && decompressSnapshot == null) return;
|
|
1521
|
+
return async (snapshot) => {
|
|
1522
|
+
const clearSnapshot = payloadProtection == null ? snapshot : await payloadProtection.decodeSnapshot(snapshot);
|
|
1523
|
+
return decompressSnapshot == null ? clearSnapshot : await decompressSnapshot(clearSnapshot);
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
composeSnapshotEncoder(payloadProtection, compressSnapshot) {
|
|
1527
|
+
if (payloadProtection == null && compressSnapshot == null) return;
|
|
1528
|
+
return async (snapshot) => {
|
|
1529
|
+
const compressedSnapshot = compressSnapshot == null ? snapshot : await compressSnapshot(snapshot);
|
|
1530
|
+
return payloadProtection == null ? compressedSnapshot : await payloadProtection.encodeSnapshot(compressedSnapshot);
|
|
1531
|
+
};
|
|
1169
1532
|
}
|
|
1170
1533
|
/**
|
|
1171
1534
|
* Stops any active live join and releases transport-owned state.
|
|
1172
1535
|
*/
|
|
1173
1536
|
async close() {
|
|
1537
|
+
this.disableWriteOnlyMode("transport closed");
|
|
1174
1538
|
const state = this.joinState;
|
|
1175
1539
|
if (state != null) await this.disposeJoinState(state);
|
|
1176
1540
|
}
|
|
@@ -1178,22 +1542,51 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1178
1542
|
* Resets the retry counter and immediately attempts to reconnect.
|
|
1179
1543
|
*
|
|
1180
1544
|
* Use this after device wake or network recovery to skip any pending
|
|
1181
|
-
* backoff delay. No-op when the room is already
|
|
1545
|
+
* backoff delay. No-op when the room is already fully healthy (both
|
|
1546
|
+
* read and write sub-statuses are "ok") or no join is active.
|
|
1182
1547
|
*
|
|
1183
|
-
* If the room reached `"disconnected"`
|
|
1184
|
-
*
|
|
1548
|
+
* If the room reached `"disconnected"` or `"error"` status, this
|
|
1549
|
+
* restarts the affected loop(s).
|
|
1185
1550
|
*/
|
|
1186
1551
|
rejoin() {
|
|
1187
1552
|
const state = this.joinState;
|
|
1188
|
-
if (state == null || state.closed
|
|
1189
|
-
|
|
1553
|
+
if (state == null || state.closed) return;
|
|
1554
|
+
if (state.initialSyncFailed) {
|
|
1555
|
+
state.initialSyncFailed = false;
|
|
1556
|
+
state.lastError = void 0;
|
|
1557
|
+
state.retryAttempt = 0;
|
|
1558
|
+
state.retrySleepController.abort();
|
|
1559
|
+
state.retrySleepController = new AbortController();
|
|
1560
|
+
state.readSubStatus = "connecting";
|
|
1561
|
+
state.writeSubStatus = "ok";
|
|
1562
|
+
this.recomputeJoinStatus(state);
|
|
1563
|
+
state.started = this.startJoin(state);
|
|
1564
|
+
state.started.catch((error) => {
|
|
1565
|
+
if (state.closed) return;
|
|
1566
|
+
const transportError = toTransportError(error);
|
|
1567
|
+
if (transportError.retryable) {
|
|
1568
|
+
state.initialSyncFailed = true;
|
|
1569
|
+
state.lastError = transportError;
|
|
1570
|
+
} else this.disposeJoinState(state);
|
|
1571
|
+
});
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
if ((state.readSubStatus === "ok" || state.readSubStatus === "connecting") && state.writeSubStatus === "ok") return;
|
|
1190
1575
|
state.retryAttempt = 0;
|
|
1191
1576
|
state.retrySleepController.abort();
|
|
1192
1577
|
state.retrySleepController = new AbortController();
|
|
1193
|
-
|
|
1194
|
-
|
|
1578
|
+
const previousController = state.requestController;
|
|
1579
|
+
state.requestController = new AbortController();
|
|
1580
|
+
previousController?.abort();
|
|
1581
|
+
if (state.readSubStatus === "disconnected" || state.readSubStatus === "error") {
|
|
1582
|
+
this.setReadSubStatus(state, "reconnecting");
|
|
1195
1583
|
this.runReadLoop(state);
|
|
1196
1584
|
}
|
|
1585
|
+
if (state.writeSubStatus === "error") {
|
|
1586
|
+
state.writeSubStatus = state.pendingLocal.length > 0 ? "reconnecting" : "ok";
|
|
1587
|
+
this.recomputeJoinStatus(state);
|
|
1588
|
+
if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
|
|
1589
|
+
}
|
|
1197
1590
|
}
|
|
1198
1591
|
/**
|
|
1199
1592
|
* Reads the latest remote snapshot offset, if the server exposes one.
|
|
@@ -1212,6 +1605,33 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1212
1605
|
}
|
|
1213
1606
|
}
|
|
1214
1607
|
/**
|
|
1608
|
+
* Uploads a full adapter snapshot at the current remote tail.
|
|
1609
|
+
*
|
|
1610
|
+
* @internal Intended for end-to-end tests that must deterministically create
|
|
1611
|
+
* a remote snapshot without waiting for the best-effort join() debounce.
|
|
1612
|
+
*/
|
|
1613
|
+
async uploadSnapshotForTesting() {
|
|
1614
|
+
try {
|
|
1615
|
+
await this.adapter.flushLocalExports?.();
|
|
1616
|
+
const cursor = await this.syncOnce();
|
|
1617
|
+
const rawSnapshot = this.adapter.exportSnapshot();
|
|
1618
|
+
const snapshot = this.encodeSnapshot == null ? rawSnapshot : await this.encodeSnapshot(rawSnapshot);
|
|
1619
|
+
await this.client.putSnapshotAt(cursor.nextOffset, snapshot);
|
|
1620
|
+
return {
|
|
1621
|
+
ok: true,
|
|
1622
|
+
value: {
|
|
1623
|
+
snapshotOffset: cursor.nextOffset,
|
|
1624
|
+
snapshotByteLength: snapshot.byteLength
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
return {
|
|
1629
|
+
ok: false,
|
|
1630
|
+
error: toTransportError(error)
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1215
1635
|
* Explicitly creates the target stream.
|
|
1216
1636
|
*/
|
|
1217
1637
|
async createStream() {
|
|
@@ -1255,6 +1675,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1255
1675
|
*/
|
|
1256
1676
|
async sync() {
|
|
1257
1677
|
try {
|
|
1678
|
+
this.disableWriteOnlyMode("appendWriteOnly cannot be used after sync() on the same transport");
|
|
1258
1679
|
return {
|
|
1259
1680
|
ok: true,
|
|
1260
1681
|
value: { cursor: await this.syncOnce() }
|
|
@@ -1267,27 +1688,75 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1267
1688
|
}
|
|
1268
1689
|
}
|
|
1269
1690
|
/**
|
|
1691
|
+
* Appends local CRDT updates without reading the stream first.
|
|
1692
|
+
*
|
|
1693
|
+
* Use this for write-only actors whose auth token may POST to the stream but
|
|
1694
|
+
* may not bootstrap, catch up, or join live reads. The method does not save a
|
|
1695
|
+
* RemoteCursorStore cursor and does not apply remote updates.
|
|
1696
|
+
*/
|
|
1697
|
+
async appendWriteOnly() {
|
|
1698
|
+
return await this.enqueueExclusive(async () => {
|
|
1699
|
+
try {
|
|
1700
|
+
if (this.writeOnlyDisabledReason != null) throw new Error(this.writeOnlyDisabledReason);
|
|
1701
|
+
await this.adapter.flushLocalExports?.();
|
|
1702
|
+
const drained = await this.drainPendingWriteOnlyBatch();
|
|
1703
|
+
if (drained == null) return {
|
|
1704
|
+
ok: true,
|
|
1705
|
+
value: {
|
|
1706
|
+
appended: false,
|
|
1707
|
+
localVersion: this.writeOnlyCursor.serverLowerBoundVersion
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
const nextCursor = await this.appendLocalBatch(this.writeOnlyCursor, drained.batch, false);
|
|
1711
|
+
for (let i = 0; i < drained.count; i += 1) this.pendingWriteOnly.popFront();
|
|
1712
|
+
this.writeOnlyCursor = nextCursor;
|
|
1713
|
+
return {
|
|
1714
|
+
ok: true,
|
|
1715
|
+
value: {
|
|
1716
|
+
appended: true,
|
|
1717
|
+
nextOffset: nextCursor.nextOffset,
|
|
1718
|
+
localVersion: nextCursor.serverLowerBoundVersion
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
return {
|
|
1723
|
+
ok: false,
|
|
1724
|
+
error: toTransportError(error)
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1270
1730
|
* Replays remote state, flushes queued local updates, then enters live read mode.
|
|
1271
1731
|
*/
|
|
1272
1732
|
async join(params) {
|
|
1733
|
+
this.disableWriteOnlyMode("appendWriteOnly cannot be used after join() on the same transport");
|
|
1273
1734
|
const existing = this.joinState;
|
|
1274
|
-
if (existing != null)
|
|
1275
|
-
|
|
1276
|
-
return {
|
|
1735
|
+
if (existing != null) {
|
|
1736
|
+
if (existing.initialSyncFailed && !existing.closed) return {
|
|
1277
1737
|
ok: true,
|
|
1278
1738
|
value: this.createSubscription(existing, params)
|
|
1279
1739
|
};
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1740
|
+
try {
|
|
1741
|
+
await existing.started;
|
|
1742
|
+
return {
|
|
1743
|
+
ok: true,
|
|
1744
|
+
value: this.createSubscription(existing, params)
|
|
1745
|
+
};
|
|
1746
|
+
} catch (error) {
|
|
1747
|
+
return {
|
|
1748
|
+
ok: false,
|
|
1749
|
+
error: toTransportError(error)
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1285
1752
|
}
|
|
1286
1753
|
const state = createJoinState(createInitialRemoteCursor({
|
|
1287
1754
|
streamUrl: this.streamUrl,
|
|
1288
1755
|
nextOffset: "-1",
|
|
1289
1756
|
serverLowerBoundVersion: this.adapter.emptyVersion()
|
|
1290
1757
|
}));
|
|
1758
|
+
const statusListener = params?.onStatusChange;
|
|
1759
|
+
if (statusListener != null) state.statusListeners.add(statusListener);
|
|
1291
1760
|
state.started = this.startJoin(state);
|
|
1292
1761
|
this.joinState = state;
|
|
1293
1762
|
try {
|
|
@@ -1297,10 +1766,20 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1297
1766
|
value: this.createSubscription(state, params)
|
|
1298
1767
|
};
|
|
1299
1768
|
} catch (error) {
|
|
1769
|
+
const transportError = toTransportError(error);
|
|
1770
|
+
if (transportError.retryable && !state.closed) {
|
|
1771
|
+
state.initialSyncFailed = true;
|
|
1772
|
+
state.lastError = transportError;
|
|
1773
|
+
if (statusListener != null) state.statusListeners.delete(statusListener);
|
|
1774
|
+
return {
|
|
1775
|
+
ok: false,
|
|
1776
|
+
error: transportError
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1300
1779
|
await this.disposeJoinState(state);
|
|
1301
1780
|
return {
|
|
1302
1781
|
ok: false,
|
|
1303
|
-
error:
|
|
1782
|
+
error: transportError
|
|
1304
1783
|
};
|
|
1305
1784
|
}
|
|
1306
1785
|
}
|
|
@@ -1308,8 +1787,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1308
1787
|
state.refCount += 1;
|
|
1309
1788
|
const statusListener = params?.onStatusChange;
|
|
1310
1789
|
if (statusListener != null) {
|
|
1311
|
-
state.statusListeners.
|
|
1312
|
-
|
|
1790
|
+
if (!state.statusListeners.has(statusListener)) {
|
|
1791
|
+
state.statusListeners.add(statusListener);
|
|
1792
|
+
statusListener(state.status);
|
|
1793
|
+
}
|
|
1313
1794
|
}
|
|
1314
1795
|
let unsubscribed = false;
|
|
1315
1796
|
return {
|
|
@@ -1325,7 +1806,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1325
1806
|
return state.status === "joined";
|
|
1326
1807
|
},
|
|
1327
1808
|
get status() {
|
|
1328
|
-
return state.status;
|
|
1809
|
+
return state.status === "connecting" ? "reconnecting" : state.status;
|
|
1810
|
+
},
|
|
1811
|
+
get lastError() {
|
|
1812
|
+
return state.lastError;
|
|
1329
1813
|
},
|
|
1330
1814
|
onStatusChange: (listener) => {
|
|
1331
1815
|
state.statusListeners.add(listener);
|
|
@@ -1335,6 +1819,20 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1335
1819
|
};
|
|
1336
1820
|
},
|
|
1337
1821
|
waitUntilSynced: async () => {
|
|
1822
|
+
if (state.initialSyncFailed) {
|
|
1823
|
+
const msg = state.lastError?.code ? `initial sync failed: ${state.lastError.code}` : "initial sync failed";
|
|
1824
|
+
throw new Error(msg);
|
|
1825
|
+
}
|
|
1826
|
+
if (state.readSubStatus === "connecting") try {
|
|
1827
|
+
await state.started;
|
|
1828
|
+
} catch (error) {
|
|
1829
|
+
const te = toTransportError(error);
|
|
1830
|
+
throw new Error(te.code ? `initial sync failed: ${te.code}` : "initial sync failed");
|
|
1831
|
+
}
|
|
1832
|
+
if (state.writeSubStatus === "error") {
|
|
1833
|
+
const msg = state.lastError?.code ? `local append failed: ${state.lastError.code}` : "local append failed";
|
|
1834
|
+
throw new Error(msg);
|
|
1835
|
+
}
|
|
1338
1836
|
await this.adapter.flushLocalExports?.();
|
|
1339
1837
|
if (state.closed) throw new Error("subscription closed");
|
|
1340
1838
|
const remaining = state.pendingLocal.length;
|
|
@@ -1377,13 +1875,18 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1377
1875
|
if (state.status === "error" || state.status === "disconnected") throw new Error("join local sync failed before subscription became ready");
|
|
1378
1876
|
}
|
|
1379
1877
|
if (state.closed) return;
|
|
1380
|
-
|
|
1878
|
+
state.lastError = void 0;
|
|
1879
|
+
this.setReadSubStatus(state, "ok");
|
|
1880
|
+
this.setWriteSubStatus(state, "ok");
|
|
1381
1881
|
this.runReadLoop(state);
|
|
1382
1882
|
} catch (error) {
|
|
1383
1883
|
if (state.closed) return;
|
|
1384
1884
|
this.snapshotManager.dispose(state);
|
|
1885
|
+
state.lastError = toTransportError(error);
|
|
1385
1886
|
this.logError("join first sync failed", error, { streamUrl: this.streamUrl });
|
|
1386
|
-
|
|
1887
|
+
state.readSubStatus = "error";
|
|
1888
|
+
state.writeSubStatus = "error";
|
|
1889
|
+
this.recomputeJoinStatus(state);
|
|
1387
1890
|
throw error;
|
|
1388
1891
|
}
|
|
1389
1892
|
}
|
|
@@ -1435,7 +1938,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1435
1938
|
return caughtUp;
|
|
1436
1939
|
} catch (error) {
|
|
1437
1940
|
if (!isStreamNotFoundError(error) || !this.createStreamIfMissing) throw error;
|
|
1438
|
-
await this.
|
|
1941
|
+
await this.ensureStreamExists();
|
|
1439
1942
|
return await this.bootstrapState(this.createInitialCursor());
|
|
1440
1943
|
}
|
|
1441
1944
|
}
|
|
@@ -1500,7 +2003,17 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1500
2003
|
nextProducerSeq: this.nextProducerSeq
|
|
1501
2004
|
};
|
|
1502
2005
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1503
|
-
|
|
2006
|
+
let result;
|
|
2007
|
+
try {
|
|
2008
|
+
const wireUpdates = this.encodeUpdateItems == null ? batch.updates : await this.encodeUpdateItems(batch.updates);
|
|
2009
|
+
result = await this.client.appendUpdates(wireUpdates, producerSession);
|
|
2010
|
+
} catch (error) {
|
|
2011
|
+
if (isStreamNotFoundError(error) && this.createStreamIfMissing) {
|
|
2012
|
+
await this.ensureStreamExists();
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
throw error;
|
|
2016
|
+
}
|
|
1504
2017
|
if (result.kind === "ok") {
|
|
1505
2018
|
this.producerEpoch = result.ackEpoch;
|
|
1506
2019
|
this.nextProducerSeq = result.ackSeq + 1;
|
|
@@ -1526,6 +2039,17 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1526
2039
|
}
|
|
1527
2040
|
throw new Error("append retry budget exhausted");
|
|
1528
2041
|
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Ensures the target stream exists, creating it if necessary.
|
|
2044
|
+
* Tolerates 409 Conflict (stream already created concurrently).
|
|
2045
|
+
*/
|
|
2046
|
+
async ensureStreamExists() {
|
|
2047
|
+
try {
|
|
2048
|
+
await this.client.createStreamRequest();
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
if (!(error instanceof HttpError && error.status === 409)) throw error;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
1529
2053
|
exportUpdates(fromVersion) {
|
|
1530
2054
|
const exported = this.adapter.exportUpdates(fromVersion);
|
|
1531
2055
|
if (exported == null) return { nextRefVersion: fromVersion };
|
|
@@ -1546,6 +2070,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1546
2070
|
return new TransportCursorManager({
|
|
1547
2071
|
adapter,
|
|
1548
2072
|
decodeSnapshot: this.decodeSnapshot,
|
|
2073
|
+
decodeUpdateItems: this.decodeUpdateItems,
|
|
1549
2074
|
saveRemoteCursor
|
|
1550
2075
|
});
|
|
1551
2076
|
}
|
|
@@ -1589,121 +2114,193 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1589
2114
|
}
|
|
1590
2115
|
async runReadLoop(state) {
|
|
1591
2116
|
state.requestController ??= new AbortController();
|
|
1592
|
-
while (!state.closed)
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2117
|
+
while (!state.closed) {
|
|
2118
|
+
const capturedController = state.requestController;
|
|
2119
|
+
let inactivityTimer = null;
|
|
2120
|
+
let inactivityTriggered = false;
|
|
2121
|
+
const clearInactivity = () => {
|
|
2122
|
+
if (inactivityTimer != null) {
|
|
2123
|
+
clearTimeout(inactivityTimer);
|
|
2124
|
+
inactivityTimer = null;
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
const resetInactivity = () => {
|
|
2128
|
+
clearInactivity();
|
|
2129
|
+
const timeoutMs = this.reconnectConfig.liveInactivityTimeoutMs;
|
|
2130
|
+
if (timeoutMs != null && timeoutMs > 0 && !state.closed) inactivityTimer = setTimeout(() => {
|
|
2131
|
+
inactivityTriggered = true;
|
|
2132
|
+
capturedController?.abort();
|
|
2133
|
+
}, timeoutMs);
|
|
2134
|
+
};
|
|
2135
|
+
try {
|
|
2136
|
+
if (state.liveMode === "long-poll") {
|
|
2137
|
+
const requestOffset = state.cursor.nextOffset;
|
|
2138
|
+
const response = await this.client.readFromOffset(requestOffset, state.streamCursor, "long-poll", capturedController?.signal);
|
|
2139
|
+
if (response.kind === "gone") {
|
|
2140
|
+
const bootstrapped = await this.bootstrapState({
|
|
2141
|
+
...state.cursor,
|
|
2142
|
+
nextOffset: "-1",
|
|
2143
|
+
serverLowerBoundVersion: this.adapter.emptyVersion()
|
|
2144
|
+
});
|
|
2145
|
+
state.cursor = bootstrapped.cursor;
|
|
2146
|
+
state.streamCursor = bootstrapped.streamCursor;
|
|
2147
|
+
state.retryAttempt = 0;
|
|
2148
|
+
continue;
|
|
2149
|
+
}
|
|
2150
|
+
const result = response.value;
|
|
2151
|
+
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`);
|
|
2152
|
+
await this.enqueueExclusive(async () => {
|
|
2153
|
+
if (state.closed) return;
|
|
2154
|
+
state.cursor = await this.cursorManager.applyLiveResult(state.cursor, requestOffset, result, (previousVersion, nextVersion) => {
|
|
2155
|
+
this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
|
|
2156
|
+
});
|
|
2157
|
+
if (result.streamCursor != null) state.streamCursor = result.streamCursor;
|
|
1601
2158
|
});
|
|
1602
|
-
state.
|
|
1603
|
-
state.streamCursor = bootstrapped.streamCursor;
|
|
2159
|
+
if (state.closed) return;
|
|
1604
2160
|
state.retryAttempt = 0;
|
|
2161
|
+
this.setReadSubStatus(state, "ok");
|
|
2162
|
+
await sleep(0);
|
|
1605
2163
|
continue;
|
|
1606
2164
|
}
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
2165
|
+
const sseRequestOffset = state.cursor.nextOffset;
|
|
2166
|
+
const pendingRemote = [];
|
|
2167
|
+
let flushChain = Promise.resolve();
|
|
2168
|
+
resetInactivity();
|
|
2169
|
+
const sseResult = await this.client.readFromOffsetSseItems(sseRequestOffset, state.streamCursor, (updates, nextOffset, _upToDate, streamCursor) => {
|
|
2170
|
+
resetInactivity();
|
|
2171
|
+
pendingRemote.push({
|
|
2172
|
+
updates,
|
|
2173
|
+
nextOffset,
|
|
2174
|
+
streamCursor
|
|
1613
2175
|
});
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
flushChain = flushChain.then(async () => {
|
|
1632
|
-
const events = pendingRemote.splice(0);
|
|
1633
|
-
if (events.length === 0 || state.closed) return;
|
|
1634
|
-
const allUpdates = events.flatMap((e) => e.updates);
|
|
1635
|
-
const lastEvent = events[events.length - 1];
|
|
1636
|
-
await this.enqueueExclusive(async () => {
|
|
1637
|
-
if (state.closed) return;
|
|
1638
|
-
state.cursor = await this.cursorManager.applyLiveResult(state.cursor, sseRequestOffset, {
|
|
1639
|
-
nextOffset: lastEvent.nextOffset,
|
|
1640
|
-
updates: allUpdates
|
|
1641
|
-
}, (previousVersion, nextVersion) => {
|
|
1642
|
-
this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
|
|
2176
|
+
flushChain = flushChain.then(async () => {
|
|
2177
|
+
const events = pendingRemote.splice(0);
|
|
2178
|
+
if (events.length === 0 || state.closed) return;
|
|
2179
|
+
const allUpdates = events.flatMap((e) => e.updates);
|
|
2180
|
+
const lastEvent = events[events.length - 1];
|
|
2181
|
+
await this.enqueueExclusive(async () => {
|
|
2182
|
+
if (state.closed) return;
|
|
2183
|
+
state.cursor = await this.cursorManager.applyLiveResult(state.cursor, sseRequestOffset, {
|
|
2184
|
+
nextOffset: lastEvent.nextOffset,
|
|
2185
|
+
updates: allUpdates
|
|
2186
|
+
}, (previousVersion, nextVersion) => {
|
|
2187
|
+
this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
|
|
2188
|
+
});
|
|
2189
|
+
for (let i = events.length - 1; i >= 0; i--) if (events[i].streamCursor != null) {
|
|
2190
|
+
state.streamCursor = events[i].streamCursor;
|
|
2191
|
+
break;
|
|
2192
|
+
}
|
|
1643
2193
|
});
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
2194
|
+
this.setReadSubStatus(state, "ok");
|
|
2195
|
+
}).catch((flushError) => {
|
|
2196
|
+
this.logError("sse flush chain error", flushError, { streamUrl: this.streamUrl });
|
|
2197
|
+
capturedController?.abort(flushError);
|
|
2198
|
+
});
|
|
2199
|
+
}, () => {
|
|
2200
|
+
resetInactivity();
|
|
2201
|
+
this.setReadSubStatus(state, "ok");
|
|
2202
|
+
}, capturedController?.signal);
|
|
2203
|
+
clearInactivity();
|
|
2204
|
+
await flushChain;
|
|
2205
|
+
if (state.closed) return;
|
|
2206
|
+
if (sseResult.kind === "ok") {
|
|
2207
|
+
state.retryAttempt = 0;
|
|
2208
|
+
state.streamCursor = sseResult.latestCursor;
|
|
2209
|
+
await sleep(0);
|
|
2210
|
+
continue;
|
|
2211
|
+
}
|
|
2212
|
+
if (sseResult.kind === "unsupported") {
|
|
2213
|
+
state.liveMode = "long-poll";
|
|
2214
|
+
await sleep(0);
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
if (sseResult.kind === "not-found") {
|
|
2218
|
+
const notFoundError = new HttpError("stream not found", 404);
|
|
2219
|
+
this.logError("live read failed", notFoundError, {
|
|
2220
|
+
streamUrl: this.streamUrl,
|
|
2221
|
+
code: "stream_not_found"
|
|
1648
2222
|
});
|
|
1649
|
-
|
|
2223
|
+
state.lastError = toTransportError(notFoundError);
|
|
2224
|
+
this.setReadSubStatus(state, "error");
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
const bootstrapped = await this.bootstrapState({
|
|
2228
|
+
...state.cursor,
|
|
2229
|
+
nextOffset: "-1",
|
|
2230
|
+
serverLowerBoundVersion: this.adapter.emptyVersion()
|
|
1650
2231
|
});
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
}, state.requestController?.signal);
|
|
1654
|
-
await flushChain;
|
|
1655
|
-
if (state.closed) return;
|
|
1656
|
-
if (sseResult.kind === "ok") {
|
|
2232
|
+
state.cursor = bootstrapped.cursor;
|
|
2233
|
+
state.streamCursor = bootstrapped.streamCursor;
|
|
1657
2234
|
state.retryAttempt = 0;
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
clearInactivity();
|
|
2237
|
+
if (state.closed) return;
|
|
2238
|
+
if (capturedController?.signal.aborted) {
|
|
2239
|
+
if (inactivityTriggered) {
|
|
2240
|
+
if (state.requestController === capturedController) state.requestController = new AbortController();
|
|
2241
|
+
this.logDebug("sse inactivity timeout", { streamUrl: this.streamUrl });
|
|
2242
|
+
this.setReadSubStatus(state, "reconnecting");
|
|
2243
|
+
const maxAttempts = this.reconnectConfig.maxAttempts;
|
|
2244
|
+
if (state.retryAttempt >= maxAttempts) {
|
|
2245
|
+
this.logError("live read reconnect exhausted (inactivity)", error, {
|
|
2246
|
+
streamUrl: this.streamUrl,
|
|
2247
|
+
attempts: state.retryAttempt
|
|
2248
|
+
});
|
|
2249
|
+
state.lastError = {
|
|
2250
|
+
code: "timeout",
|
|
2251
|
+
retryable: true,
|
|
2252
|
+
phase: "poll",
|
|
2253
|
+
message: "SSE inactivity timeout exhausted retry budget"
|
|
2254
|
+
};
|
|
2255
|
+
this.setReadSubStatus(state, "disconnected");
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
state.retryAttempt += 1;
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
const abortReason = capturedController.signal.reason;
|
|
2262
|
+
const flushError = abortReason instanceof Error ? abortReason : error;
|
|
2263
|
+
if (flushError instanceof Error && !(flushError instanceof DOMException && flushError.name === "AbortError")) {
|
|
2264
|
+
const flushClassified = classifyFetchError(flushError);
|
|
2265
|
+
if (!flushClassified.retriable || isAuthOrProtocolError(flushError)) {
|
|
2266
|
+
this.logError("live read failed (flush chain)", flushError, {
|
|
2267
|
+
streamUrl: this.streamUrl,
|
|
2268
|
+
code: flushClassified.code
|
|
2269
|
+
});
|
|
2270
|
+
state.lastError = toTransportError(flushError);
|
|
2271
|
+
this.setReadSubStatus(state, "error");
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
state.retryAttempt = 0;
|
|
2276
|
+
this.setReadSubStatus(state, "reconnecting");
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
const classified = classifyFetchError(error);
|
|
2280
|
+
if (!classified.retriable || isAuthOrProtocolError(error)) {
|
|
2281
|
+
this.logError("live read failed", error, {
|
|
2282
|
+
streamUrl: this.streamUrl,
|
|
2283
|
+
code: classified.code
|
|
2284
|
+
});
|
|
2285
|
+
state.lastError = toTransportError(error);
|
|
2286
|
+
this.setReadSubStatus(state, "error");
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
this.setReadSubStatus(state, "reconnecting");
|
|
2290
|
+
const maxAttempts = this.reconnectConfig.maxAttempts;
|
|
2291
|
+
if (state.retryAttempt >= maxAttempts) {
|
|
2292
|
+
this.logError("live read reconnect exhausted", error, {
|
|
2293
|
+
streamUrl: this.streamUrl,
|
|
2294
|
+
attempts: state.retryAttempt
|
|
2295
|
+
});
|
|
2296
|
+
state.lastError = toTransportError(error);
|
|
2297
|
+
this.setReadSubStatus(state, "disconnected");
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig);
|
|
2301
|
+
state.retryAttempt += 1;
|
|
2302
|
+
await sleep(delay, state.retrySleepController.signal);
|
|
1703
2303
|
}
|
|
1704
|
-
const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig);
|
|
1705
|
-
state.retryAttempt += 1;
|
|
1706
|
-
await sleep(delay, state.retrySleepController.signal);
|
|
1707
2304
|
}
|
|
1708
2305
|
}
|
|
1709
2306
|
/** Maximum encoded size (bytes) for a single batched append. */
|
|
@@ -1714,20 +2311,19 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1714
2311
|
*
|
|
1715
2312
|
* Returns `undefined` when the queue is empty.
|
|
1716
2313
|
*/
|
|
1717
|
-
|
|
1718
|
-
const first =
|
|
2314
|
+
async drainQueuedBatch(pendingLocal) {
|
|
2315
|
+
const first = pendingLocal.peekFront();
|
|
1719
2316
|
if (first == null) return;
|
|
1720
2317
|
const maxBytes = StreamsCrdt.MAX_BATCH_BYTES;
|
|
1721
|
-
let totalBytes = first.updates.reduce((sum, u) => sum + 4 + u.byteLength, 0);
|
|
1722
2318
|
let mergedVersion = first.version;
|
|
1723
2319
|
const allUpdates = Array.from(first.updates);
|
|
1724
2320
|
let count = 1;
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
const
|
|
1728
|
-
|
|
2321
|
+
await this.measureAppendBodyByteLength(allUpdates);
|
|
2322
|
+
while (count < pendingLocal.length) {
|
|
2323
|
+
const next = pendingLocal.at(count);
|
|
2324
|
+
const candidateUpdates = [...allUpdates, ...next.updates];
|
|
2325
|
+
if (await this.measureAppendBodyByteLength(candidateUpdates) > maxBytes) break;
|
|
1729
2326
|
allUpdates.push(...next.updates);
|
|
1730
|
-
totalBytes += nextBytes;
|
|
1731
2327
|
mergedVersion = this.adapter.mergeVersions(mergedVersion, next.version);
|
|
1732
2328
|
count += 1;
|
|
1733
2329
|
}
|
|
@@ -1743,19 +2339,25 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1743
2339
|
count
|
|
1744
2340
|
};
|
|
1745
2341
|
}
|
|
2342
|
+
drainPendingBatch(state) {
|
|
2343
|
+
return this.drainQueuedBatch(state.pendingLocal);
|
|
2344
|
+
}
|
|
2345
|
+
drainPendingWriteOnlyBatch() {
|
|
2346
|
+
return this.drainQueuedBatch(this.pendingWriteOnly);
|
|
2347
|
+
}
|
|
1746
2348
|
async flushPendingLocal(state) {
|
|
1747
2349
|
if (state.flushingLocal || state.closed) return;
|
|
1748
2350
|
state.flushingLocal = true;
|
|
1749
2351
|
try {
|
|
1750
2352
|
while (!state.closed) {
|
|
1751
|
-
const drained = this.drainPendingBatch(state);
|
|
2353
|
+
const drained = await this.drainPendingBatch(state);
|
|
1752
2354
|
if (drained == null) return;
|
|
1753
2355
|
let attempts = 0;
|
|
1754
2356
|
while (!state.closed) try {
|
|
1755
2357
|
state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch));
|
|
1756
2358
|
for (let i = 0; i < drained.count; i += 1) state.pendingLocal.popFront();
|
|
1757
2359
|
this.notifySyncWaiters(state, drained.count);
|
|
1758
|
-
this.
|
|
2360
|
+
this.setWriteSubStatus(state, "ok");
|
|
1759
2361
|
this.snapshotManager.schedule(state);
|
|
1760
2362
|
break;
|
|
1761
2363
|
} catch (error) {
|
|
@@ -1765,17 +2367,19 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1765
2367
|
streamUrl: this.streamUrl,
|
|
1766
2368
|
code: classified.code
|
|
1767
2369
|
});
|
|
2370
|
+
state.lastError = toTransportError(error);
|
|
1768
2371
|
this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`local append failed: ${classified.code}`));
|
|
1769
|
-
this.
|
|
2372
|
+
this.setWriteSubStatus(state, "error");
|
|
1770
2373
|
return;
|
|
1771
2374
|
}
|
|
1772
2375
|
const maxAttempts = this.reconnectConfig.maxAttempts;
|
|
1773
2376
|
if (attempts >= maxAttempts) {
|
|
2377
|
+
state.lastError = toTransportError(error);
|
|
1774
2378
|
this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("local append retry budget exhausted"));
|
|
1775
|
-
this.
|
|
2379
|
+
this.setWriteSubStatus(state, "error");
|
|
1776
2380
|
return;
|
|
1777
2381
|
}
|
|
1778
|
-
this.
|
|
2382
|
+
this.setWriteSubStatus(state, "reconnecting");
|
|
1779
2383
|
const delay = computeRetryDelay(attempts, this.reconnectConfig);
|
|
1780
2384
|
attempts += 1;
|
|
1781
2385
|
await sleep(delay, state.retrySleepController.signal);
|
|
@@ -1812,16 +2416,47 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1812
2416
|
beforeRemoteCursorSave: this.beforeRemoteCursorSave
|
|
1813
2417
|
});
|
|
1814
2418
|
}
|
|
1815
|
-
|
|
2419
|
+
setReadSubStatus(state, sub) {
|
|
2420
|
+
if (state.readSubStatus === sub) return;
|
|
2421
|
+
state.readSubStatus = sub;
|
|
2422
|
+
this.recomputeJoinStatus(state);
|
|
2423
|
+
}
|
|
2424
|
+
setWriteSubStatus(state, sub) {
|
|
2425
|
+
if (state.writeSubStatus === sub) return;
|
|
2426
|
+
state.writeSubStatus = sub;
|
|
2427
|
+
this.recomputeJoinStatus(state);
|
|
2428
|
+
}
|
|
2429
|
+
recomputeJoinStatus(state) {
|
|
2430
|
+
const status = this.computeOverallStatus(state);
|
|
1816
2431
|
if (state.status === status) return;
|
|
1817
2432
|
state.status = status;
|
|
2433
|
+
if (status === "joined") state.lastError = void 0;
|
|
1818
2434
|
for (const listener of state.statusListeners) listener(status);
|
|
1819
2435
|
}
|
|
2436
|
+
computeOverallStatus(state) {
|
|
2437
|
+
const { readSubStatus, writeSubStatus } = state;
|
|
2438
|
+
if (readSubStatus === "error" || writeSubStatus === "error") return "error";
|
|
2439
|
+
if (readSubStatus === "disconnected") return "disconnected";
|
|
2440
|
+
if (readSubStatus === "connecting") return "connecting";
|
|
2441
|
+
if (readSubStatus === "reconnecting" || writeSubStatus === "reconnecting") return "reconnecting";
|
|
2442
|
+
return "joined";
|
|
2443
|
+
}
|
|
1820
2444
|
enqueueExclusive(task) {
|
|
1821
2445
|
const run = this.opQueue.then(task, task);
|
|
1822
2446
|
this.opQueue = run.then(() => void 0, () => void 0);
|
|
1823
2447
|
return run;
|
|
1824
2448
|
}
|
|
2449
|
+
async measureAppendBodyByteLength(updates) {
|
|
2450
|
+
if (this.measureUpdateItemsByteLength != null) return await this.measureUpdateItemsByteLength(updates);
|
|
2451
|
+
return updates.reduce((sum, update) => sum + 4 + update.byteLength, 0);
|
|
2452
|
+
}
|
|
2453
|
+
disableWriteOnlyMode(reason) {
|
|
2454
|
+
if (this.writeOnlyDisabledReason != null) return;
|
|
2455
|
+
this.writeOnlyDisabledReason = reason;
|
|
2456
|
+
this.pendingWriteOnly.clear();
|
|
2457
|
+
this.unsubscribeWriteOnlyLocal?.();
|
|
2458
|
+
this.unsubscribeWriteOnlyLocal = void 0;
|
|
2459
|
+
}
|
|
1825
2460
|
logDebug(message, detail) {
|
|
1826
2461
|
if (!this.debug) return;
|
|
1827
2462
|
if (detail == null) {
|
|
@@ -1873,6 +2508,6 @@ function createStreamUrl(input) {
|
|
|
1873
2508
|
return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
|
|
1874
2509
|
}
|
|
1875
2510
|
//#endregion
|
|
1876
|
-
export {
|
|
2511
|
+
export { PayloadProtectionError as a, createInitialRemoteCursor as c, StreamsCrdt as i, isValidBucketId as n, InMemoryRemoteCursorStore as o, isValidRillId as r, IndexedDbRemoteCursorStore as s, createStreamUrl as t };
|
|
1877
2512
|
|
|
1878
|
-
//# sourceMappingURL=stream-id-
|
|
2513
|
+
//# sourceMappingURL=stream-id-CbVwCS3g.js.map
|