@loro-dev/streams-crdt 0.2.1 → 0.3.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.
@@ -1,3 +1,4 @@
1
+ import { StreamsClient } from "@loro-dev/streams-client";
1
2
  //#region src/checkpoint.ts
2
3
  function composeBeforeRemoteCursorSaveHooks(...hooks) {
3
4
  const active = hooks.filter((hook) => hook != null);
@@ -181,7 +182,7 @@ const DEFAULT_RECONNECT_CONFIG = {
181
182
  15e3
182
183
  ],
183
184
  jitterFraction: .2,
184
- maxAttempts: 5,
185
+ maxAttempts: Infinity,
185
186
  connectTimeoutMs: 1e4,
186
187
  pollTimeoutMs: 3e4
187
188
  };
@@ -234,79 +235,6 @@ function decodeItems(payload) {
234
235
  return out;
235
236
  }
236
237
  //#endregion
237
- //#region src/multipart.ts
238
- /**
239
- * Parse an RFC 2046 multipart/mixed body into its constituent parts.
240
- *
241
- * The boundary must NOT include the leading "--".
242
- */
243
- function decodeMultipartMixed(boundary, data) {
244
- const delimiter = new TextEncoder().encode(`--${boundary}`);
245
- const closeDelimiter = new TextEncoder().encode(`--${boundary}--`);
246
- const crlfCrlf = new TextEncoder().encode("\r\n\r\n");
247
- const parts = [];
248
- let pos = 0;
249
- const firstDelimPos = indexOf(data, delimiter, pos);
250
- if (firstDelimPos < 0) return parts;
251
- pos = firstDelimPos + delimiter.byteLength;
252
- if (pos < data.byteLength && data[pos] === 13) pos++;
253
- if (pos < data.byteLength && data[pos] === 10) pos++;
254
- while (pos < data.byteLength) {
255
- if (startsWith(data.subarray(pos), closeDelimiter.subarray(delimiter.byteLength))) break;
256
- const headerEnd = indexOf(data, crlfCrlf, pos);
257
- if (headerEnd < 0) break;
258
- const headerBytes = data.subarray(pos, headerEnd);
259
- const headerText = new TextDecoder().decode(headerBytes);
260
- let contentType = "application/octet-stream";
261
- for (const line of headerText.split("\r\n")) {
262
- const colonIdx = line.indexOf(":");
263
- if (colonIdx > 0) {
264
- if (line.slice(0, colonIdx).trim().toLowerCase() === "content-type") contentType = line.slice(colonIdx + 1).trim();
265
- }
266
- }
267
- const bodyStart = headerEnd + crlfCrlf.byteLength;
268
- const nextDelimPos = indexOf(data, delimiter, bodyStart);
269
- if (nextDelimPos < 0) {
270
- parts.push({
271
- contentType,
272
- body: data.subarray(bodyStart)
273
- });
274
- break;
275
- }
276
- let bodyEnd = nextDelimPos;
277
- if (bodyEnd >= 2 && data[bodyEnd - 2] === 13 && data[bodyEnd - 1] === 10) bodyEnd -= 2;
278
- parts.push({
279
- contentType,
280
- body: data.subarray(bodyStart, bodyEnd)
281
- });
282
- pos = nextDelimPos + delimiter.byteLength;
283
- if (pos < data.byteLength && data[pos] === 45 && pos + 1 < data.byteLength && data[pos + 1] === 45) break;
284
- if (pos < data.byteLength && data[pos] === 13) pos++;
285
- if (pos < data.byteLength && data[pos] === 10) pos++;
286
- }
287
- return parts;
288
- }
289
- /**
290
- * Extract the boundary parameter from a Content-Type header value.
291
- * Returns null if not found.
292
- */
293
- function extractBoundary(contentType) {
294
- const match = /boundary=("?)([^";,\s]+)\1/i.exec(contentType);
295
- return match ? match[2] : null;
296
- }
297
- function indexOf(haystack, needle, from) {
298
- outer: for (let i = from; i <= haystack.byteLength - needle.byteLength; i++) {
299
- for (let j = 0; j < needle.byteLength; j++) if (haystack[i + j] !== needle[j]) continue outer;
300
- return i;
301
- }
302
- return -1;
303
- }
304
- function startsWith(data, prefix) {
305
- if (data.byteLength < prefix.byteLength) return false;
306
- for (let i = 0; i < prefix.byteLength; i++) if (data[i] !== prefix[i]) return false;
307
- return true;
308
- }
309
- //#endregion
310
238
  //#region src/transport-internal.ts
