@loro-dev/streams-crdt 0.6.1 → 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-Dr8ZBpUQ.d.ts → stream-id-CS7SzuLW.d.ts} +148 -2
- package/dist/{stream-id-0dTFsiaO.js → stream-id-CbVwCS3g.js} +674 -154
- package/dist/stream-id-CbVwCS3g.js.map +1 -0
- package/package.json +5 -3
- package/dist/stream-id-0dTFsiaO.js.map +0 -1
|
@@ -206,6 +206,127 @@ function computeRetryDelay(attempt, config = DEFAULT_RECONNECT_CONFIG) {
|
|
|
206
206
|
return applyJitter(base, jitterFraction);
|
|
207
207
|
}
|
|
208
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
|
|
209
330
|
//#region src/items.ts
|
|
210
331
|
const BINARY_CONTENT_TYPE = "application/octet-stream";
|
|
211
332
|
function encodeItems(items) {
|
|
@@ -236,6 +357,290 @@ function decodeItems(payload) {
|
|
|
236
357
|
return out;
|
|
237
358
|
}
|
|
238
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
|
|
239
644
|
//#region src/transport-internal.ts
|
|
240
645
|
function createProducerId() {
|
|
241
646
|
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
@@ -382,6 +787,12 @@ function classifyFetchError(err) {
|
|
|
382
787
|
retriable: true,
|
|
383
788
|
atMs: Date.now()
|
|
384
789
|
};
|
|
790
|
+
if (isPayloadProtectionError(err)) return {
|
|
791
|
+
code: "protocol",
|
|
792
|
+
message: err.message,
|
|
793
|
+
retriable: false,
|
|
794
|
+
atMs: Date.now()
|
|
795
|
+
};
|
|
385
796
|
return {
|
|
386
797
|
code: "internal",
|
|
387
798
|
message,
|
|
@@ -430,6 +841,13 @@ function toTransportError(error) {
|
|
|
430
841
|
retryable: true,
|
|
431
842
|
message: error.message
|
|
432
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
|
+
};
|
|
433
851
|
if (error instanceof TypeError) return {
|
|
434
852
|
code: "network_error",
|
|
435
853
|
retryable: true,
|
|
@@ -782,16 +1200,19 @@ var StreamsTransportClient = class {
|
|
|
782
1200
|
var TransportCursorManager = class {
|
|
783
1201
|
adapter;
|
|
784
1202
|
decodeSnapshot;
|
|
1203
|
+
decodeUpdateItems;
|
|
785
1204
|
saveRemoteCursor;
|
|
786
1205
|
constructor(options) {
|
|
787
1206
|
this.adapter = options.adapter;
|
|
788
1207
|
this.decodeSnapshot = options.decodeSnapshot;
|
|
1208
|
+
this.decodeUpdateItems = options.decodeUpdateItems;
|
|
789
1209
|
this.saveRemoteCursor = options.saveRemoteCursor;
|
|
790
1210
|
}
|
|
791
1211
|
async applyBootstrapPayload(cursor, snapshot, updates, nextOffset) {
|
|
792
1212
|
let nextVersion = cursor.serverLowerBoundVersion;
|
|
793
1213
|
if (snapshot.byteLength > 0) nextVersion = await this.adapter.applySnapshot(this.decodeSnapshot == null ? snapshot : await this.decodeSnapshot(snapshot));
|
|
794
|
-
|
|
1214
|
+
const clearUpdates = await this.decodeRemoteUpdateItems(updates);
|
|
1215
|
+
if (clearUpdates.length > 0) nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
|
|
795
1216
|
return await this.finalizeCursor({
|
|
796
1217
|
...cursor,
|
|
797
1218
|
nextOffset,
|
|
@@ -811,8 +1232,9 @@ var TransportCursorManager = class {
|
|
|
811
1232
|
async applyLiveResult(cursor, requestOffset, result, onRemoteVersionApplied) {
|
|
812
1233
|
const previousVersion = cursor.serverLowerBoundVersion;
|
|
813
1234
|
let nextVersion = previousVersion;
|
|
814
|
-
|
|
815
|
-
|
|
1235
|
+
const clearUpdates = await this.decodeRemoteUpdateItems(result.updates);
|
|
1236
|
+
if (clearUpdates.length > 0) {
|
|
1237
|
+
nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
|
|
816
1238
|
await onRemoteVersionApplied?.(previousVersion, nextVersion);
|
|
817
1239
|
}
|
|
818
1240
|
return await this.finalizeCursor({
|
|
@@ -824,8 +1246,9 @@ var TransportCursorManager = class {
|
|
|
824
1246
|
async applyRemotePayload(cursor, updates, nextOffset, source, onRemoteVersionApplied) {
|
|
825
1247
|
const previousVersion = cursor.serverLowerBoundVersion;
|
|
826
1248
|
let nextVersion = previousVersion;
|
|
827
|
-
|
|
828
|
-
|
|
1249
|
+
const clearUpdates = await this.decodeRemoteUpdateItems(updates);
|
|
1250
|
+
if (clearUpdates.length > 0) {
|
|
1251
|
+
nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
|
|
829
1252
|
await onRemoteVersionApplied?.(previousVersion, nextVersion);
|
|
830
1253
|
}
|
|
831
1254
|
return await this.finalizeCursor({
|
|
@@ -837,126 +1260,9 @@ var TransportCursorManager = class {
|
|
|
837
1260
|
async finalizeCursor(cursor, source) {
|
|
838
1261
|
return await this.saveRemoteCursor(cursor, source);
|
|
839
1262
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
const DEFAULT_COMPACT_THRESHOLD = 64;
|
|
844
|
-
const DEFAULT_MIN_CAPACITY = 16;
|
|
845
|
-
var Deque = class {
|
|
846
|
-
buffer;
|
|
847
|
-
head = 0;
|
|
848
|
-
tail = 0;
|
|
849
|
-
compactThreshold;
|
|
850
|
-
minCapacity;
|
|
851
|
-
constructor(values, options = {}) {
|
|
852
|
-
this.compactThreshold = Math.max(1, options.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD);
|
|
853
|
-
this.minCapacity = Math.max(1, options.minCapacity ?? DEFAULT_MIN_CAPACITY);
|
|
854
|
-
const seed = values == null ? [] : Array.from(values);
|
|
855
|
-
this.buffer = seed.slice();
|
|
856
|
-
this.tail = seed.length;
|
|
857
|
-
}
|
|
858
|
-
get length() {
|
|
859
|
-
return this.tail - this.head;
|
|
860
|
-
}
|
|
861
|
-
isEmpty() {
|
|
862
|
-
return this.length === 0;
|
|
863
|
-
}
|
|
864
|
-
clear() {
|
|
865
|
-
this.buffer = [];
|
|
866
|
-
this.head = 0;
|
|
867
|
-
this.tail = 0;
|
|
868
|
-
}
|
|
869
|
-
peekFront() {
|
|
870
|
-
if (this.length === 0) return;
|
|
871
|
-
const value = this.buffer[this.head];
|
|
872
|
-
return value == null ? void 0 : value;
|
|
873
|
-
}
|
|
874
|
-
peekBack() {
|
|
875
|
-
if (this.length === 0) return;
|
|
876
|
-
const value = this.buffer[this.tail - 1];
|
|
877
|
-
return value == null ? void 0 : value;
|
|
878
|
-
}
|
|
879
|
-
at(index) {
|
|
880
|
-
const length = this.length;
|
|
881
|
-
if (length === 0) return;
|
|
882
|
-
let offset = index;
|
|
883
|
-
if (offset < 0) offset += length;
|
|
884
|
-
if (offset < 0 || offset >= length) return;
|
|
885
|
-
const value = this.buffer[this.head + offset];
|
|
886
|
-
return value == null ? void 0 : value;
|
|
887
|
-
}
|
|
888
|
-
pushBack(value) {
|
|
889
|
-
this.ensureBackCapacity();
|
|
890
|
-
this.buffer[this.tail] = value;
|
|
891
|
-
this.tail += 1;
|
|
892
|
-
return this.length;
|
|
893
|
-
}
|
|
894
|
-
pushFront(value) {
|
|
895
|
-
if (this.head === 0) this.recenter(1, 0);
|
|
896
|
-
this.head -= 1;
|
|
897
|
-
this.buffer[this.head] = value;
|
|
898
|
-
return this.length;
|
|
899
|
-
}
|
|
900
|
-
popFront() {
|
|
901
|
-
if (this.length === 0) return;
|
|
902
|
-
const value = this.buffer[this.head];
|
|
903
|
-
this.buffer[this.head] = null;
|
|
904
|
-
this.head += 1;
|
|
905
|
-
this.compactIfNeeded();
|
|
906
|
-
return value == null ? void 0 : value;
|
|
907
|
-
}
|
|
908
|
-
popBack() {
|
|
909
|
-
if (this.length === 0) return;
|
|
910
|
-
this.tail -= 1;
|
|
911
|
-
const value = this.buffer[this.tail];
|
|
912
|
-
this.buffer[this.tail] = null;
|
|
913
|
-
this.compactIfNeeded();
|
|
914
|
-
return value == null ? void 0 : value;
|
|
915
|
-
}
|
|
916
|
-
toArray() {
|
|
917
|
-
const out = [];
|
|
918
|
-
for (let i = this.head; i < this.tail; i += 1) out.push(this.buffer[i]);
|
|
919
|
-
return out;
|
|
920
|
-
}
|
|
921
|
-
*[Symbol.iterator]() {
|
|
922
|
-
for (let i = this.head; i < this.tail; i += 1) yield this.buffer[i];
|
|
923
|
-
}
|
|
924
|
-
ensureBackCapacity() {
|
|
925
|
-
if (this.tail < this.buffer.length) return;
|
|
926
|
-
if (this.head > 0) {
|
|
927
|
-
this.compactToFront();
|
|
928
|
-
if (this.tail < this.buffer.length) return;
|
|
929
|
-
}
|
|
930
|
-
this.recenter(0, 1);
|
|
931
|
-
}
|
|
932
|
-
recenter(extraFront, extraBack) {
|
|
933
|
-
const length = this.length;
|
|
934
|
-
const needed = length + extraFront + extraBack;
|
|
935
|
-
const currentCapacity = this.buffer.length;
|
|
936
|
-
let nextCapacity = Math.max(currentCapacity, this.minCapacity);
|
|
937
|
-
if (nextCapacity < needed) nextCapacity = Math.max(nextCapacity * 2, needed, this.minCapacity);
|
|
938
|
-
const free = nextCapacity - length;
|
|
939
|
-
const nextHead = Math.max(extraFront, Math.floor(free / 2));
|
|
940
|
-
const next = new Array(nextCapacity);
|
|
941
|
-
for (let i = 0; i < length; i += 1) next[nextHead + i] = this.buffer[this.head + i];
|
|
942
|
-
this.buffer = next;
|
|
943
|
-
this.head = nextHead;
|
|
944
|
-
this.tail = nextHead + length;
|
|
945
|
-
}
|
|
946
|
-
compactIfNeeded() {
|
|
947
|
-
const length = this.length;
|
|
948
|
-
if (length === 0) {
|
|
949
|
-
this.clear();
|
|
950
|
-
return;
|
|
951
|
-
}
|
|
952
|
-
if (this.head < this.compactThreshold || this.head < length) return;
|
|
953
|
-
this.compactToFront();
|
|
954
|
-
}
|
|
955
|
-
compactToFront() {
|
|
956
|
-
const length = this.length;
|
|
957
|
-
this.buffer = this.buffer.slice(this.head, this.tail);
|
|
958
|
-
this.head = 0;
|
|
959
|
-
this.tail = length;
|
|
1263
|
+
async decodeRemoteUpdateItems(items) {
|
|
1264
|
+
if (items.length === 0) return [];
|
|
1265
|
+
return this.decodeUpdateItems == null ? [...items] : await this.decodeUpdateItems(items);
|
|
960
1266
|
}
|
|
961
1267
|
};
|
|
962
1268
|
//#endregion
|
|
@@ -1102,7 +1408,8 @@ function createJoinState(cursor, liveMode = "sse") {
|
|
|
1102
1408
|
liveMode,
|
|
1103
1409
|
snapshot: createTransportSnapshotState(),
|
|
1104
1410
|
started: Promise.resolve(),
|
|
1105
|
-
syncWaiters: []
|
|
1411
|
+
syncWaiters: [],
|
|
1412
|
+
initialSyncFailed: false
|
|
1106
1413
|
};
|
|
1107
1414
|
}
|
|
1108
1415
|
async function disposeJoinState(state, options) {
|
|
@@ -1113,6 +1420,7 @@ async function disposeJoinState(state, options) {
|
|
|
1113
1420
|
const unsubscribeLocal = state.unsubscribeLocal;
|
|
1114
1421
|
state.unsubscribeLocal = void 0;
|
|
1115
1422
|
requestController?.abort();
|
|
1423
|
+
state.retrySleepController.abort();
|
|
1116
1424
|
unsubscribeLocal?.();
|
|
1117
1425
|
const disposedError = /* @__PURE__ */ new Error("subscription closed");
|
|
1118
1426
|
for (const waiter of state.syncWaiters.splice(0)) waiter.reject(disposedError);
|
|
@@ -1132,6 +1440,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1132
1440
|
cursorManager;
|
|
1133
1441
|
volatileCursorManager;
|
|
1134
1442
|
decodeSnapshot;
|
|
1443
|
+
encodeSnapshot;
|
|
1444
|
+
decodeUpdateItems;
|
|
1445
|
+
encodeUpdateItems;
|
|
1446
|
+
measureUpdateItemsByteLength;
|
|
1135
1447
|
reconnectConfig;
|
|
1136
1448
|
debug;
|
|
1137
1449
|
beforeRemoteCursorSave;
|
|
@@ -1139,6 +1451,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1139
1451
|
producerId = createProducerId();
|
|
1140
1452
|
producerEpoch = 0;
|
|
1141
1453
|
nextProducerSeq = 0;
|
|
1454
|
+
pendingWriteOnly = new Deque();
|
|
1455
|
+
writeOnlyCursor;
|
|
1456
|
+
unsubscribeWriteOnlyLocal;
|
|
1457
|
+
writeOnlyDisabledReason;
|
|
1142
1458
|
joinState;
|
|
1143
1459
|
opQueue = Promise.resolve();
|
|
1144
1460
|
/**
|
|
@@ -1163,8 +1479,17 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1163
1479
|
this.debug = options.debug ?? false;
|
|
1164
1480
|
this.beforeRemoteCursorSave = composeBeforeRemoteCursorSaveHooks(options.beforeRemoteCursorSave, ...options.beforeRemoteCursorSaveHooks ?? []);
|
|
1165
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`);
|
|
1166
1487
|
const snapshotUploadConfig = normalizeSnapshotUploadConfig(options.snapshotUpload);
|
|
1167
|
-
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);
|
|
1168
1493
|
this.client = new StreamsTransportClient({
|
|
1169
1494
|
streamUrl: this.streamUrl,
|
|
1170
1495
|
adapter: this.adapter,
|
|
@@ -1177,7 +1502,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1177
1502
|
this.snapshotManager = new TransportSnapshotManager({
|
|
1178
1503
|
adapter: this.adapter,
|
|
1179
1504
|
config: snapshotUploadConfig,
|
|
1180
|
-
encodeSnapshot:
|
|
1505
|
+
encodeSnapshot: this.encodeSnapshot,
|
|
1181
1506
|
readSnapshotOffsetValue: () => this.client.readSnapshotOffsetValue(),
|
|
1182
1507
|
putSnapshotAt: (snapshotOffset, snapshot) => this.client.putSnapshotAt(snapshotOffset, snapshot),
|
|
1183
1508
|
enqueueExclusive: (task) => this.enqueueExclusive(task),
|
|
@@ -1185,11 +1510,31 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1185
1510
|
logError: (message, error, detail) => this.logError(message, error, detail),
|
|
1186
1511
|
isCurrentState: (state) => this.joinState === state
|
|
1187
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
|
+
};
|
|
1188
1532
|
}
|
|
1189
1533
|
/**
|
|
1190
1534
|
* Stops any active live join and releases transport-owned state.
|
|
1191
1535
|
*/
|
|
1192
1536
|
async close() {
|
|
1537
|
+
this.disableWriteOnlyMode("transport closed");
|
|
1193
1538
|
const state = this.joinState;
|
|
1194
1539
|
if (state != null) await this.disposeJoinState(state);
|
|
1195
1540
|
}
|
|
@@ -1206,7 +1551,27 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1206
1551
|
rejoin() {
|
|
1207
1552
|
const state = this.joinState;
|
|
1208
1553
|
if (state == null || state.closed) return;
|
|
1209
|
-
if (state.
|
|
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;
|
|
1210
1575
|
state.retryAttempt = 0;
|
|
1211
1576
|
state.retrySleepController.abort();
|
|
1212
1577
|
state.retrySleepController = new AbortController();
|
|
@@ -1240,6 +1605,33 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1240
1605
|
}
|
|
1241
1606
|
}
|
|
1242
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
|
+
/**
|
|
1243
1635
|
* Explicitly creates the target stream.
|
|
1244
1636
|
*/
|
|
1245
1637
|
async createStream() {
|
|
@@ -1283,6 +1675,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1283
1675
|
*/
|
|
1284
1676
|
async sync() {
|
|
1285
1677
|
try {
|
|
1678
|
+
this.disableWriteOnlyMode("appendWriteOnly cannot be used after sync() on the same transport");
|
|
1286
1679
|
return {
|
|
1287
1680
|
ok: true,
|
|
1288
1681
|
value: { cursor: await this.syncOnce() }
|
|
@@ -1295,27 +1688,75 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1295
1688
|
}
|
|
1296
1689
|
}
|
|
1297
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
|
+
/**
|
|
1298
1730
|
* Replays remote state, flushes queued local updates, then enters live read mode.
|
|
1299
1731
|
*/
|
|
1300
1732
|
async join(params) {
|
|
1733
|
+
this.disableWriteOnlyMode("appendWriteOnly cannot be used after join() on the same transport");
|
|
1301
1734
|
const existing = this.joinState;
|
|
1302
|
-
if (existing != null)
|
|
1303
|
-
|
|
1304
|
-
return {
|
|
1735
|
+
if (existing != null) {
|
|
1736
|
+
if (existing.initialSyncFailed && !existing.closed) return {
|
|
1305
1737
|
ok: true,
|
|
1306
1738
|
value: this.createSubscription(existing, params)
|
|
1307
1739
|
};
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
+
}
|
|
1313
1752
|
}
|
|
1314
1753
|
const state = createJoinState(createInitialRemoteCursor({
|
|
1315
1754
|
streamUrl: this.streamUrl,
|
|
1316
1755
|
nextOffset: "-1",
|
|
1317
1756
|
serverLowerBoundVersion: this.adapter.emptyVersion()
|
|
1318
1757
|
}));
|
|
1758
|
+
const statusListener = params?.onStatusChange;
|
|
1759
|
+
if (statusListener != null) state.statusListeners.add(statusListener);
|
|
1319
1760
|
state.started = this.startJoin(state);
|
|
1320
1761
|
this.joinState = state;
|
|
1321
1762
|
try {
|
|
@@ -1325,10 +1766,20 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1325
1766
|
value: this.createSubscription(state, params)
|
|
1326
1767
|
};
|
|
1327
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
|
+
}
|
|
1328
1779
|
await this.disposeJoinState(state);
|
|
1329
1780
|
return {
|
|
1330
1781
|
ok: false,
|
|
1331
|
-
error:
|
|
1782
|
+
error: transportError
|
|
1332
1783
|
};
|
|
1333
1784
|
}
|
|
1334
1785
|
}
|
|
@@ -1336,8 +1787,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1336
1787
|
state.refCount += 1;
|
|
1337
1788
|
const statusListener = params?.onStatusChange;
|
|
1338
1789
|
if (statusListener != null) {
|
|
1339
|
-
state.statusListeners.
|
|
1340
|
-
|
|
1790
|
+
if (!state.statusListeners.has(statusListener)) {
|
|
1791
|
+
state.statusListeners.add(statusListener);
|
|
1792
|
+
statusListener(state.status);
|
|
1793
|
+
}
|
|
1341
1794
|
}
|
|
1342
1795
|
let unsubscribed = false;
|
|
1343
1796
|
return {
|
|
@@ -1353,7 +1806,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1353
1806
|
return state.status === "joined";
|
|
1354
1807
|
},
|
|
1355
1808
|
get status() {
|
|
1356
|
-
return state.status;
|
|
1809
|
+
return state.status === "connecting" ? "reconnecting" : state.status;
|
|
1810
|
+
},
|
|
1811
|
+
get lastError() {
|
|
1812
|
+
return state.lastError;
|
|
1357
1813
|
},
|
|
1358
1814
|
onStatusChange: (listener) => {
|
|
1359
1815
|
state.statusListeners.add(listener);
|
|
@@ -1363,6 +1819,20 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1363
1819
|
};
|
|
1364
1820
|
},
|
|
1365
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
|
+
}
|
|
1366
1836
|
await this.adapter.flushLocalExports?.();
|
|
1367
1837
|
if (state.closed) throw new Error("subscription closed");
|
|
1368
1838
|
const remaining = state.pendingLocal.length;
|
|
@@ -1405,12 +1875,14 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1405
1875
|
if (state.status === "error" || state.status === "disconnected") throw new Error("join local sync failed before subscription became ready");
|
|
1406
1876
|
}
|
|
1407
1877
|
if (state.closed) return;
|
|
1878
|
+
state.lastError = void 0;
|
|
1408
1879
|
this.setReadSubStatus(state, "ok");
|
|
1409
1880
|
this.setWriteSubStatus(state, "ok");
|
|
1410
1881
|
this.runReadLoop(state);
|
|
1411
1882
|
} catch (error) {
|
|
1412
1883
|
if (state.closed) return;
|
|
1413
1884
|
this.snapshotManager.dispose(state);
|
|
1885
|
+
state.lastError = toTransportError(error);
|
|
1414
1886
|
this.logError("join first sync failed", error, { streamUrl: this.streamUrl });
|
|
1415
1887
|
state.readSubStatus = "error";
|
|
1416
1888
|
state.writeSubStatus = "error";
|
|
@@ -1533,7 +2005,8 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1533
2005
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1534
2006
|
let result;
|
|
1535
2007
|
try {
|
|
1536
|
-
|
|
2008
|
+
const wireUpdates = this.encodeUpdateItems == null ? batch.updates : await this.encodeUpdateItems(batch.updates);
|
|
2009
|
+
result = await this.client.appendUpdates(wireUpdates, producerSession);
|
|
1537
2010
|
} catch (error) {
|
|
1538
2011
|
if (isStreamNotFoundError(error) && this.createStreamIfMissing) {
|
|
1539
2012
|
await this.ensureStreamExists();
|
|
@@ -1597,6 +2070,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1597
2070
|
return new TransportCursorManager({
|
|
1598
2071
|
adapter,
|
|
1599
2072
|
decodeSnapshot: this.decodeSnapshot,
|
|
2073
|
+
decodeUpdateItems: this.decodeUpdateItems,
|
|
1600
2074
|
saveRemoteCursor
|
|
1601
2075
|
});
|
|
1602
2076
|
}
|
|
@@ -1718,6 +2192,9 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1718
2192
|
}
|
|
1719
2193
|
});
|
|
1720
2194
|
this.setReadSubStatus(state, "ok");
|
|
2195
|
+
}).catch((flushError) => {
|
|
2196
|
+
this.logError("sse flush chain error", flushError, { streamUrl: this.streamUrl });
|
|
2197
|
+
capturedController?.abort(flushError);
|
|
1721
2198
|
});
|
|
1722
2199
|
}, () => {
|
|
1723
2200
|
resetInactivity();
|
|
@@ -1738,10 +2215,12 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1738
2215
|
continue;
|
|
1739
2216
|
}
|
|
1740
2217
|
if (sseResult.kind === "not-found") {
|
|
1741
|
-
|
|
2218
|
+
const notFoundError = new HttpError("stream not found", 404);
|
|
2219
|
+
this.logError("live read failed", notFoundError, {
|
|
1742
2220
|
streamUrl: this.streamUrl,
|
|
1743
2221
|
code: "stream_not_found"
|
|
1744
2222
|
});
|
|
2223
|
+
state.lastError = toTransportError(notFoundError);
|
|
1745
2224
|
this.setReadSubStatus(state, "error");
|
|
1746
2225
|
return;
|
|
1747
2226
|
}
|
|
@@ -1767,12 +2246,32 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1767
2246
|
streamUrl: this.streamUrl,
|
|
1768
2247
|
attempts: state.retryAttempt
|
|
1769
2248
|
});
|
|
2249
|
+
state.lastError = {
|
|
2250
|
+
code: "timeout",
|
|
2251
|
+
retryable: true,
|
|
2252
|
+
phase: "poll",
|
|
2253
|
+
message: "SSE inactivity timeout exhausted retry budget"
|
|
2254
|
+
};
|
|
1770
2255
|
this.setReadSubStatus(state, "disconnected");
|
|
1771
2256
|
return;
|
|
1772
2257
|
}
|
|
1773
2258
|
state.retryAttempt += 1;
|
|
1774
2259
|
continue;
|
|
1775
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
|
+
}
|
|
1776
2275
|
state.retryAttempt = 0;
|
|
1777
2276
|
this.setReadSubStatus(state, "reconnecting");
|
|
1778
2277
|
continue;
|
|
@@ -1783,6 +2282,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1783
2282
|
streamUrl: this.streamUrl,
|
|
1784
2283
|
code: classified.code
|
|
1785
2284
|
});
|
|
2285
|
+
state.lastError = toTransportError(error);
|
|
1786
2286
|
this.setReadSubStatus(state, "error");
|
|
1787
2287
|
return;
|
|
1788
2288
|
}
|
|
@@ -1793,6 +2293,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1793
2293
|
streamUrl: this.streamUrl,
|
|
1794
2294
|
attempts: state.retryAttempt
|
|
1795
2295
|
});
|
|
2296
|
+
state.lastError = toTransportError(error);
|
|
1796
2297
|
this.setReadSubStatus(state, "disconnected");
|
|
1797
2298
|
return;
|
|
1798
2299
|
}
|
|
@@ -1810,20 +2311,19 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1810
2311
|
*
|
|
1811
2312
|
* Returns `undefined` when the queue is empty.
|
|
1812
2313
|
*/
|
|
1813
|
-
|
|
1814
|
-
const first =
|
|
2314
|
+
async drainQueuedBatch(pendingLocal) {
|
|
2315
|
+
const first = pendingLocal.peekFront();
|
|
1815
2316
|
if (first == null) return;
|
|
1816
2317
|
const maxBytes = StreamsCrdt.MAX_BATCH_BYTES;
|
|
1817
|
-
let totalBytes = first.updates.reduce((sum, u) => sum + 4 + u.byteLength, 0);
|
|
1818
2318
|
let mergedVersion = first.version;
|
|
1819
2319
|
const allUpdates = Array.from(first.updates);
|
|
1820
2320
|
let count = 1;
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
const
|
|
1824
|
-
|
|
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;
|
|
1825
2326
|
allUpdates.push(...next.updates);
|
|
1826
|
-
totalBytes += nextBytes;
|
|
1827
2327
|
mergedVersion = this.adapter.mergeVersions(mergedVersion, next.version);
|
|
1828
2328
|
count += 1;
|
|
1829
2329
|
}
|
|
@@ -1839,12 +2339,18 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1839
2339
|
count
|
|
1840
2340
|
};
|
|
1841
2341
|
}
|
|
2342
|
+
drainPendingBatch(state) {
|
|
2343
|
+
return this.drainQueuedBatch(state.pendingLocal);
|
|
2344
|
+
}
|
|
2345
|
+
drainPendingWriteOnlyBatch() {
|
|
2346
|
+
return this.drainQueuedBatch(this.pendingWriteOnly);
|
|
2347
|
+
}
|
|
1842
2348
|
async flushPendingLocal(state) {
|
|
1843
2349
|
if (state.flushingLocal || state.closed) return;
|
|
1844
2350
|
state.flushingLocal = true;
|
|
1845
2351
|
try {
|
|
1846
2352
|
while (!state.closed) {
|
|
1847
|
-
const drained = this.drainPendingBatch(state);
|
|
2353
|
+
const drained = await this.drainPendingBatch(state);
|
|
1848
2354
|
if (drained == null) return;
|
|
1849
2355
|
let attempts = 0;
|
|
1850
2356
|
while (!state.closed) try {
|
|
@@ -1861,12 +2367,14 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1861
2367
|
streamUrl: this.streamUrl,
|
|
1862
2368
|
code: classified.code
|
|
1863
2369
|
});
|
|
2370
|
+
state.lastError = toTransportError(error);
|
|
1864
2371
|
this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`local append failed: ${classified.code}`));
|
|
1865
2372
|
this.setWriteSubStatus(state, "error");
|
|
1866
2373
|
return;
|
|
1867
2374
|
}
|
|
1868
2375
|
const maxAttempts = this.reconnectConfig.maxAttempts;
|
|
1869
2376
|
if (attempts >= maxAttempts) {
|
|
2377
|
+
state.lastError = toTransportError(error);
|
|
1870
2378
|
this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("local append retry budget exhausted"));
|
|
1871
2379
|
this.setWriteSubStatus(state, "error");
|
|
1872
2380
|
return;
|
|
@@ -1922,6 +2430,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1922
2430
|
const status = this.computeOverallStatus(state);
|
|
1923
2431
|
if (state.status === status) return;
|
|
1924
2432
|
state.status = status;
|
|
2433
|
+
if (status === "joined") state.lastError = void 0;
|
|
1925
2434
|
for (const listener of state.statusListeners) listener(status);
|
|
1926
2435
|
}
|
|
1927
2436
|
computeOverallStatus(state) {
|
|
@@ -1937,6 +2446,17 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
1937
2446
|
this.opQueue = run.then(() => void 0, () => void 0);
|
|
1938
2447
|
return run;
|
|
1939
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
|
+
}
|
|
1940
2460
|
logDebug(message, detail) {
|
|
1941
2461
|
if (!this.debug) return;
|
|
1942
2462
|
if (detail == null) {
|
|
@@ -1988,6 +2508,6 @@ function createStreamUrl(input) {
|
|
|
1988
2508
|
return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
|
|
1989
2509
|
}
|
|
1990
2510
|
//#endregion
|
|
1991
|
-
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 };
|
|
1992
2512
|
|
|
1993
|
-
//# sourceMappingURL=stream-id-
|
|
2513
|
+
//# sourceMappingURL=stream-id-CbVwCS3g.js.map
|