@loro-dev/streams-crdt 0.14.1 → 0.15.1
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 +461 -147
- 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-CEp4zEQJ.js → stream-id-BygPTNvQ.js} +284 -229
- package/dist/stream-id-BygPTNvQ.js.map +1 -0
- package/dist/{stream-id-BuXMoCvo.d.ts → stream-id-CVCOLgSp.d.ts} +123 -52
- package/package.json +1 -1
- package/dist/stream-id-CEp4zEQJ.js.map +0 -1
|
@@ -208,59 +208,51 @@ const MAGIC = new Uint8Array([
|
|
|
208
208
|
67,
|
|
209
209
|
69
|
|
210
210
|
]);
|
|
211
|
-
const
|
|
212
|
-
const
|
|
211
|
+
const PROVIDER_ENVELOPE_VERSION = 2;
|
|
212
|
+
const PAYLOAD_PROTECTION_PROTOCOL = "loro-streams-crdt-payload-protection";
|
|
213
213
|
const UPDATE_BATCH_KIND = 1;
|
|
214
214
|
const SNAPSHOT_KIND = 2;
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const textEncoder$1 = new TextEncoder();
|
|
220
|
-
const textDecoder = new TextDecoder();
|
|
215
|
+
const PROVIDER_PREFIX_BYTES = 10;
|
|
216
|
+
const MAX_PROVIDER_HEADER_BYTES = 512;
|
|
217
|
+
const MAX_PROVIDER_SEAL_OVERHEAD_BYTES = 4 * 1024;
|
|
218
|
+
const providerAadDomain = new TextEncoder().encode(`${PAYLOAD_PROTECTION_PROTOCOL}/v2\0`);
|
|
221
219
|
var PayloadProtectionError = class extends Error {
|
|
222
220
|
reason;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
super(message, options.cause == null ? void 0 : { cause: options.cause });
|
|
221
|
+
constructor(reason, message = payloadProtectionFailureMessage(reason)) {
|
|
222
|
+
super(message);
|
|
226
223
|
this.name = "PayloadProtectionError";
|
|
227
224
|
this.reason = reason;
|
|
228
|
-
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
var ProviderSealContractError = class extends PayloadProtectionError {
|
|
228
|
+
constructor(message) {
|
|
229
|
+
super("encrypt_failed", message);
|
|
230
|
+
this.name = "ProviderSealContractError";
|
|
229
231
|
}
|
|
230
232
|
};
|
|
231
233
|
var PayloadProtectionRuntime = class {
|
|
232
234
|
readPolicy;
|
|
233
235
|
writePolicy;
|
|
234
|
-
|
|
235
|
-
writeKey;
|
|
236
|
-
readKeys;
|
|
237
|
-
importedWriteKey;
|
|
238
|
-
importedReadKeys = /* @__PURE__ */ new Map();
|
|
236
|
+
provider;
|
|
239
237
|
constructor(options) {
|
|
240
238
|
this.readPolicy = options.readPolicy;
|
|
241
239
|
this.writePolicy = options.writePolicy;
|
|
242
|
-
this.
|
|
243
|
-
this.writeKey = options.writeKey;
|
|
244
|
-
this.readKeys = options.readKeys;
|
|
240
|
+
this.provider = options.provider;
|
|
245
241
|
}
|
|
246
242
|
async encodeUpdateItems(clearUpdates) {
|
|
247
243
|
if (this.writePolicy === "plaintext") return clearUpdates;
|
|
248
|
-
return [await this.
|
|
244
|
+
return [await this.sealPayload("update_batch", encodeItems(clearUpdates))];
|
|
249
245
|
}
|
|
250
246
|
measureEncodedUpdateItemsByteLength(clearUpdates) {
|
|
251
247
|
if (this.writePolicy === "plaintext") return encodedItemsByteLength(clearUpdates);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const clearBatchBytes = encodedItemsByteLength(clearUpdates);
|
|
255
|
-
const keyIdBytes = textEncoder$1.encode(writeKey.id);
|
|
256
|
-
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 });
|
|
257
|
-
return 4 + (HEADER_BYTES + keyIdBytes.byteLength + AES_GCM_NONCE_BYTES + clearBatchBytes + AES_GCM_TAG_BITS / 8);
|
|
248
|
+
if (this.provider == null) throw new PayloadProtectionError("encrypt_failed", "provider-backed E2EE writes require a provider");
|
|
249
|
+
return 4 + PROVIDER_PREFIX_BYTES + encodedItemsByteLength(clearUpdates) + this.provider.maxSealOverheadBytes;
|
|
258
250
|
}
|
|
259
251
|
async decodeUpdateItems(wireItems) {
|
|
260
252
|
const out = [];
|
|
261
253
|
for (const item of wireItems) {
|
|
262
254
|
if (isEncryptedEnvelope(item)) {
|
|
263
|
-
const clearBatch = await this.
|
|
255
|
+
const clearBatch = await this.openPayload("update_batch", item);
|
|
264
256
|
out.push(...decodeItems(clearBatch));
|
|
265
257
|
continue;
|
|
266
258
|
}
|
|
@@ -271,75 +263,76 @@ var PayloadProtectionRuntime = class {
|
|
|
271
263
|
}
|
|
272
264
|
async encodeSnapshot(snapshot) {
|
|
273
265
|
if (this.writePolicy === "plaintext") return snapshot;
|
|
274
|
-
return await this.
|
|
266
|
+
return await this.sealPayload("snapshot", snapshot);
|
|
275
267
|
}
|
|
276
268
|
async decodeSnapshot(snapshot) {
|
|
277
|
-
if (isEncryptedEnvelope(snapshot)) return await this.
|
|
269
|
+
if (isEncryptedEnvelope(snapshot)) return await this.openPayload("snapshot", snapshot);
|
|
278
270
|
this.assertPlaintextAllowed("snapshot");
|
|
279
271
|
return snapshot;
|
|
280
272
|
}
|
|
281
|
-
async
|
|
282
|
-
|
|
283
|
-
|
|
273
|
+
async sealPayload(kind, plaintext) {
|
|
274
|
+
return await this.sealProviderPayload(kind, plaintext);
|
|
275
|
+
}
|
|
276
|
+
async openPayload(expectedKind, envelope) {
|
|
277
|
+
const version = envelope[4];
|
|
278
|
+
if (version === PROVIDER_ENVELOPE_VERSION) return await this.openProviderPayload(expectedKind, envelope);
|
|
279
|
+
throw new PayloadProtectionError("invalid_envelope", `unsupported streams-crdt protected payload envelope version '${String(version)}'`);
|
|
280
|
+
}
|
|
281
|
+
async sealProviderPayload(kind, plaintext) {
|
|
282
|
+
const provider = this.provider;
|
|
283
|
+
if (provider == null) throw new PayloadProtectionError("encrypt_failed", "provider-backed E2EE writes require a provider");
|
|
284
|
+
const context = createProviderContext(kind);
|
|
285
|
+
let authenticatedHeader;
|
|
286
|
+
let additionalDataCalls = 0;
|
|
287
|
+
let result;
|
|
284
288
|
try {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
kind,
|
|
293
|
-
|
|
294
|
-
scope: this.scope
|
|
295
|
-
}))
|
|
296
|
-
}, key, toArrayBuffer(plaintext))));
|
|
297
|
-
} catch (error) {
|
|
298
|
-
if (error instanceof PayloadProtectionError) throw error;
|
|
299
|
-
throw new PayloadProtectionError("encrypt_failed", "failed to encrypt streams-crdt payload", {
|
|
300
|
-
keyId: writeKey.id,
|
|
301
|
-
cause: error
|
|
289
|
+
result = await provider.seal({
|
|
290
|
+
plaintext,
|
|
291
|
+
context,
|
|
292
|
+
additionalData: (header) => {
|
|
293
|
+
additionalDataCalls += 1;
|
|
294
|
+
if (additionalDataCalls !== 1) throw new ProviderSealContractError("payload protection provider must request additionalData exactly once");
|
|
295
|
+
authenticatedHeader = normalizeProviderHeader(header).slice();
|
|
296
|
+
return createProviderAad(createProviderEnvelopeHeader(kind, authenticatedHeader));
|
|
297
|
+
}
|
|
302
298
|
});
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error instanceof ProviderSealContractError) throw error;
|
|
301
|
+
const reason = error instanceof PayloadProtectionError ? error.reason : "encrypt_failed";
|
|
302
|
+
throw new PayloadProtectionError(reason, payloadProtectionFailureMessage(reason));
|
|
303
303
|
}
|
|
304
|
-
}
|
|
305
|
-
async decryptPayload(expectedKind, envelope) {
|
|
306
|
-
const parsed = parseEnvelope(envelope);
|
|
307
|
-
if (parsed.kind !== expectedKind) throw new PayloadProtectionError("wrong_payload_kind", `expected ${expectedKind} payload, got ${parsed.kind}`, { keyId: parsed.keyId });
|
|
308
|
-
if (this.scope == null) throw new PayloadProtectionError("missing_read_key", "E2EE read decryption requires encryption.scope", { keyId: parsed.keyId });
|
|
309
|
-
const key = await this.getReadKey(parsed.keyId);
|
|
310
304
|
try {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
keyId: parsed.keyId,
|
|
318
|
-
scope: this.scope
|
|
319
|
-
}))
|
|
320
|
-
}, key, toArrayBuffer(parsed.ciphertext)));
|
|
305
|
+
const returnedHeader = normalizeProviderHeader(result.header);
|
|
306
|
+
if (additionalDataCalls !== 1) throw new ProviderSealContractError("payload protection provider must request additionalData exactly once");
|
|
307
|
+
if (authenticatedHeader == null || !equalBytes(authenticatedHeader, returnedHeader)) throw new ProviderSealContractError("payload protection provider must authenticate the exact header it returns");
|
|
308
|
+
if (!(result.sealed instanceof Uint8Array) || result.sealed.byteLength === 0) throw new ProviderSealContractError("payload protection provider returned empty or invalid sealed bytes");
|
|
309
|
+
if (returnedHeader.byteLength + result.sealed.byteLength - plaintext.byteLength > provider.maxSealOverheadBytes) throw new ProviderSealContractError("payload protection provider exceeded maxSealOverheadBytes");
|
|
310
|
+
return concatBytes(createProviderEnvelopeHeader(kind, returnedHeader), result.sealed);
|
|
321
311
|
} catch (error) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
});
|
|
312
|
+
if (error instanceof ProviderSealContractError) throw error;
|
|
313
|
+
const reason = error instanceof PayloadProtectionError ? error.reason : "encrypt_failed";
|
|
314
|
+
throw new PayloadProtectionError(reason, payloadProtectionFailureMessage(reason));
|
|
326
315
|
}
|
|
327
316
|
}
|
|
328
|
-
async
|
|
329
|
-
const
|
|
330
|
-
if (
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
317
|
+
async openProviderPayload(expectedKind, envelope) {
|
|
318
|
+
const parsed = parseProviderEnvelope(envelope);
|
|
319
|
+
if (parsed.kind !== expectedKind) throw new PayloadProtectionError("wrong_payload_kind", `expected ${expectedKind} payload, got ${parsed.kind}`);
|
|
320
|
+
const provider = this.provider;
|
|
321
|
+
if (provider == null) throw new PayloadProtectionError("missing_read_key", "provider-backed E2EE reads require a provider");
|
|
322
|
+
const context = createProviderContext(parsed.kind);
|
|
323
|
+
try {
|
|
324
|
+
const plaintext = await provider.open({
|
|
325
|
+
sealed: parsed.sealed,
|
|
326
|
+
header: parsed.header,
|
|
327
|
+
context,
|
|
328
|
+
additionalData: createProviderAad(parsed.authenticatedHeader)
|
|
329
|
+
});
|
|
330
|
+
if (!(plaintext instanceof Uint8Array)) throw new PayloadProtectionError("decrypt_failed", "payload protection provider returned invalid plaintext bytes");
|
|
331
|
+
return plaintext;
|
|
332
|
+
} catch (error) {
|
|
333
|
+
const reason = error instanceof PayloadProtectionError ? error.reason : "decrypt_failed";
|
|
334
|
+
throw new PayloadProtectionError(reason, payloadProtectionFailureMessage(reason));
|
|
335
|
+
}
|
|
343
336
|
}
|
|
344
337
|
assertPlaintextAllowed(label) {
|
|
345
338
|
if (this.readPolicy === "allow-plaintext") return;
|
|
@@ -352,62 +345,74 @@ function normalizePayloadProtection(options, label = "payloadProtection") {
|
|
|
352
345
|
const writePolicy = options.writePolicy ?? "encrypt";
|
|
353
346
|
if (readPolicy !== "encrypted-only" && readPolicy !== "allow-plaintext") throw new Error(`${label}.readPolicy must be encrypted-only or allow-plaintext`);
|
|
354
347
|
if (writePolicy !== "encrypt" && writePolicy !== "plaintext") throw new Error(`${label}.writePolicy must be encrypt or plaintext`);
|
|
355
|
-
const
|
|
356
|
-
if (
|
|
357
|
-
if (writePolicy === "encrypt" &&
|
|
358
|
-
if (writePolicy === "encrypt" && encryption?.scope == null) throw new Error(`${label}.encryption.scope is required when writePolicy is encrypt`);
|
|
359
|
-
validateKey(encryption?.writeKey, `${label}.encryption.writeKey`);
|
|
360
|
-
const readKeys = normalizeReadKeys(encryption?.readKeys, `${label}.encryption.readKeys`);
|
|
348
|
+
const provider = options.provider;
|
|
349
|
+
if (provider != null && (typeof provider.seal !== "function" || typeof provider.open !== "function" || !Number.isSafeInteger(provider.maxSealOverheadBytes) || provider.maxSealOverheadBytes < 0 || provider.maxSealOverheadBytes > MAX_PROVIDER_SEAL_OVERHEAD_BYTES)) throw new Error(`${label}.provider must implement seal()/open() and declare maxSealOverheadBytes as a safe integer from 0 through ${MAX_PROVIDER_SEAL_OVERHEAD_BYTES}`);
|
|
350
|
+
if (writePolicy === "encrypt" && provider == null) throw new Error(`${label}.provider is required when writePolicy is encrypt`);
|
|
361
351
|
return new PayloadProtectionRuntime({
|
|
362
352
|
readPolicy,
|
|
363
353
|
writePolicy,
|
|
364
|
-
|
|
365
|
-
writeKey: encryption?.writeKey,
|
|
366
|
-
readKeys
|
|
354
|
+
provider
|
|
367
355
|
});
|
|
368
356
|
}
|
|
369
357
|
function isPayloadProtectionError(error) {
|
|
370
358
|
return error instanceof PayloadProtectionError;
|
|
371
359
|
}
|
|
360
|
+
function payloadProtectionFailureMessage(reason) {
|
|
361
|
+
switch (reason) {
|
|
362
|
+
case "plaintext_forbidden": return "plaintext payload rejected by protected-read policy";
|
|
363
|
+
case "missing_read_key": return "protected payload key is unavailable";
|
|
364
|
+
case "decrypt_failed": return "protected payload authentication failed";
|
|
365
|
+
case "invalid_envelope": return "invalid protected payload envelope";
|
|
366
|
+
case "wrong_payload_kind": return "protected payload kind mismatch";
|
|
367
|
+
case "encrypt_failed": return "protected payload sealing failed";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
372
370
|
function isEncryptedEnvelope(payload) {
|
|
373
371
|
return payload.byteLength >= MAGIC.byteLength && MAGIC.every((byte, index) => payload[index] === byte);
|
|
374
372
|
}
|
|
375
|
-
function
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
373
|
+
function createProviderContext(kind) {
|
|
374
|
+
return {
|
|
375
|
+
protocol: PAYLOAD_PROTECTION_PROTOCOL,
|
|
376
|
+
version: PROVIDER_ENVELOPE_VERSION,
|
|
377
|
+
kind
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function createProviderEnvelopeHeader(kind, providerHeader) {
|
|
381
|
+
const normalizedHeader = normalizeProviderHeader(providerHeader);
|
|
382
|
+
const header = new Uint8Array(PROVIDER_PREFIX_BYTES + normalizedHeader.byteLength);
|
|
383
|
+
const view = new DataView(header.buffer);
|
|
380
384
|
header.set(MAGIC, 0);
|
|
381
|
-
header[4] =
|
|
385
|
+
header[4] = PROVIDER_ENVELOPE_VERSION;
|
|
382
386
|
header[5] = encodeKind(kind);
|
|
383
|
-
|
|
384
|
-
header[
|
|
385
|
-
header[8] = nonce.byteLength;
|
|
387
|
+
view.setUint16(6, normalizedHeader.byteLength, false);
|
|
388
|
+
header[8] = 0;
|
|
386
389
|
header[9] = 0;
|
|
387
|
-
header.set(
|
|
388
|
-
header.set(nonce, HEADER_BYTES + keyIdBytes.byteLength);
|
|
390
|
+
header.set(normalizedHeader, PROVIDER_PREFIX_BYTES);
|
|
389
391
|
return header;
|
|
390
392
|
}
|
|
391
|
-
function
|
|
392
|
-
if (!isEncryptedEnvelope(payload) || payload.byteLength <
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
const reserved = payload[9];
|
|
398
|
-
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");
|
|
399
|
-
const kind = decodeKind(payload[5]);
|
|
400
|
-
const keyIdStart = HEADER_BYTES;
|
|
401
|
-
const nonceStart = keyIdStart + keyIdLen;
|
|
402
|
-
const ciphertextStart = nonceStart + nonceLen;
|
|
403
|
-
if (keyIdLen === 0 || payload.byteLength <= ciphertextStart) throw new PayloadProtectionError("invalid_envelope", "truncated streams-crdt encrypted payload envelope");
|
|
393
|
+
function parseProviderEnvelope(payload) {
|
|
394
|
+
if (!isEncryptedEnvelope(payload) || payload.byteLength < PROVIDER_PREFIX_BYTES) throw new PayloadProtectionError("invalid_envelope", "invalid streams-crdt provider payload envelope");
|
|
395
|
+
const providerHeaderLength = new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint16(6, false);
|
|
396
|
+
if (payload[4] !== PROVIDER_ENVELOPE_VERSION || providerHeaderLength === 0 || providerHeaderLength > MAX_PROVIDER_HEADER_BYTES || payload[8] !== 0 || payload[9] !== 0) throw new PayloadProtectionError("invalid_envelope", "unsupported streams-crdt provider payload envelope");
|
|
397
|
+
const headerEnd = PROVIDER_PREFIX_BYTES + providerHeaderLength;
|
|
398
|
+
if (payload.byteLength <= headerEnd) throw new PayloadProtectionError("invalid_envelope", "truncated streams-crdt provider payload envelope");
|
|
404
399
|
return {
|
|
405
|
-
kind,
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
400
|
+
kind: decodeKind(payload[5]),
|
|
401
|
+
header: payload.slice(PROVIDER_PREFIX_BYTES, headerEnd),
|
|
402
|
+
authenticatedHeader: payload.slice(0, headerEnd),
|
|
403
|
+
sealed: payload.slice(headerEnd)
|
|
409
404
|
};
|
|
410
405
|
}
|
|
406
|
+
function normalizeProviderHeader(header) {
|
|
407
|
+
if (!(header instanceof Uint8Array) || header.byteLength === 0 || header.byteLength > MAX_PROVIDER_HEADER_BYTES) throw new ProviderSealContractError(`payload protection provider header must be 1..${MAX_PROVIDER_HEADER_BYTES} bytes`);
|
|
408
|
+
return header;
|
|
409
|
+
}
|
|
410
|
+
function equalBytes(left, right) {
|
|
411
|
+
return left.byteLength === right.byteLength && left.every((byte, index) => byte === right[index]);
|
|
412
|
+
}
|
|
413
|
+
function createProviderAad(authenticatedHeader) {
|
|
414
|
+
return concatBytes(providerAadDomain, authenticatedHeader);
|
|
415
|
+
}
|
|
411
416
|
function encodeKind(kind) {
|
|
412
417
|
return kind === "update_batch" ? UPDATE_BATCH_KIND : SNAPSHOT_KIND;
|
|
413
418
|
}
|
|
@@ -416,74 +421,18 @@ function decodeKind(encoded) {
|
|
|
416
421
|
if (encoded === SNAPSHOT_KIND) return "snapshot";
|
|
417
422
|
throw new PayloadProtectionError("invalid_envelope", "unsupported streams-crdt encrypted payload kind");
|
|
418
423
|
}
|
|
419
|
-
function
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
keyId: input.keyId,
|
|
427
|
-
kind: input.kind,
|
|
428
|
-
protocol: "loro-streams-crdt-payload-protection",
|
|
429
|
-
scope: normalizeScope(input.scope),
|
|
430
|
-
suite: "aes_256_gcm",
|
|
431
|
-
version: ENVELOPE_VERSION
|
|
432
|
-
}));
|
|
433
|
-
}
|
|
434
|
-
function normalizeScope(scope) {
|
|
435
|
-
return typeof scope === "string" ? scope : `bucket:${scope.bucketId}\nstream:${scope.streamId}`;
|
|
436
|
-
}
|
|
437
|
-
function concatBytes(left, right) {
|
|
438
|
-
const out = new Uint8Array(left.byteLength + right.byteLength);
|
|
439
|
-
out.set(left);
|
|
440
|
-
out.set(right, left.byteLength);
|
|
424
|
+
function concatBytes(...parts) {
|
|
425
|
+
const out = new Uint8Array(parts.reduce((length, part) => length + part.byteLength, 0));
|
|
426
|
+
let cursor = 0;
|
|
427
|
+
for (const part of parts) {
|
|
428
|
+
out.set(part, cursor);
|
|
429
|
+
cursor += part.byteLength;
|
|
430
|
+
}
|
|
441
431
|
return out;
|
|
442
432
|
}
|
|
443
433
|
function encodedItemsByteLength(items) {
|
|
444
434
|
return items.reduce((sum, item) => sum + 4 + item.byteLength, 0);
|
|
445
435
|
}
|
|
446
|
-
async function importAesGcmKey(key) {
|
|
447
|
-
if (isCryptoKey(key)) return key;
|
|
448
|
-
validateRawKey(key);
|
|
449
|
-
return await getSubtleCrypto().importKey("raw", toArrayBuffer(key), "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
450
|
-
}
|
|
451
|
-
function toArrayBuffer(bytes) {
|
|
452
|
-
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
453
|
-
}
|
|
454
|
-
function validateKey(key, label) {
|
|
455
|
-
if (key == null) return;
|
|
456
|
-
if (key.id.trim().length === 0) throw new Error(`${label}.id must be non-empty`);
|
|
457
|
-
if (key.key instanceof Uint8Array) validateRawKey(key.key);
|
|
458
|
-
}
|
|
459
|
-
function normalizeReadKeys(readKeys, label) {
|
|
460
|
-
if (readKeys == null) return;
|
|
461
|
-
if (typeof readKeys === "function") return async () => {
|
|
462
|
-
const resolved = await readKeys();
|
|
463
|
-
validateKeys(resolved, label);
|
|
464
|
-
return resolved;
|
|
465
|
-
};
|
|
466
|
-
validateKeys(readKeys, label);
|
|
467
|
-
return readKeys;
|
|
468
|
-
}
|
|
469
|
-
function validateKeys(keys, label) {
|
|
470
|
-
for (const [index, key] of keys.entries()) validateKey(key, `${label}[${index}]`);
|
|
471
|
-
}
|
|
472
|
-
function isCryptoKey(key) {
|
|
473
|
-
return typeof CryptoKey !== "undefined" && key instanceof CryptoKey;
|
|
474
|
-
}
|
|
475
|
-
function validateRawKey(key) {
|
|
476
|
-
if (key.byteLength !== RAW_AES_256_KEY_BYTES) throw new Error(`E2EE raw AES-GCM keys must be ${RAW_AES_256_KEY_BYTES} bytes`);
|
|
477
|
-
}
|
|
478
|
-
function getCrypto() {
|
|
479
|
-
if (globalThis.crypto == null) throw new PayloadProtectionError("encrypt_failed", "Web Crypto is unavailable in this runtime");
|
|
480
|
-
return globalThis.crypto;
|
|
481
|
-
}
|
|
482
|
-
function getSubtleCrypto() {
|
|
483
|
-
const subtle = getCrypto().subtle;
|
|
484
|
-
if (subtle == null) throw new PayloadProtectionError("encrypt_failed", "SubtleCrypto is unavailable in this runtime");
|
|
485
|
-
return subtle;
|
|
486
|
-
}
|
|
487
436
|
//#endregion
|
|
488
437
|
//#region src/transport-internal.ts
|
|
489
438
|
function createProducerId() {
|
|
@@ -844,8 +793,7 @@ function toTransportError(error) {
|
|
|
844
793
|
code: "payload_protection_error",
|
|
845
794
|
retryable: false,
|
|
846
795
|
reason: error.reason,
|
|
847
|
-
|
|
848
|
-
message: error.message
|
|
796
|
+
message: payloadProtectionFailureMessage(error.reason)
|
|
849
797
|
};
|
|
850
798
|
if (error instanceof TypeError) return {
|
|
851
799
|
code: "network_error",
|
|
@@ -2118,11 +2066,32 @@ function createInitialRemoteCursor(input) {
|
|
|
2118
2066
|
}
|
|
2119
2067
|
//#endregion
|
|
2120
2068
|
//#region src/transport-cursor.ts
|
|
2069
|
+
const MAX_SPANS_IN_MESSAGE = 8;
|
|
2070
|
+
/**
|
|
2071
|
+
* Renders a bounded summary of unresolved spans for the error message, so a
|
|
2072
|
+
* caller's error log names the author(s) whose ops are missing from the
|
|
2073
|
+
* stream without the caller having to unwrap `error.unresolved` itself.
|
|
2074
|
+
*/
|
|
2075
|
+
function formatUnresolvedSpans(unresolved) {
|
|
2076
|
+
if (unresolved.length === 0) return "";
|
|
2077
|
+
const shown = unresolved.slice(0, MAX_SPANS_IN_MESSAGE).map((span) => `${span.peer}@[${span.start}..${span.end})`).join(", ");
|
|
2078
|
+
const suffix = unresolved.length > MAX_SPANS_IN_MESSAGE ? `, +${unresolved.length - MAX_SPANS_IN_MESSAGE} more` : "";
|
|
2079
|
+
return ` (missing deps for ${unresolved.length} span(s): ${shown}${suffix})`;
|
|
2080
|
+
}
|
|
2121
2081
|
var CrdtApplyIncompleteError = class extends Error {
|
|
2122
2082
|
unresolved;
|
|
2123
|
-
|
|
2124
|
-
|
|
2083
|
+
/**
|
|
2084
|
+
* The volatile server version at the moment apply failed: everything the
|
|
2085
|
+
* server delivered that the local doc actually applied (success spans
|
|
2086
|
+
* only — pending spans never advance it). `exportUpdates(appliedVersion)`
|
|
2087
|
+
* is therefore exactly "the ops this replica holds that the server does
|
|
2088
|
+
* not", which is what the join-time salvage append publishes.
|
|
2089
|
+
*/
|
|
2090
|
+
appliedVersion;
|
|
2091
|
+
constructor(unresolved, appliedVersion) {
|
|
2092
|
+
super(`CRDT apply incomplete: unresolved remote updates remain${formatUnresolvedSpans(unresolved)}`);
|
|
2125
2093
|
this.unresolved = unresolved;
|
|
2094
|
+
this.appliedVersion = appliedVersion;
|
|
2126
2095
|
}
|
|
2127
2096
|
};
|
|
2128
2097
|
function isApplyOutcome(value) {
|
|
@@ -2150,6 +2119,22 @@ var TransportCursorManager = class {
|
|
|
2150
2119
|
isCursorComplete(cursor) {
|
|
2151
2120
|
return !this.incompleteCursors.has(cursor);
|
|
2152
2121
|
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Marks `cursor` incomplete without an apply.
|
|
2124
|
+
*
|
|
2125
|
+
* The `CrdtApplyIncompleteError` throw path discards its candidate cursor
|
|
2126
|
+
* BEFORE the usual WeakMap marking, so a caller's retained cursor keeps
|
|
2127
|
+
* whatever completeness it had. For a caught-up live room the first
|
|
2128
|
+
* poisoned event typically arrives with `up_to_date=true`, so the retained
|
|
2129
|
+
* cursor still reads "complete" for the whole wedge — leaving the degraded
|
|
2130
|
+
* local flush unengaged and the snapshot-upload clean gate open, exactly
|
|
2131
|
+
* the two consumers keyed on this marking. The read loop calls this when
|
|
2132
|
+
* it catches the error so the room's incomplete state is visible on the
|
|
2133
|
+
* cursor it actually holds.
|
|
2134
|
+
*/
|
|
2135
|
+
markCursorIncomplete(cursor, unresolved) {
|
|
2136
|
+
this.incompleteCursors.set(cursor, unresolved);
|
|
2137
|
+
}
|
|
2153
2138
|
async applyBootstrapPayload(cursor, snapshot, updates, nextOffset, upToDate = false) {
|
|
2154
2139
|
let nextVersion = cursor.serverLowerBoundVersion;
|
|
2155
2140
|
let complete = true;
|
|
@@ -2223,7 +2208,7 @@ var TransportCursorManager = class {
|
|
|
2223
2208
|
}
|
|
2224
2209
|
async finalizeCursor(cursor, source, complete, unresolved, upToDate) {
|
|
2225
2210
|
if (!complete) {
|
|
2226
|
-
if (upToDate) throw new CrdtApplyIncompleteError(unresolved);
|
|
2211
|
+
if (upToDate) throw new CrdtApplyIncompleteError(unresolved, cursor.serverLowerBoundVersion);
|
|
2227
2212
|
this.incompleteCursors.set(cursor, unresolved);
|
|
2228
2213
|
return cursor;
|
|
2229
2214
|
}
|
|
@@ -2378,6 +2363,7 @@ function createJoinState(cursor, liveMode = "sse") {
|
|
|
2378
2363
|
pendingLocal: new Deque(),
|
|
2379
2364
|
flushingLocal: false,
|
|
2380
2365
|
retryAttempt: 0,
|
|
2366
|
+
incompleteRecoveryAttempt: 0,
|
|
2381
2367
|
retrySleepController: new AbortController(),
|
|
2382
2368
|
readLoopRunning: false,
|
|
2383
2369
|
readErrorRetryAttempt: 0,
|
|
@@ -2480,11 +2466,9 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2480
2466
|
this.debug = options.debug ?? false;
|
|
2481
2467
|
this.beforeRemoteCursorSave = composeBeforeRemoteCursorSaveHooks(options.beforeRemoteCursorSave, ...options.beforeRemoteCursorSaveHooks ?? []);
|
|
2482
2468
|
const snapshotCodec = normalizeSnapshotCodec(options.snapshotCodec);
|
|
2483
|
-
if (options.
|
|
2484
|
-
const
|
|
2485
|
-
|
|
2486
|
-
const payloadProtection = normalizePayloadProtection(payloadProtectionOption, payloadProtectionLabel);
|
|
2487
|
-
if (payloadProtection?.writePolicy === "plaintext" && options.snapshotUpload != null && options.snapshotUpload.enabled !== false) throw new Error(`snapshotUpload is not supported when ${payloadProtectionLabel}.writePolicy is plaintext`);
|
|
2469
|
+
if (options.payloadProtectionRequired === true && options.e2ee == null) throw new Error("payloadProtectionRequired is true but this room has no e2ee config");
|
|
2470
|
+
const payloadProtection = normalizePayloadProtection(options.e2ee, "e2ee");
|
|
2471
|
+
if (payloadProtection?.writePolicy === "plaintext" && options.snapshotUpload != null && options.snapshotUpload.enabled !== false) throw new Error("snapshotUpload is not supported when e2ee.writePolicy is plaintext");
|
|
2488
2472
|
const snapshotUploadConfig = normalizeSnapshotUploadConfig(options.snapshotUpload);
|
|
2489
2473
|
this.decodeSnapshot = this.composeSnapshotDecoder(payloadProtection, snapshotCodec?.decompress);
|
|
2490
2474
|
this.encodeSnapshot = this.composeSnapshotEncoder(payloadProtection, snapshotCodec?.compress);
|
|
@@ -2963,7 +2947,13 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2963
2947
|
async startJoin(state) {
|
|
2964
2948
|
try {
|
|
2965
2949
|
state.requestController ??= new AbortController();
|
|
2966
|
-
|
|
2950
|
+
let joined;
|
|
2951
|
+
try {
|
|
2952
|
+
joined = await this.performInitialJoinSync(state.liveMode, state.requestController?.signal);
|
|
2953
|
+
} catch (error) {
|
|
2954
|
+
if (!(error instanceof CrdtApplyIncompleteError) || state.closed || !await this.salvageLocalBacklogOnIncompleteInitialSync(state, error)) throw error;
|
|
2955
|
+
joined = await this.performInitialJoinSync(state.liveMode, state.requestController?.signal);
|
|
2956
|
+
}
|
|
2967
2957
|
state.cursor = joined.cursor;
|
|
2968
2958
|
state.streamCursor = joined.streamCursor;
|
|
2969
2959
|
state.liveMode = joined.liveMode;
|
|
@@ -2995,6 +2985,65 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
2995
2985
|
throw error;
|
|
2996
2986
|
}
|
|
2997
2987
|
}
|
|
2988
|
+
/**
|
|
2989
|
+
* Join-time salvage for a stream whose head cannot be fully applied
|
|
2990
|
+
* (apply_incomplete at `up_to_date`, even after bootstrap): before the
|
|
2991
|
+
* join surfaces the error, durably append everything this replica holds
|
|
2992
|
+
* that the server does not.
|
|
2993
|
+
*
|
|
2994
|
+
* Why this exists: the dual-author wedge is usually two-sided — the stream
|
|
2995
|
+
* is missing some author's ops, and a replica that HOLDS those ops (e.g.
|
|
2996
|
+
* received out-of-band over a local data plane) may itself be unable to
|
|
2997
|
+
* complete the join because of a second gap. The regular join-time export
|
|
2998
|
+
* runs only AFTER a successful initial sync, so without this salvage such
|
|
2999
|
+
* a replica can never publish the healing ops, and its own backlog from a
|
|
3000
|
+
* previous process run stays local for as long as the stream stays
|
|
3001
|
+
* poisoned.
|
|
3002
|
+
*
|
|
3003
|
+
* `error.appliedVersion` advances only from success spans, so
|
|
3004
|
+
* `exportUpdates(appliedVersion)` is exactly "ops this replica holds that
|
|
3005
|
+
* the server does not" — the failed initial sync already imported
|
|
3006
|
+
* everything the server DOES have, so the delta cannot re-append
|
|
3007
|
+
* server-known data. Cursor safety is untouched: the append is write-only
|
|
3008
|
+
* style (producer-acked, no read catch-up, no durable-cursor save), and
|
|
3009
|
+
* the retried initial sync re-derives its cursor from the server.
|
|
3010
|
+
*
|
|
3011
|
+
* Returns `true` when anything was durably appended; the caller then
|
|
3012
|
+
* retries the initial sync once. Best-effort: a salvage failure logs and
|
|
3013
|
+
* returns `false` so the original apply-incomplete error is what callers
|
|
3014
|
+
* observe.
|
|
3015
|
+
*/
|
|
3016
|
+
async salvageLocalBacklogOnIncompleteInitialSync(state, error) {
|
|
3017
|
+
const appliedVersion = error.appliedVersion;
|
|
3018
|
+
if (appliedVersion == null) return false;
|
|
3019
|
+
try {
|
|
3020
|
+
const appended = await this.enqueueExclusive(async () => {
|
|
3021
|
+
let didAppend = false;
|
|
3022
|
+
const frozen = this.frozenLocalAppend;
|
|
3023
|
+
if (frozen != null) {
|
|
3024
|
+
const ack = await this.appendFrozenLocalBatchRemote(frozen);
|
|
3025
|
+
this.commitProducerAck(ack.producerAck);
|
|
3026
|
+
didAppend = true;
|
|
3027
|
+
}
|
|
3028
|
+
await this.adapter.flushLocalExports?.();
|
|
3029
|
+
const localBatch = this.exportUpdates(appliedVersion);
|
|
3030
|
+
if (localBatch.batch != null) {
|
|
3031
|
+
const appendedBatch = await this.appendLocalBatchRemote({
|
|
3032
|
+
...state.cursor,
|
|
3033
|
+
serverLowerBoundVersion: appliedVersion
|
|
3034
|
+
}, localBatch.batch, void 0, "direct");
|
|
3035
|
+
this.commitProducerAck(appendedBatch.producerAck);
|
|
3036
|
+
didAppend = true;
|
|
3037
|
+
}
|
|
3038
|
+
return didAppend;
|
|
3039
|
+
});
|
|
3040
|
+
if (appended) this.logDebug("join salvage appended local backlog", { streamUrl: this.streamUrl });
|
|
3041
|
+
return appended;
|
|
3042
|
+
} catch (salvageError) {
|
|
3043
|
+
this.logError("join salvage append failed", salvageError, { streamUrl: this.streamUrl });
|
|
3044
|
+
return false;
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
2998
3047
|
async syncOnce(operation, parentSignal) {
|
|
2999
3048
|
let cursor = (await this.finalizeDirectLocalAppendIfNeeded(await this.resolveInitialRemoteState(operation, parentSignal))).cursor;
|
|
3000
3049
|
const localBatch = this.exportUpdates(cursor.serverLowerBoundVersion);
|
|
@@ -3009,8 +3058,11 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3009
3058
|
let liveMode = preferredLiveMode;
|
|
3010
3059
|
const exportedLocal = this.exportUpdates(localExportRefVersion);
|
|
3011
3060
|
localExportRefVersion = exportedLocal.nextRefVersion;
|
|
3012
|
-
|
|
3013
|
-
|
|
3061
|
+
const exportedBatch = exportedLocal.batch;
|
|
3062
|
+
if (exportedBatch != null) {
|
|
3063
|
+
const appendCursor = cursor;
|
|
3064
|
+
const appendStreamCursor = streamCursor;
|
|
3065
|
+
const committed = await this.enqueueExclusive(() => this.appendLocalBatchDurably(appendCursor, exportedBatch, void 0, "direct", appendStreamCursor));
|
|
3014
3066
|
cursor = committed.cursor;
|
|
3015
3067
|
streamCursor = committed.streamCursor;
|
|
3016
3068
|
}
|
|
@@ -3170,7 +3222,8 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3170
3222
|
};
|
|
3171
3223
|
}
|
|
3172
3224
|
}
|
|
3173
|
-
async recoverLiveIncompleteRemoteApply(state) {
|
|
3225
|
+
async recoverLiveIncompleteRemoteApply(state, error) {
|
|
3226
|
+
this.cursorManager.markCursorIncomplete(state.cursor, error.unresolved);
|
|
3174
3227
|
try {
|
|
3175
3228
|
const bootstrapped = await this.bootstrapState({
|
|
3176
3229
|
...state.cursor,
|
|
@@ -3180,6 +3233,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3180
3233
|
state.cursor = bootstrapped.cursor;
|
|
3181
3234
|
state.streamCursor = bootstrapped.streamCursor;
|
|
3182
3235
|
state.retryAttempt = 0;
|
|
3236
|
+
state.incompleteRecoveryAttempt = 0;
|
|
3183
3237
|
state.lastError = void 0;
|
|
3184
3238
|
this.setReadSubStatus(state, "ok");
|
|
3185
3239
|
if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
|
|
@@ -3191,7 +3245,10 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3191
3245
|
code: classified.code
|
|
3192
3246
|
});
|
|
3193
3247
|
state.lastError = toTransportError(error);
|
|
3248
|
+
if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
|
|
3194
3249
|
if (classified.retriable && !isAuthOrProtocolError(error)) {
|
|
3250
|
+
state.incompleteRecoveryAttempt += 1;
|
|
3251
|
+
state.retryAttempt = Math.max(state.retryAttempt, state.incompleteRecoveryAttempt);
|
|
3195
3252
|
this.setReadSubStatus(state, "reconnecting");
|
|
3196
3253
|
return "retry";
|
|
3197
3254
|
}
|
|
@@ -3667,7 +3724,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3667
3724
|
if (state.closed) return;
|
|
3668
3725
|
if (error instanceof CrdtApplyIncompleteError) {
|
|
3669
3726
|
if (capturedController?.signal.aborted && state.requestController === capturedController) state.requestController = new AbortController();
|
|
3670
|
-
const recovery = await this.recoverLiveIncompleteRemoteApply(state);
|
|
3727
|
+
const recovery = await this.recoverLiveIncompleteRemoteApply(state, error);
|
|
3671
3728
|
if (recovery === "recovered") continue;
|
|
3672
3729
|
if (recovery === "terminal") return;
|
|
3673
3730
|
if (await this.awaitReadRetryBackoff(state, error)) continue;
|
|
@@ -3701,7 +3758,7 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3701
3758
|
const flushError = abortReason instanceof Error ? abortReason : error;
|
|
3702
3759
|
if (flushError instanceof CrdtApplyIncompleteError) {
|
|
3703
3760
|
if (state.requestController === capturedController) state.requestController = new AbortController();
|
|
3704
|
-
const recovery = await this.recoverLiveIncompleteRemoteApply(state);
|
|
3761
|
+
const recovery = await this.recoverLiveIncompleteRemoteApply(state, flushError);
|
|
3705
3762
|
if (recovery === "recovered") continue;
|
|
3706
3763
|
if (recovery === "terminal") return;
|
|
3707
3764
|
if (await this.awaitReadRetryBackoff(state, flushError)) continue;
|
|
@@ -3796,10 +3853,15 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3796
3853
|
* `"error"` write status (with `lastWriteError` set) and then retries via
|
|
3797
3854
|
* this flush's own backoff timer (`maybeScheduleWriteRetry`) — it does not
|
|
3798
3855
|
* depend on a future local op to re-trigger the flush.
|
|
3856
|
+
*
|
|
3857
|
+
* While the cursor is incomplete (unresolved remote spans), the flush
|
|
3858
|
+
* degrades to an append-only mode instead of pausing: batches still reach
|
|
3859
|
+
* the server, but read progress, durable-cursor saves, and snapshot
|
|
3860
|
+
* scheduling stay untouched. See the inline comment in the incomplete
|
|
3861
|
+
* branch and specs/loro-pending.md "Local Append Rules".
|
|
3799
3862
|
*/
|
|
3800
3863
|
async flushPendingLocal(state) {
|
|
3801
3864
|
if (state.flushingLocal || state.closed) return;
|
|
3802
|
-
if (!this.cursorManager.isCursorComplete(state.cursor)) return;
|
|
3803
3865
|
this.clearWriteRetryTimer(state);
|
|
3804
3866
|
state.flushingLocal = true;
|
|
3805
3867
|
try {
|
|
@@ -3808,16 +3870,23 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3808
3870
|
if (drained == null) return;
|
|
3809
3871
|
let attempts = 0;
|
|
3810
3872
|
while (!state.closed) try {
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3873
|
+
let committedCount;
|
|
3874
|
+
if (this.cursorManager.isCursorComplete(state.cursor)) {
|
|
3875
|
+
state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch, drained.count, state.streamCursor));
|
|
3876
|
+
const committedStreamCursor = this.consumeCommittedLocalStreamCursor();
|
|
3877
|
+
if (committedStreamCursor != null) state.streamCursor = committedStreamCursor;
|
|
3878
|
+
committedCount = this.consumeCommittedPendingCount() ?? drained.count;
|
|
3879
|
+
this.snapshotManager.schedule(state);
|
|
3880
|
+
} else {
|
|
3881
|
+
const appended = await this.enqueueExclusive(async () => this.appendLocalBatchRemote(state.cursor, drained.batch, drained.count, "pending"));
|
|
3882
|
+
this.commitProducerAck(appended.producerAck);
|
|
3883
|
+
committedCount = appended.pendingCount ?? drained.count;
|
|
3884
|
+
}
|
|
3815
3885
|
for (let i = 0; i < committedCount; i += 1) state.pendingLocal.popFront();
|
|
3816
3886
|
this.notifySyncWaiters(state, committedCount);
|
|
3817
3887
|
state.flushHeadBatchOnly = false;
|
|
3818
3888
|
state.lastWriteError = void 0;
|
|
3819
3889
|
this.setWriteSubStatus(state, "ok");
|
|
3820
|
-
this.snapshotManager.schedule(state);
|
|
3821
3890
|
break;
|
|
3822
3891
|
} catch (error) {
|
|
3823
3892
|
if (isPayloadTooLargeError(error) && drained.count > 1 && this.abandonPendingFrozenLocalAppend()) {
|
|
@@ -3911,12 +3980,15 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3911
3980
|
for (const waiter of state.syncWaiters.splice(0)) waiter.reject(error);
|
|
3912
3981
|
}
|
|
3913
3982
|
async saveRemoteCursor(cursor, source) {
|
|
3914
|
-
|
|
3983
|
+
const saved = await persistRemoteCursor({
|
|
3915
3984
|
remoteCursorStore: this.remoteCursorStore,
|
|
3916
3985
|
cursor,
|
|
3917
3986
|
source,
|
|
3918
3987
|
beforeRemoteCursorSave: this.beforeRemoteCursorSave
|
|
3919
3988
|
});
|
|
3989
|
+
const state = this.joinState;
|
|
3990
|
+
if (state != null && !state.closed) state.incompleteRecoveryAttempt = 0;
|
|
3991
|
+
return saved;
|
|
3920
3992
|
}
|
|
3921
3993
|
setReadSubStatus(state, sub) {
|
|
3922
3994
|
if (sub === "ok") state.readErrorRetryAttempt = 0;
|
|
@@ -3987,33 +4059,16 @@ var StreamsCrdt = class StreamsCrdt {
|
|
|
3987
4059
|
//#region src/stream-id.ts
|
|
3988
4060
|
const MAX_STREAM_ID_BYTES = 512;
|
|
3989
4061
|
const textEncoder = new TextEncoder();
|
|
3990
|
-
const DEFAULT_BASE_URL = "https://streams-api.loro.dev";
|
|
3991
4062
|
function utf8ByteLength(value) {
|
|
3992
4063
|
return textEncoder.encode(value).byteLength;
|
|
3993
4064
|
}
|
|
3994
4065
|
/**
|
|
3995
|
-
* Validates a stream id
|
|
4066
|
+
* Validates a Durable Streams stream id.
|
|
3996
4067
|
*/
|
|
3997
4068
|
function isValidRillId(id) {
|
|
3998
|
-
return id.length > 0 && utf8ByteLength(id) <= MAX_STREAM_ID_BYTES && !id.includes("/") && !id.includes("\0") && !id.includes("..");
|
|
3999
|
-
}
|
|
4000
|
-
/**
|
|
4001
|
-
* Validates a bucket id accepted by `createStreamUrl()`.
|
|
4002
|
-
*/
|
|
4003
|
-
function isValidBucketId(id) {
|
|
4004
|
-
return isValidRillId(id);
|
|
4005
|
-
}
|
|
4006
|
-
/**
|
|
4007
|
-
* Builds a Durable Streams HTTP URL for one `(bucketId, streamId)` pair.
|
|
4008
|
-
*/
|
|
4009
|
-
function createStreamUrl(input) {
|
|
4010
|
-
const baseUrl = (input.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/g, "");
|
|
4011
|
-
if (!isValidBucketId(input.bucketId)) throw new Error(`invalid bucketId: ${JSON.stringify(input.bucketId)} (must be non-empty UTF-8, <= ${MAX_STREAM_ID_BYTES} bytes, and exclude "/", "\\0", "..")`);
|
|
4012
|
-
if (!isValidRillId(input.streamId)) throw new Error(`invalid streamId: ${JSON.stringify(input.streamId)} (must be non-empty UTF-8, <= ${MAX_STREAM_ID_BYTES} bytes, and exclude "/", "\\0", "..")`);
|
|
4013
|
-
const streamId = input.streamId;
|
|
4014
|
-
return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
|
|
4069
|
+
return id.length > 0 && utf8ByteLength(id) <= MAX_STREAM_ID_BYTES && id !== "." && !id.includes("/") && !id.includes("\0") && !id.includes("..");
|
|
4015
4070
|
}
|
|
4016
4071
|
//#endregion
|
|
4017
|
-
export {
|
|
4072
|
+
export { createInitialRemoteCursor as a, PayloadProtectionError as c, IndexedDbRemoteCursorStore as i, StreamsCrdt as n, EphemeralStreamCrdt as o, InMemoryRemoteCursorStore as r, LocalAppendFailedError as s, isValidRillId as t };
|
|
4018
4073
|
|
|
4019
|
-
//# sourceMappingURL=stream-id-
|
|
4074
|
+
//# sourceMappingURL=stream-id-BygPTNvQ.js.map
|