311
239
  function createProducerId() {
312
240
  if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
@@ -335,30 +263,9 @@ var ProtocolError = class extends Error {
335
263
  this.status = status;
336
264
  }
337
265
  };
338
- function getHeader(headers, lowerCaseName, titleCaseName) {
339
- return headers.get(lowerCaseName) ?? headers.get(titleCaseName);
340
- }
341
- function visibleHeaderNames(headers) {
342
- const names = [];
343
- headers.forEach((_value, name) => {
344
- names.push(name);
345
- });
346
- if (names.length === 0) return "(none)";
347
- names.sort();
348
- return names.join(", ");
349
- }
350
- function missingRequiredHeaderError(context, requiredHeader, headers) {
351
- return new ProtocolError(`${context} response missing ${requiredHeader}. If this is cross-origin, expose it via Access-Control-Expose-Headers (${requiredHeader.toLowerCase()}). Visible headers: ${visibleHeaderNames(headers)}`);
352
- }
353
266
  function isObject(value) {
354
267
  return typeof value === "object" && value != null;
355
268
  }
356
- function parseHeaderU64(value) {
357
- if (value == null) return null;
358
- const parsed = Number.parseInt(value, 10);
359
- if (!Number.isFinite(parsed) || parsed < 0) return null;
360
- return parsed;
361
- }
362
269
  function decodeBase64Bytes(encoded) {
363
270
  const normalized = encoded.replace(/[\r\n]/g, "");
364
271
  if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(normalized, "base64"));
@@ -425,34 +332,15 @@ function isSseUnsupportedErrorText(message) {
425
332
  const mentionsCompatibility = normalized.includes("unsupported") || normalized.includes("not support") || normalized.includes("incompatible") || normalized.includes("content type");
426
333
  return mentionsSse && mentionsCompatibility;
427
334
  }
428
- function withCursorQuery(url, cursor) {
429
- if (cursor == null || cursor.length === 0) return url;
430
- const target = new URL(url);
431
- target.searchParams.set("cursor", cursor);
432
- return target.toString();
433
- }
434
- function bytesToArrayBuffer(bytes) {
435
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
436
- }
437
- function sleep(ms) {
438
- return new Promise((resolve) => setTimeout(resolve, ms));
439
- }
440
- function mergeAbortSignals(...signals) {
441
- const active = signals.filter((signal) => signal != null);
442
- if (active.length === 0) return;
443
- if (active.length === 1) return active[0];
444
- if (typeof AbortSignal.any === "function") return AbortSignal.any(active);
445
- const controller = new AbortController();
446
- for (const signal of active) {
447
- if (signal.aborted) {
448
- controller.abort(signal.reason);
449
- break;
450
- }
451
- signal.addEventListener("abort", () => {
452
- controller.abort(signal.reason);
335
+ function sleep(ms, signal) {
336
+ if (signal?.aborted) return Promise.resolve();
337
+ return new Promise((resolve) => {
338
+ const timer = setTimeout(resolve, ms);
339
+ signal?.addEventListener("abort", () => {
340
+ clearTimeout(timer);
341
+ resolve();
453
342
  }, { once: true });
454
- }
455
- return controller.signal;
343
+ });
456
344
  }
457
345
  function classifyFetchError(err) {
458
346
  const message = err instanceof Error ? err.message : String(err);
@@ -651,209 +539,143 @@ function resolveNextOffsetAfterConcurrentAdvance(requestOffset, currentOffset, r
651
539
  var StreamsTransportClient = class {
652
540
  streamUrl;
653
541
  adapter;
654
- authProvider;
655
- fetchImpl;
542
+ client;
656
543
  reconnectConfig;
657
- authRefreshInFlight;
658
544
  constructor(options) {
659
545
  this.streamUrl = options.streamUrl;
660
546
  this.adapter = options.adapter;
661
- this.authProvider = options.authProvider;
662
- this.fetchImpl = options.fetchImpl;
547
+ this.client = new StreamsClient({
548
+ url: options.streamUrl,
549
+ auth: options.authProvider,
550
+ fetch: options.fetchImpl,
551
+ retry: { maxAttempts: 0 },
552
+ timeout: {
553
+ connectTimeoutMs: options.reconnectConfig.connectTimeoutMs,
554
+ pollTimeoutMs: options.reconnectConfig.pollTimeoutMs
555
+ }
556
+ });
663
557
  this.reconnectConfig = options.reconnectConfig;
664
558
  }
665
559
  async createStreamRequest() {
666
560
  const adapterHeaders = this.adapter.getCreateStreamHeaders ? await this.adapter.getCreateStreamHeaders() : void 0;
667
- const response = await this.fetchAuthorized(this.streamUrl, {
668
- method: "PUT",
669
- headers: {
670
- "Content-Type": BINARY_CONTENT_TYPE,
671
- ...adapterHeaders
672
- }
673
- }, this.reconnectConfig.connectTimeoutMs, "connect");
674
- if (response.status === 200 || response.status === 201) return response.status === 201;
675
- throw new HttpError(`create stream failed with status ${response.status}`, response.status);
561
+ const result = await this.client.create({ headers: adapterHeaders });
562
+ if (result.ok) return result.result.created;
563
+ throw this.toTransportFailure(result.result);
676
564
  }
677
565
  async deleteStreamRequest() {
678
- const response = await this.fetchAuthorized(this.streamUrl, { method: "DELETE" }, this.reconnectConfig.connectTimeoutMs, "connect");
679
- if (response.status === 200 || response.status === 204) return true;
680
- if (response.status === 404) return false;
681
- throw new HttpError(`delete stream failed with status ${response.status}`, response.status);
566
+ const result = await this.client.delete();
567
+ if (result.ok) return result.result.deleted;
568
+ throw this.toTransportFailure(result.result);
682
569
  }
683
570
  async readFromOffsetSseItems(offset, cursor, onControl, onOpen, signal) {
684
- const query = new URLSearchParams({
685
- offset,
686
- live: "sse"
687
- });
688
- return this.readItemsSse(withCursorQuery(`${this.streamUrl}?${query.toString()}`, cursor), offset, onControl, onOpen, signal);
571
+ return this.readItemsSse(offset, cursor, onControl, onOpen, signal);
689
572
  }
690
573
  async readFromOffset(offset, cursor, live, signal) {
691
- const query = new URLSearchParams({ offset });
692
- if (live != null) query.set("live", live);
693
- if (cursor != null && cursor.length > 0) query.set("cursor", cursor);
694
- const timeoutMs = live === "long-poll" ? this.reconnectConfig.pollTimeoutMs : this.reconnectConfig.connectTimeoutMs;
695
- const timeoutType = live === "long-poll" ? "poll" : "connect";
696
- const response = await this.fetchAuthorized(`${this.streamUrl}?${query.toString()}`, {
697
- method: "GET",
574
+ const result = await this.client.readOnce({
575
+ offset,
576
+ cursor,
577
+ live,
698
578
  signal
699
- }, timeoutMs, timeoutType);
700
- if (response.status === 410) return { kind: "gone" };
701
- if (!response.ok) throw new HttpError(`read failed with status ${response.status}`, response.status);
702
- const nextOffset = getHeader(response.headers, "stream-next-offset", "Stream-Next-Offset");
703
- if (nextOffset == null) throw missingRequiredHeaderError("read", "Stream-Next-Offset", response.headers);
704
- const upToDate = getHeader(response.headers, "stream-up-to-date", "Stream-Up-To-Date") === "true";
705
- const streamCursor = getHeader(response.headers, "stream-cursor", "Stream-Cursor") ?? void 0;
706
- const body = new Uint8Array(await response.arrayBuffer());
579
+ });
580
+ if (!result.ok) {
581
+ if (result.result.code === "gone") return { kind: "gone" };
582
+ throw this.toTransportFailure(result.result);
583
+ }
584
+ const payload = result.result.payload?.body ?? new Uint8Array();
707
585
  return {
708
586
  kind: "ok",
709
587
  value: {
710
- nextOffset,
711
- upToDate,
712
- updates: body.byteLength === 0 ? [] : decodeItems(body),
713
- streamCursor
588
+ nextOffset: result.result.nextOffset,
589
+ upToDate: result.result.upToDate,
590
+ updates: payload.byteLength === 0 ? [] : decodeItems(payload),
591
+ streamCursor: result.result.cursor
714
592
  }
715
593
  };
716
594
  }
717
595
  async bootstrap() {
718
- const response = await this.fetchAuthorized(`${this.streamUrl}/bootstrap`, { method: "GET" }, this.reconnectConfig.connectTimeoutMs, "connect");
719
- if (!response.ok) throw new HttpError(`bootstrap failed with status ${response.status}`, response.status);
720
- const nextOffset = getHeader(response.headers, "stream-next-offset", "Stream-Next-Offset");
721
- if (nextOffset == null) throw missingRequiredHeaderError("bootstrap", "Stream-Next-Offset", response.headers);
722
- const upToDate = getHeader(response.headers, "stream-up-to-date", "Stream-Up-To-Date") === "true";
723
- const streamCursor = getHeader(response.headers, "stream-cursor", "Stream-Cursor") ?? void 0;
724
- const body = new Uint8Array(await response.arrayBuffer());
725
- const boundary = extractBoundary(response.headers.get("Content-Type") ?? "");
726
- if (boundary != null && body.byteLength > 0) {
727
- const parts = decodeMultipartMixed(boundary, body);
728
- return {
729
- nextOffset,
730
- upToDate,
731
- snapshot: parts.length > 0 ? parts[0].body : new Uint8Array(),
732
- updates: parts.slice(1).flatMap((part) => decodeMultipartPartItems(part.body)),
733
- streamCursor
734
- };
735
- }
736
- if (body.byteLength > 0) throw new ProtocolError("bootstrap returned a non-multipart body");
596
+ const result = await this.client.bootstrap();
597
+ if (!result.ok) throw this.toTransportFailure(result.result);
737
598
  return {
738
- nextOffset,
739
- upToDate,
740
- snapshot: new Uint8Array(),
741
- updates: [],
742
- streamCursor
599
+ nextOffset: result.result.nextOffset,
600
+ upToDate: result.result.upToDate,
601
+ snapshot: result.result.snapshot?.body ?? new Uint8Array(),
602
+ updates: result.result.updates.flatMap((part) => decodeMultipartPartItems(part.body)),
603
+ streamCursor: result.result.cursor
743
604
  };
744
605
  }
745
606
  async readSnapshotOffsetValue() {
746
- const response = await this.fetchAuthorized(this.streamUrl, { method: "HEAD" }, this.reconnectConfig.connectTimeoutMs, "connect");
747
- if (response.status === 404) return null;
748
- if (!response.ok) throw new HttpError(`head snapshot offset failed with status ${response.status}`, response.status);
749
- const snapshotOffset = getHeader(response.headers, "stream-snapshot-offset", "Stream-Snapshot-Offset");
607
+ const result = await this.client.head();
608
+ if (!result.ok) {
609
+ if (result.result.code === "not_found") return null;
610
+ throw this.toTransportFailure(result.result);
611
+ }
612
+ const snapshotOffset = result.result.snapshotOffset;
750
613
  return snapshotOffset == null || snapshotOffset.length === 0 ? null : snapshotOffset;
751
614
  }
752
615
  async putSnapshotAt(snapshotOffset, snapshot) {
753
- const response = await this.fetchAuthorized(`${this.streamUrl}/snapshot/${encodeURIComponent(snapshotOffset)}`, {
754
- method: "PUT",
755
- headers: { "Content-Type": "application/octet-stream" },
756
- body: bytesToArrayBuffer(snapshot)
757
- }, this.reconnectConfig.connectTimeoutMs, "connect");
758
- if (response.status === 200 || response.status === 204) return;
759
- throw new HttpError(`snapshot upload failed with status ${response.status}`, response.status);
616
+ const result = await this.client.putSnapshot({
617
+ offset: snapshotOffset,
618
+ part: {
619
+ contentType: "application/octet-stream",
620
+ body: snapshot
621
+ }
622
+ });
623
+ if (!result.ok) throw this.toTransportFailure(result.result);
760
624
  }
761
625
  async appendUpdates(updates, producerSession) {
762
- const response = await this.fetchAuthorized(this.streamUrl, {
763
- method: "POST",
764
- headers: {
765
- "Content-Type": BINARY_CONTENT_TYPE,
766
- "Producer-Id": producerSession.producerId,
767
- "Producer-Epoch": producerSession.producerEpoch.toString(),
768
- "Producer-Seq": producerSession.nextProducerSeq.toString()
626
+ const result = await this.client.append({
627
+ part: {
628
+ contentType: BINARY_CONTENT_TYPE,
629
+ body: encodeItems(updates)
769
630
  },
770
- body: bytesToArrayBuffer(encodeItems(updates))
771
- }, this.reconnectConfig.connectTimeoutMs, "connect", { shouldRetryAuthFailure: (response) => response.status === 401 || response.status === 403 && parseHeaderU64(getHeader(response.headers, "producer-epoch", "Producer-Epoch")) == null });
772
- if (response.status === 200 || response.status === 204) {
773
- const nextOffset = getHeader(response.headers, "stream-next-offset", "Stream-Next-Offset");
774
- if (nextOffset == null) throw missingRequiredHeaderError("append", "Stream-Next-Offset", response.headers);
775
- return {
776
- kind: "ok",
777
- ackEpoch: parseHeaderU64(getHeader(response.headers, "producer-epoch", "Producer-Epoch")) ?? producerSession.producerEpoch,
778
- ackSeq: parseHeaderU64(getHeader(response.headers, "producer-seq", "Producer-Seq")) ?? producerSession.nextProducerSeq,
779
- nextOffset
780
- };
781
- }
782
- if (response.status === 403) {
783
- const serverEpoch = parseHeaderU64(getHeader(response.headers, "producer-epoch", "Producer-Epoch"));
784
- if (serverEpoch != null) return {
785
- kind: "stale-epoch",
786
- serverEpoch
787
- };
788
- }
789
- if (response.status === 409) {
790
- const expectedSeq = parseHeaderU64(getHeader(response.headers, "producer-expected-seq", "Producer-Expected-Seq"));
791
- if (expectedSeq != null) return {
792
- kind: "seq-gap",
793
- expectedSeq
794
- };
795
- }
796
- throw new HttpError(`append failed with status ${response.status}`, response.status);
797
- }
798
- async resolveToken(context) {
799
- if (this.authProvider == null) return;
800
- return await this.authProvider(context);
801
- }
802
- async refreshTokenAfterAuthFailure(status, previousToken) {
803
- if (this.authProvider == null) return;
804
- if (this.authRefreshInFlight == null) this.authRefreshInFlight = this.resolveToken({
805
- reason: "unauthorized",
806
- status,
807
- previousToken
808
- }).finally(() => {
809
- this.authRefreshInFlight = void 0;
631
+ producer: {
632
+ producerId: producerSession.producerId,
633
+ epoch: producerSession.producerEpoch,
634
+ seq: producerSession.nextProducerSeq
635
+ }
810
636
  });
811
- return await this.authRefreshInFlight;
812
- }
813
- async fetchAuthorized(input, init, timeoutMs, timeoutType, options) {
814
- const firstToken = await this.resolveToken({ reason: "request" });
815
- const firstResponse = await this.fetchWithTimeout(input, {
816
- ...init,
817
- headers: this.attachAuthorizationHeader(init.headers, firstToken)
818
- }, timeoutMs, timeoutType);
819
- const shouldRetryAuthFailure = options?.shouldRetryAuthFailure ?? ((response) => response.status === 401 || response.status === 403);
820
- if (this.authProvider == null || !shouldRetryAuthFailure(firstResponse)) return firstResponse;
821
- const refreshedToken = await this.refreshTokenAfterAuthFailure(firstResponse.status, firstToken);
822
- return await this.fetchWithTimeout(input, {
823
- ...init,
824
- headers: this.attachAuthorizationHeader(init.headers, refreshedToken)
825
- }, timeoutMs, timeoutType);
826
- }
827
- attachAuthorizationHeader(base, token) {
828
- const headers = new Headers(base);
829
- if (token == null || token.length === 0) {
830
- headers.delete("Authorization");
831
- return headers;
832
- }
833
- headers.set("Authorization", `Bearer ${token}`);
834
- return headers;
835
- }
836
- async fetchWithTimeout(input, init, timeoutMs, timeoutType) {
837
- if (!(timeoutMs > 0)) return this.fetchImpl(input, init);
838
- const controller = new AbortController();
839
- const signal = mergeAbortSignals(init.signal, controller.signal);
840
- const timer = setTimeout(() => {
841
- controller.abort(new TimeoutError(timeoutType, timeoutMs));
842
- }, timeoutMs);
843
- try {
844
- return await this.fetchImpl(input, {
845
- ...init,
846
- signal
847
- });
848
- } catch (error) {
849
- if (controller.signal.aborted && controller.signal.reason instanceof TimeoutError) throw controller.signal.reason;
850
- throw error;
851
- } finally {
852
- clearTimeout(timer);
637
+ if (result.ok) return {
638
+ kind: "ok",
639
+ ackEpoch: result.result.producer?.epoch ?? producerSession.producerEpoch,
640
+ ackSeq: result.result.producer?.seq ?? producerSession.nextProducerSeq,
641
+ nextOffset: result.result.nextOffset
642
+ };
643
+ const error = result.result;
644
+ if (error.code === "conflict" && error.detail?.kind === "producer_seq_gap") return {
645
+ kind: "seq-gap",
646
+ expectedSeq: error.detail.expectedSeq
647
+ };
648
+ if (error.code === "forbidden" && error.detail?.kind === "stale_producer_epoch") return {
649
+ kind: "stale-epoch",
650
+ serverEpoch: error.detail.serverEpoch
651
+ };
652
+ throw this.toTransportFailure(error);
653
+ }
654
+ toTransportFailure(error) {
655
+ switch (error.code) {
656
+ case "bad_request":
657
+ case "unauthorized":
658
+ case "forbidden":
659
+ case "not_found":
660
+ case "conflict":
661
+ case "gone":
662
+ case "rate_limited": return new HttpError(error.message, error.status);
663
+ case "timeout": return new TimeoutError(error.phase, error.phase === "poll" ? this.reconnectConfig.pollTimeoutMs : this.reconnectConfig.connectTimeoutMs);
664
+ case "network_error": return error.cause instanceof TypeError ? error.cause : new TypeError(error.message);
665
+ case "protocol_error": return new ProtocolError(error.message, error.status);
666
+ case "unknown":
667
+ if (typeof error.status === "number") return new HttpError(error.message, error.status);
668
+ if (error.cause instanceof TypeError) return error.cause;
669
+ if (error.cause instanceof Error) return error.cause;
670
+ return new Error(error.message);
671
+ default: {
672
+ const exhaustive = error;
673
+ return new Error(String(exhaustive));
674
+ }
853
675
  }
854
676
  }
855
- async readItemsSse(url, initialOffset, onControl, onOpen, signal) {
856
- const opened = await this.openItemsSseSession(url, initialOffset, onOpen, signal);
677
+ async readItemsSse(initialOffset, cursor, onControl, onOpen, signal) {
678
+ const opened = await this.openItemsSseSession(initialOffset, cursor, onOpen, signal);
857
679
  if (opened.kind !== "ok") return opened;
858
680
  const result = await opened.session.continue(onControl);
859
681
  if (result.paused) await opened.session.cancel();
@@ -863,41 +685,23 @@ var StreamsTransportClient = class {
863
685
  latestCursor: result.latestCursor
864
686
  };
865
687
  }
866
- async openItemsSseSession(url, initialOffset, onOpen, signal) {
867
- const response = await this.fetchAuthorized(url, {
868
- method: "GET",
688
+ async openItemsSseSession(initialOffset, cursor, onOpen, signal) {
689
+ const result = await this.client.openSseSession({
690
+ offset: initialOffset,
691
+ cursor,
869
692
  signal
870
- }, this.reconnectConfig.connectTimeoutMs, "connect");
871
- if (response.status === 410) return { kind: "gone" };
872
- if (response.status === 404) return { kind: "not-found" };
873
- if (response.status === 400) {
874
- const errorText = await response.text();
875
- if (isSseUnsupportedErrorText(errorText)) return { kind: "unsupported" };
876
- throw new HttpError(errorText.length > 0 ? `sse read failed with status 400: ${errorText}` : "sse read failed with status 400", response.status);
693
+ });
694
+ if (!result.ok) {
695
+ if (result.result.code === "gone") return { kind: "gone" };
696
+ if (result.result.code === "not_found") return { kind: "not-found" };
697
+ if (result.result.code === "bad_request" && isSseUnsupportedErrorText(result.result.message)) return { kind: "unsupported" };
698
+ throw this.toTransportFailure(result.result);
877
699
  }
878
- if (!response.ok) throw new HttpError(`sse read failed with status ${response.status}`, response.status);
879
- if (!(response.headers.get("Content-Type") ?? "").toLowerCase().includes("text/event-stream")) throw new ProtocolError("sse read response must use text/event-stream");
880
- const sseDataEncoding = response.headers.get("stream-sse-data-encoding");
700
+ const session = result.result;
881
701
  onOpen?.();
882
- if (response.body == null) return {
883
- kind: "ok",
884
- session: {
885
- continue: async () => ({
886
- kind: "ok",
887
- latestOffset: initialOffset,
888
- paused: false
889
- }),
890
- cancel: async () => {}
891
- }
892
- };
893
- const reader = response.body.getReader();
894
- const decoder = new TextDecoder();
895
- let pendingText = "";
896
- let currentEvent = "";
897
- let dataLines = [];
898
702
  let pendingDataEvents = [];
899
- let latestOffset = initialOffset;
900
- let latestCursor;
703
+ let latestOffset = session.nextOffset ?? initialOffset;
704
+ let latestCursor = session.cursor;
901
705
  let exhausted = false;
902
706
  return {
903
707
  kind: "ok",
@@ -909,96 +713,48 @@ var StreamsTransportClient = class {
909
713
  latestCursor,
910
714
  paused: false
911
715
  };
912
- let stopRequested = false;
913
- let pauseRequested = false;
914
- const processEvent = async () => {
915
- if (dataLines.length === 0) {
916
- currentEvent = "";
917
- return;
918
- }
919
- const eventType = currentEvent.length === 0 ? "message" : currentEvent;
920
- const data = dataLines.join("\n");
921
- currentEvent = "";
922
- dataLines = [];
923
- if (eventType === "data") {
924
- pendingDataEvents.push(data);
925
- return;
716
+ for await (const event of session.events) {
717
+ if (event.event === "data") {
718
+ pendingDataEvents.push(event.data);
719
+ continue;
926
720
  }
927
- if (eventType !== "control") return;
928
- const control = parseSseControlData(data);
721
+ if (event.event !== "control") continue;
722
+ const control = parseSseControlData(event.data);
929
723
  const updates = [];
930
- for (const eventData of pendingDataEvents) for (const payload of decodeSseItemPayloads(eventData, sseDataEncoding)) updates.push(payload);
724
+ for (const eventData of pendingDataEvents) updates.push(...decodeSseItemPayloads(eventData, session.dataEncoding));
931
725
  pendingDataEvents = [];
932
726
  const handlerResult = await nextOnControl(updates, control.nextOffset, control.upToDate, control.streamCursor);
933
727
  latestOffset = control.nextOffset;
934
728
  if (control.streamCursor != null) latestCursor = control.streamCursor;
935
729
  if (handlerResult === "stop") {
936
- stopRequested = true;
937
730
  exhausted = true;
938
- try {
939
- await reader.cancel();
940
- } catch {}
941
- return;
731
+ await session.cancel();
732
+ return {
733
+ kind: "ok",
734
+ latestOffset,
735
+ latestCursor,
736
+ paused: false
737
+ };
942
738
  }
943
- if (handlerResult === "pause") pauseRequested = true;
944
- };
945
- while (true) {
946
- if (stopRequested || pauseRequested) return {
739
+ if (handlerResult === "pause") return {
947
740
  kind: "ok",
948
741
  latestOffset,
949
742
  latestCursor,
950
- paused: pauseRequested
951
- };
952
- const { done, value } = await reader.read();
953
- if (value != null) {
954
- pendingText += decoder.decode(value, { stream: true });
955
- while (true) {
956
- const newline = pendingText.indexOf("\n");
957
- if (newline < 0) break;
958
- const rawLine = pendingText.slice(0, newline);
959
- pendingText = pendingText.slice(newline + 1);
960
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
961
- if (line.length === 0) {
962
- await processEvent();
963
- if (stopRequested || pauseRequested) break;
964
- continue;
965
- }
966
- if (line.startsWith(":")) continue;
967
- if (line.startsWith("event:")) {
968
- currentEvent = line.slice(6).trim();
969
- continue;
970
- }
971
- if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
972
- }
973
- }
974
- if (stopRequested || pauseRequested) return {
975
- kind: "ok",
976
- latestOffset,
977
- latestCursor,
978
- paused: pauseRequested
979
- };
980
- if (!done) continue;
981
- exhausted = true;
982
- pendingText += decoder.decode();
983
- if (pendingText.length > 0) {
984
- const line = pendingText.endsWith("\r") ? pendingText.slice(0, -1) : pendingText;
985
- if (line.length > 0 && line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
986
- }
987
- await processEvent();
988
- return {
989
- kind: "ok",
990
- latestOffset,
991
- latestCursor,
992
- paused: pauseRequested
743
+ paused: true
993
744
  };
994
745
  }
746
+ exhausted = true;
747
+ return {
748
+ kind: "ok",
749
+ latestOffset,
750
+ latestCursor,
751
+ paused: false
752
+ };
995
753
  },
996
754
  cancel: async () => {
997
755
  if (exhausted) return;
998
756
  exhausted = true;
999
- try {
1000
- await reader.cancel();
1001
- } catch {}
757
+ await session.cancel();
1002
758
  }
1003
759
  }
1004
760
  };
@@ -1319,6 +1075,7 @@ function createJoinState(cursor, liveMode = "sse") {
1319
1075
  pendingLocal: new Deque(),
1320
1076
  flushingLocal: false,
1321
1077
  retryAttempt: 0,
1078
+ retrySleepController: new AbortController(),
1322
1079
  cursor,
1323
1080
  liveMode,
1324
1081
  snapshot: createTransportSnapshotState(),
@@ -1407,6 +1164,27 @@ var StreamsCrdt = class StreamsCrdt {
1407
1164
  if (state != null) await this.disposeJoinState(state);
1408
1165
  }
1409
1166
  /**
1167
+ * Resets the retry counter and immediately attempts to reconnect.
1168
+ *
1169
+ * Use this after device wake or network recovery to skip any pending
1170
+ * backoff delay. No-op when the room is already joined or no join is active.
1171
+ *
1172
+ * If the room reached `"disconnected"` status (only possible with a finite
1173
+ * `maxAttempts` config), this restarts the read loop.
1174
+ */
1175
+ rejoin() {
1176
+ const state = this.joinState;
1177
+ if (state == null || state.closed || state.status === "joined") return;
1178
+ const wasDisconnected = state.status === "disconnected";
1179
+ state.retryAttempt = 0;
1180
+ state.retrySleepController.abort();
1181
+ state.retrySleepController = new AbortController();
1182
+ if (wasDisconnected) {
1183
+ this.setJoinStatus(state, "reconnecting");
1184
+ this.runReadLoop(state);
1185
+ }
1186
+ }
1187
+ /**
1410
1188
  * Reads the latest remote snapshot offset, if the server exposes one.
1411
1189
  */
1412
1190
  async getSnapshotOffset() {
@@ -1827,7 +1605,7 @@ var StreamsCrdt = class StreamsCrdt {
1827
1605
  }
1828
1606
  const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig);
1829
1607
  state.retryAttempt += 1;
1830
- await sleep(delay);
1608
+ await sleep(delay, state.retrySleepController.signal);
1831
1609
  }
1832
1610
  }
1833
1611
  /** Maximum encoded size (bytes) for a single batched append. */
@@ -1899,7 +1677,7 @@ var StreamsCrdt = class StreamsCrdt {
1899
1677
  this.setJoinStatus(state, "reconnecting");
1900
1678
  const delay = computeRetryDelay(attempts, this.reconnectConfig);
1901
1679
  attempts += 1;
1902
- await sleep(delay);
1680
+ await sleep(delay, state.retrySleepController.signal);
1903
1681
  }
1904
1682
  }
1905
1683
  } finally {
@@ -2001,4 +1779,4 @@ function createStreamUrl(input) {
2001
1779
  //#endregion
2002
1780
  export { InMemoryRemoteCursorStore as a, StreamsCrdt as i, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t };
2003
1781
 
2004
- //# sourceMappingURL=stream-id-kM9JM1F7.js.map
1782
+ //# sourceMappingURL=stream-id-BS_IJr-Y.js.map