@brass-build/client 0.1.0 → 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.
Files changed (213) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +325 -235
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +4 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +245 -76
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-checkpoints.d.ts +1 -0
  13. package/dist/document-checkpoints.d.ts.map +1 -1
  14. package/dist/document-checkpoints.js +27 -0
  15. package/dist/document-checkpoints.js.map +1 -1
  16. package/dist/document-engine.d.ts +4 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +9 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-signals.d.ts +31 -1
  21. package/dist/document-signals.d.ts.map +1 -1
  22. package/dist/document-signals.js +59 -0
  23. package/dist/document-signals.js.map +1 -1
  24. package/dist/document-streams.d.ts +8 -1
  25. package/dist/document-streams.d.ts.map +1 -1
  26. package/dist/document-streams.js +79 -8
  27. package/dist/document-streams.js.map +1 -1
  28. package/dist/document-thumbnail-capture.d.ts +20 -0
  29. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  30. package/dist/document-thumbnail-capture.js +247 -9
  31. package/dist/document-thumbnail-capture.js.map +1 -1
  32. package/dist/documents-namespace.d.ts.map +1 -1
  33. package/dist/documents-namespace.js +22 -3
  34. package/dist/documents-namespace.js.map +1 -1
  35. package/dist/documents.d.ts +49 -16
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +981 -237
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed-protocol.d.ts +3 -0
  40. package/dist/embed-protocol.d.ts.map +1 -0
  41. package/dist/embed-protocol.js +26 -0
  42. package/dist/embed-protocol.js.map +1 -0
  43. package/dist/emitter.d.ts.map +1 -1
  44. package/dist/emitter.js +8 -1
  45. package/dist/emitter.js.map +1 -1
  46. package/dist/export-guest.d.ts.map +1 -1
  47. package/dist/export-guest.js +14 -6
  48. package/dist/export-guest.js.map +1 -1
  49. package/dist/fetch-deadline.d.ts +22 -0
  50. package/dist/fetch-deadline.d.ts.map +1 -0
  51. package/dist/fetch-deadline.js +96 -0
  52. package/dist/fetch-deadline.js.map +1 -0
  53. package/dist/fetch-retry.d.ts +1 -0
  54. package/dist/fetch-retry.d.ts.map +1 -1
  55. package/dist/fetch-retry.js +12 -5
  56. package/dist/fetch-retry.js.map +1 -1
  57. package/dist/handle-types.d.ts +150 -0
  58. package/dist/handle-types.d.ts.map +1 -0
  59. package/dist/handle-types.js +8 -0
  60. package/dist/handle-types.js.map +1 -0
  61. package/dist/handles.d.ts +48 -48
  62. package/dist/handles.d.ts.map +1 -1
  63. package/dist/handles.js +3122 -501
  64. package/dist/handles.js.map +1 -1
  65. package/dist/hosted-runtime.d.ts.map +1 -1
  66. package/dist/hosted-runtime.js +8 -12
  67. package/dist/hosted-runtime.js.map +1 -1
  68. package/dist/hosted-ui.d.ts +0 -1
  69. package/dist/hosted-ui.d.ts.map +1 -1
  70. package/dist/hosted-ui.js +29 -24
  71. package/dist/hosted-ui.js.map +1 -1
  72. package/dist/importer.d.ts.map +1 -1
  73. package/dist/importer.js +204 -8
  74. package/dist/importer.js.map +1 -1
  75. package/dist/index.d.ts +10 -10
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +11 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal.d.ts +2 -1
  80. package/dist/internal.d.ts.map +1 -1
  81. package/dist/internal.js +1 -0
  82. package/dist/internal.js.map +1 -1
  83. package/dist/join-coverage.d.ts +12 -0
  84. package/dist/join-coverage.d.ts.map +1 -0
  85. package/dist/join-coverage.js +37 -0
  86. package/dist/join-coverage.js.map +1 -0
  87. package/dist/jwt.d.ts.map +1 -1
  88. package/dist/jwt.js +5 -1
  89. package/dist/jwt.js.map +1 -1
  90. package/dist/launch-context.js +2 -2
  91. package/dist/launch-context.js.map +1 -1
  92. package/dist/mutate-batching.d.ts +25 -2
  93. package/dist/mutate-batching.d.ts.map +1 -1
  94. package/dist/mutate-batching.js +57 -10
  95. package/dist/mutate-batching.js.map +1 -1
  96. package/dist/open-document.d.ts +30 -1
  97. package/dist/open-document.d.ts.map +1 -1
  98. package/dist/open-document.js +1905 -322
  99. package/dist/open-document.js.map +1 -1
  100. package/dist/presence.d.ts +28 -0
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +149 -7
  103. package/dist/presence.js.map +1 -1
  104. package/dist/record-paging.d.ts +335 -71
  105. package/dist/record-paging.d.ts.map +1 -1
  106. package/dist/record-paging.js +590 -127
  107. package/dist/record-paging.js.map +1 -1
  108. package/dist/selection-persistence.d.ts.map +1 -1
  109. package/dist/selection-persistence.js +11 -1
  110. package/dist/selection-persistence.js.map +1 -1
  111. package/dist/serializer.d.ts +30 -1
  112. package/dist/serializer.d.ts.map +1 -1
  113. package/dist/session-persistence.d.ts.map +1 -1
  114. package/dist/session-persistence.js +9 -8
  115. package/dist/session-persistence.js.map +1 -1
  116. package/dist/session-toolbar.d.ts +0 -1
  117. package/dist/session-toolbar.d.ts.map +1 -1
  118. package/dist/session-toolbar.js +356 -83
  119. package/dist/session-toolbar.js.map +1 -1
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +87 -18
  122. package/dist/session.js.map +1 -1
  123. package/dist/tail-ops.d.ts +14 -4
  124. package/dist/tail-ops.d.ts.map +1 -1
  125. package/dist/tail-ops.js +75 -15
  126. package/dist/tail-ops.js.map +1 -1
  127. package/dist/topic-lifecycle.d.ts +4 -0
  128. package/dist/topic-lifecycle.d.ts.map +1 -1
  129. package/dist/topic-lifecycle.js +25 -1
  130. package/dist/topic-lifecycle.js.map +1 -1
  131. package/dist/types.d.ts +47 -1
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/undo.d.ts +2 -3
  134. package/dist/undo.d.ts.map +1 -1
  135. package/dist/undo.js +11 -0
  136. package/dist/undo.js.map +1 -1
  137. package/dist/unload-guard.d.ts +5 -0
  138. package/dist/unload-guard.d.ts.map +1 -0
  139. package/dist/unload-guard.js +88 -0
  140. package/dist/unload-guard.js.map +1 -0
  141. package/dist/version-bar.d.ts +4 -3
  142. package/dist/version-bar.d.ts.map +1 -1
  143. package/dist/version-bar.js +162 -20
  144. package/dist/version-bar.js.map +1 -1
  145. package/dist/version.d.ts +2 -2
  146. package/dist/version.js +1 -1
  147. package/dist/vite.d.ts +18 -2
  148. package/dist/vite.d.ts.map +1 -1
  149. package/dist/vite.js +133 -8
  150. package/dist/vite.js.map +1 -1
  151. package/dist/when-saved.d.ts +2 -0
  152. package/dist/when-saved.d.ts.map +1 -0
  153. package/dist/when-saved.js +70 -0
  154. package/dist/when-saved.js.map +1 -0
  155. package/dist/ws-client.d.ts +27 -3
  156. package/dist/ws-client.d.ts.map +1 -1
  157. package/dist/ws-client.js +408 -89
  158. package/dist/ws-client.js.map +1 -1
  159. package/dist/ws-frame-limit.d.ts.map +1 -1
  160. package/dist/ws-frame-limit.js +23 -17
  161. package/dist/ws-frame-limit.js.map +1 -1
  162. package/docs/app-manifest.md +7 -8
  163. package/docs/custom-chrome.md +21 -22
  164. package/docs/hosted-ui-advanced.md +14 -17
  165. package/docs/imported-documents.md +53 -197
  166. package/docs/presence.md +45 -21
  167. package/docs/schemas.md +12 -17
  168. package/docs/streams.md +55 -57
  169. package/package.json +4 -3
  170. package/src/auth-flow.ts +90 -23
  171. package/src/client.ts +262 -80
  172. package/src/document-checkpoints.ts +30 -0
  173. package/src/document-engine.ts +15 -0
  174. package/src/document-signals.ts +120 -8
  175. package/src/document-streams.ts +95 -9
  176. package/src/document-thumbnail-capture.ts +253 -9
  177. package/src/documents-namespace.ts +38 -13
  178. package/src/documents.ts +1103 -290
  179. package/src/embed-protocol.ts +25 -0
  180. package/src/emitter.ts +8 -1
  181. package/src/export-guest.ts +15 -9
  182. package/src/fetch-deadline.ts +119 -0
  183. package/src/fetch-retry.ts +14 -5
  184. package/src/handle-types.ts +303 -0
  185. package/src/handles.ts +3407 -683
  186. package/src/hosted-runtime.ts +8 -12
  187. package/src/hosted-ui.ts +28 -32
  188. package/src/importer.ts +216 -8
  189. package/src/index.ts +29 -18
  190. package/src/internal.ts +7 -0
  191. package/src/join-coverage.ts +42 -0
  192. package/src/jwt.ts +8 -1
  193. package/src/launch-context.ts +2 -2
  194. package/src/mutate-batching.ts +60 -11
  195. package/src/open-document.ts +2441 -502
  196. package/src/presence.ts +210 -11
  197. package/src/record-paging.ts +686 -137
  198. package/src/selection-persistence.ts +10 -1
  199. package/src/serializer.ts +33 -1
  200. package/src/session-persistence.ts +9 -8
  201. package/src/session-toolbar.ts +387 -89
  202. package/src/session.ts +91 -17
  203. package/src/tail-ops.ts +76 -10
  204. package/src/topic-lifecycle.ts +31 -1
  205. package/src/types.ts +56 -4
  206. package/src/undo.ts +13 -5
  207. package/src/unload-guard.ts +91 -0
  208. package/src/version-bar.ts +183 -26
  209. package/src/version.ts +1 -1
  210. package/src/vite.ts +175 -10
  211. package/src/when-saved.ts +66 -0
  212. package/src/ws-client.ts +461 -92
  213. package/src/ws-frame-limit.ts +23 -17
package/src/session.ts CHANGED
@@ -375,7 +375,14 @@ export function createDocumentSession(
375
375
  // `openError` message joins the signature so a switch from one failed open
376
376
  // to a different one (same 'error' status, no bound doc) still re-fires.
377
377
  const err = openError ? `${openError.status ?? ''}:${openError.message}` : '';
378
- return `${status()}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${cmds}`;
378
+ // The previewed version and the browsing flag are their own terms because
379
+ // nothing else in this signature follows them: a preview -> preview step
380
+ // holds the id, the name, the status and every command's enabled state
381
+ // (edit affordances are already disabled by the first checkout), so
382
+ // without these the step is a silent emit and the chrome rendering the
383
+ // preview keeps showing the version the reader stepped away from.
384
+ const ver = `${document?.version ?? ''}:${document?.versionBrowsing ? 1 : 0}`;
385
+ return `${status()}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${ver}|${cmds}`;
379
386
  }
380
387
 
381
388
  function emit(): void {
@@ -386,7 +393,10 @@ export function createDocumentSession(
386
393
  // subscribe handler awaits whenReady sees it already resolved by the time
387
394
  // its listener runs.
388
395
  settleWhenReady();
389
- for (const listener of listeners) {
396
+ // Copied before the walk, so a listener that detaches another still
397
+ // leaves it this fire: an entry removed from a `Set` before its turn is
398
+ // never visited.
399
+ for (const listener of Array.from(listeners)) {
390
400
  try {
391
401
  listener(session);
392
402
  } catch (err) {
@@ -533,8 +543,7 @@ export function createDocumentSession(
533
543
  // A `branches` event resolves `defaultBranchId`, which `urlBranch`
534
544
  // needs to drop a specified-but-default branch; re-sync in place so
535
545
  // the pinned branch settles once the default is known.
536
- const kind = (event as { kind?: string } | undefined)?.kind;
537
- if (kind === 'branches') syncUrlForActive('replace');
546
+ if (event.kind === 'branches') syncUrlForActive('replace');
538
547
  // Take the doc's name whenever it moves. A rename reaches the doc
539
548
  // from the server's `doc_meta` frame, which sets it directly, and
540
549
  // `documentName` is what the emit signature reads: without this the
@@ -564,7 +573,15 @@ export function createDocumentSession(
564
573
  // (access was genuinely lost in the move). Detach the moved doc first
565
574
  // so that failure parks the gone surface rather than rebinding a doc
566
575
  // that is itself gone.
576
+ //
577
+ // Detaching is what leaves the evicted copy to be disposed here:
578
+ // `activate` closes the document it detached itself, and it detached
579
+ // a null one. Nothing else holds this copy, and an evicted Document
580
+ // keeps its topic subscription and its LoroDoc until it is closed, so
581
+ // every move would otherwise leave one resident for the life of the
582
+ // page, taking a copy of every op the reopened document receives.
567
583
  const branch = urlBranch(next);
584
+ const evicted = next;
568
585
  document = null;
569
586
  documentName = null;
570
587
  void activate(
@@ -581,7 +598,9 @@ export function createDocumentSession(
581
598
  null,
582
599
  'skip',
583
600
  branch,
584
- ).catch(() => markGoneRemote());
601
+ )
602
+ .catch(() => markGoneRemote())
603
+ .finally(() => closeQuietly(evicted));
585
604
  return;
586
605
  }
587
606
  // Evicted from under the viewer (remote delete, or a future eviction
@@ -712,11 +731,51 @@ export function createDocumentSession(
712
731
  const previousSpecifiedBranch = activeSpecifiedBranch;
713
732
  document = null;
714
733
  documentName = null;
734
+ // What the join's spilled ops have cost so far, and what the whole
735
+ // download was declared to be. Carried across the two phases so the pair
736
+ // reads as one figure. Per open, because a second open of the same
737
+ // document downloads it again.
738
+ let tailLoadedBytes = 0;
739
+ let downloadTotalBytes: number | null = null;
715
740
  emit();
716
741
  // Surface snapshot-download progress. Ignored once a newer open supersedes
717
742
  // this generation, so a slow stale download can't repaint the new open.
718
- const onSnapshotProgress = (loaded: number, total: number | null): void => {
743
+ const onSnapshotProgress = (
744
+ loaded: number,
745
+ total: number | null,
746
+ phase?: 'snapshot' | 'tail',
747
+ ): void => {
719
748
  if (generation !== openGeneration) return;
749
+ // One download, reported once. A document whose ops spilled to storage
750
+ // fetches them from the join before the snapshot, so a load has two
751
+ // stretches of bytes arriving over the network with a reader watching.
752
+ // Which of them is on the wire is a fact about how the platform stores a
753
+ // document, not something a reader can act on, so both count into the
754
+ // same figure under the same word.
755
+ //
756
+ // The tail runs first and declares no total, so its bytes are carried
757
+ // into the snapshot's total once that is known. The percentage is then
758
+ // monotonic across the pair rather than filling twice.
759
+ if (phase === 'tail') {
760
+ tailLoadedBytes = loaded;
761
+ // The tail's total already includes the snapshot still to come, so the
762
+ // figure runs across both rather than filling once here and again
763
+ // after. Held for the snapshot phase, which reports only its own.
764
+ downloadTotalBytes = total;
765
+ }
766
+ if (phase !== 'tail') {
767
+ loaded += tailLoadedBytes;
768
+ if (downloadTotalBytes !== null) total = downloadTotalBytes;
769
+ else if (total !== null) total += tailLoadedBytes;
770
+ }
771
+ // Both phases fall through to the same reporting. The tail's total
772
+ // already covers the snapshot still to come, so a tail callback that
773
+ // reaches the total is a load with no snapshot phase behind it (an
774
+ // inline snapshot arrived with the join and declares no bytes), and
775
+ // the last byte of the download is the last progress event there will
776
+ // ever be. Reported as a fraction of its own phase instead, that load
777
+ // ends parked on a number that never moves again while the reader
778
+ // waits out the import.
720
779
  const complete = total !== null && total > 0 && loaded >= total;
721
780
  if (complete) {
722
781
  // Bytes are in; the remaining work (loro import, projection, render)
@@ -732,25 +791,22 @@ export function createDocumentSession(
732
791
  }
733
792
  emit();
734
793
  };
735
- let doc: OpenedDocument;
736
- try {
737
- doc = await load(onSnapshotProgress);
738
- } catch (err) {
794
+ // Give up on this open. A warm swap (previous doc present) rebinds it and
795
+ // only rejects the caller, so the working surface is never clobbered; with
796
+ // nothing to fall back to the session parks in 'error' so a status-gated
797
+ // app renders the failure, where a rebind would land on the empty state
798
+ // and read as the app's document-less home. `display` is what the app
799
+ // renders; it defaults to the platform's own open-failure copy.
800
+ function abandonOpen(err: unknown, display?: BrassError): never {
739
801
  if (generation === openGeneration) {
740
802
  loading = false;
741
803
  loadingMessage = null;
742
804
  loadingProgress = null;
743
- // Rebind the previous document so the user keeps their editor.
744
805
  // 'replace' restores the URL without planting an entry for an open
745
806
  // that never happened.
746
807
  activeSpecifiedBranch = previousSpecifiedBranch;
747
- // With no document to fall back to, a rebind lands on the empty state,
748
- // which reads as the app's document-less home and hides the failure.
749
- // Park the session in 'error' instead so a status-gated app renders
750
- // the failure. A warm swap (previous doc present) keeps the editor and
751
- // only rejects the caller, so the working surface is never clobbered.
752
808
  if (surfaceOpenError && previousDocument === null) {
753
- openError = toDisplayError(err);
809
+ openError = display ?? toDisplayError(err);
754
810
  // Leave the URL pointing at the doc that failed ('skip', not the
755
811
  // 'replace' below that clears it for a null document): the error
756
812
  // surface corresponds to that id, so a reload re-shows it and the
@@ -766,6 +822,12 @@ export function createDocumentSession(
766
822
  }
767
823
  throw err;
768
824
  }
825
+ let doc: OpenedDocument;
826
+ try {
827
+ doc = await load(onSnapshotProgress);
828
+ } catch (err) {
829
+ abandonOpen(err);
830
+ }
769
831
  if (generation !== openGeneration) {
770
832
  // A newer open / close superseded this one. Discard the orphan (so its
771
833
  // WS subscription doesn't leak) plus the doc this generation detached.
@@ -815,6 +877,18 @@ export function createDocumentSession(
815
877
  return doc;
816
878
  }
817
879
  }
880
+ // The work ended with no content: the platform could not prepare this
881
+ // document, so the open failed. Binding it would present an empty
882
+ // document as ready, and the app would let the user work in it. The
883
+ // platform's own message is what the app renders.
884
+ if (doc._contentLoadFailed) {
885
+ closeQuietly(doc);
886
+ const failure = new BrassError(
887
+ doc.status?.message ?? OPEN_FAILED_MESSAGE,
888
+ { status: 0 },
889
+ );
890
+ abandonOpen(failure, failure);
891
+ }
818
892
  loading = false;
819
893
  loadingMessage = null;
820
894
  loadingProgress = null;
package/src/tail-ops.ts CHANGED
@@ -16,7 +16,11 @@ import { BrassError } from './errors.js';
16
16
  /** A tail op as the server sends it. */
17
17
  export type WireTailOp =
18
18
  | { seq: number; op_blob_b64: string }
19
- | { seq: number; op_blob_url: string };
19
+ // `op_blob_bytes` is what this entry costs to fetch, which is what lets a
20
+ // load know the whole of its download before it starts one. Optional
21
+ // because a server that predates it names no size, and a total built from
22
+ // what is named is still better than none.
23
+ | { seq: number; op_blob_url: string; op_blob_bytes?: number };
20
24
 
21
25
  /** A tail op with its bytes in hand. */
22
26
  export interface ResolvedTailOp {
@@ -28,13 +32,44 @@ function isUrlOp(op: WireTailOp): op is { seq: number; op_blob_url: string } {
28
32
  return typeof (op as { op_blob_url?: unknown }).op_blob_url === 'string';
29
33
  }
30
34
 
35
+ /**
36
+ * What the spilled entries in this tail say they will cost to fetch.
37
+ *
38
+ * Read before any of them is fetched, so a load can state the whole of what it
39
+ * is about to download rather than discovering it. An entry naming no size
40
+ * counts zero, which understates the total: a bar that finishes early is a
41
+ * worse answer than one that never moves, and a much better one than a bar
42
+ * that goes backwards when the rest arrives.
43
+ */
44
+ export function declaredTailBytes(tailOps: readonly WireTailOp[]): number {
45
+ let total = 0;
46
+ for (const op of tailOps) {
47
+ if (!isUrlOp(op)) continue;
48
+ const declared = (op as { op_blob_bytes?: unknown }).op_blob_bytes;
49
+ if (typeof declared === 'number' && declared > 0) total += declared;
50
+ }
51
+ return total;
52
+ }
53
+
54
+ /**
55
+ * How many spilled ops a join fetches at once.
56
+ *
57
+ * A browser refuses to open an unbounded number of sockets: past a few
58
+ * hundred in flight it fails the rest of them outright with
59
+ * `ERR_INSUFFICIENT_RESOURCES`, which arrives as a rejected fetch and
60
+ * fails the whole join. Chrome's own per-host cap is 6, so a higher bound
61
+ * here only queues inside the browser; this is low enough to stay clear of
62
+ * the failure and high enough that the queue drains at the connection
63
+ * limit rather than the loop's.
64
+ */
65
+ const TAIL_FETCH_CONCURRENCY = 8;
66
+
31
67
  /**
32
68
  * Fetch any tail op whose bytes live behind a signed URL, leaving inline
33
69
  * ops untouched, and return the tail in its original order.
34
70
  *
35
- * Ordering matters and concurrency does not disturb it: the fetches run
36
- * together but each result lands back at its own index, so the caller
37
- * still applies ops in ascending seq.
71
+ * Ordering matters and concurrency does not disturb it: each result lands
72
+ * back at its own index, so the caller still applies ops in ascending seq.
38
73
  *
39
74
  * A failed fetch throws rather than dropping the op. Skipping one would
40
75
  * leave the reader a document missing an edit it will never be sent
@@ -44,20 +79,51 @@ function isUrlOp(op: WireTailOp): op is { seq: number; op_blob_url: string } {
44
79
  export async function resolveTailOps(
45
80
  tailOps: readonly WireTailOp[],
46
81
  fetchImpl: typeof fetch = fetch,
82
+ // Told how many BYTES of the spilled ops are in hand, as each lands. A
83
+ // document whose tail spilled fetches hundreds of objects here, eight at a
84
+ // time, inside the join and before anything else reports: without this the
85
+ // whole stretch is silent, and a load that is working looks exactly like one
86
+ // that has hung.
87
+ //
88
+ // Bytes rather than objects because this is half of one download, not a
89
+ // stage of its own. The snapshot that follows is measured in bytes, and a
90
+ // reader watching a load has no use for the distinction between an op that
91
+ // spilled and the snapshot it will be folded into.
92
+ onProgress?: (loadedBytes: number) => void,
47
93
  ): Promise<ResolvedTailOp[]> {
48
- if (!tailOps.some(isUrlOp)) {
94
+ const spilled = tailOps.filter(isUrlOp).length;
95
+ if (spilled === 0) {
49
96
  return tailOps as ResolvedTailOp[];
50
97
  }
51
- return Promise.all(
52
- tailOps.map(async (op) => {
53
- if (!isUrlOp(op)) return op;
98
+ const out: ResolvedTailOp[] = Array.from({ length: tailOps.length });
99
+ let loadedBytes = 0;
100
+ onProgress?.(0);
101
+ let next = 0;
102
+ const worker = async (): Promise<void> => {
103
+ for (;;) {
104
+ const i = next++;
105
+ if (i >= tailOps.length) return;
106
+ const op = tailOps[i]!;
107
+ if (!isUrlOp(op)) {
108
+ out[i] = op;
109
+ continue;
110
+ }
54
111
  // The URL is pre-signed, so it carries no Brass headers: adding one
55
112
  // would fall outside the signature and be refused.
56
113
  const response = await fetchImpl(op.op_blob_url, { method: 'GET' });
57
114
  if (!response.ok) {
58
115
  throw new BrassError('Tail op fetch failed', { status: response.status });
59
116
  }
60
- return { seq: op.seq, op_blob_b64: await response.text() };
61
- }),
117
+ const body = await response.text();
118
+ out[i] = { seq: op.seq, op_blob_b64: body };
119
+ loadedBytes += body.length;
120
+ onProgress?.(loadedBytes);
121
+ }
122
+ };
123
+ await Promise.all(
124
+ Array.from({ length: Math.min(TAIL_FETCH_CONCURRENCY, tailOps.length) }, () =>
125
+ worker(),
126
+ ),
62
127
  );
128
+ return out;
63
129
  }
@@ -17,6 +17,11 @@
17
17
  // `retrySpent` survives into a same-connection re-subscribe so the
18
18
  // retry stays one-per-topic-per-connection.
19
19
  //
20
+ // A frame-scoped reason (`isFrameScopedReason`) is the exception to all of
21
+ // that: it describes the frame the server could not process, not the
22
+ // subscription it arrived on, so every live phase keeps its state and hands
23
+ // the error to the topic's callbacks, which hold the frame that drew it.
24
+ //
20
25
  // Invariant: every transition into `tornDown` emits `send_unsubscribe`,
21
26
  // so no server-side subscription row can outlive the client-side state
22
27
  // (an orphan row would have its `last_seen` kept fresh by the heartbeat,
@@ -55,6 +60,10 @@ export type TopicEffect =
55
60
  // Detach the topic's callbacks: further server frames must not reach
56
61
  // them and the user's unsubscribe becomes a no-op.
57
62
  | { kind: 'drop_listeners' }
63
+ // Hand the error to the topic's callbacks and leave the subscription
64
+ // alone. The callbacks own whatever frame drew it, so they own the
65
+ // recovery.
66
+ | { kind: 'deliver_error'; reason: string }
58
67
  | { kind: 'fire_error'; reason: string };
59
68
 
60
69
  export interface StepResult {
@@ -63,16 +72,28 @@ export interface StepResult {
63
72
  }
64
73
 
65
74
  // True while topic-scoped frames must be held back: sending before the
66
- // `subscribed` ack races the server's SUB# row write and draws a spurious
75
+ // `subscribed` ack races the server's subscription record write and draws a spurious
67
76
  // `forbidden`.
68
77
  export function holdsFrames(state: TopicState): boolean {
69
78
  return state.phase === 'subscribing' || state.phase === 'retrying';
70
79
  }
71
80
 
81
+ // True for a reason that judges one frame rather than the subscription.
82
+ // `unavailable` answers a durable op write the server was authorised to
83
+ // take and could not store right now, so the topic stays up and the writer
84
+ // re-sends the op.
85
+ export function isFrameScopedReason(reason: string): boolean {
86
+ return reason === 'unavailable';
87
+ }
88
+
72
89
  const SEND_SUBSCRIBE: TopicEffect = { kind: 'send_subscribe' };
73
90
  const FLUSH_HELD: TopicEffect = { kind: 'flush_held' };
74
91
  const DROP_HELD: TopicEffect = { kind: 'drop_held' };
75
92
 
93
+ function deliverError(state: TopicState, reason: string): StepResult {
94
+ return { state, effects: [{ kind: 'deliver_error', reason }] };
95
+ }
96
+
76
97
  // Rejection / spent-retry teardown. `send_unsubscribe` releases any
77
98
  // server-side row a completed retry may have re-created (the invariant in
78
99
  // the header); `unsubscribe` is idempotent server-side, so sending it when
@@ -123,6 +144,9 @@ export function step(state: TopicState, event: TopicEvent): StepResult {
123
144
  effects: [FLUSH_HELD],
124
145
  };
125
146
  case 'error':
147
+ if (isFrameScopedReason(event.reason)) {
148
+ return deliverError(state, event.reason);
149
+ }
126
150
  // Never established on this connection: the rejection is final.
127
151
  return tearDown(state, event.reason);
128
152
  case 'socket_open':
@@ -139,6 +163,9 @@ export function step(state: TopicState, event: TopicEvent): StepResult {
139
163
  case 'established':
140
164
  switch (event.type) {
141
165
  case 'error':
166
+ if (isFrameScopedReason(event.reason)) {
167
+ return deliverError(state, event.reason);
168
+ }
142
169
  // A mid-stream `not_subscribed` is an eviction (the server
143
170
  // dropped the subscription row while this client stayed alive,
144
171
  // e.g. a presence reap that raced a live-but-quiet connection),
@@ -169,6 +196,9 @@ export function step(state: TopicState, event: TopicEvent): StepResult {
169
196
  case 'retrying':
170
197
  switch (event.type) {
171
198
  case 'error':
199
+ if (isFrameScopedReason(event.reason)) {
200
+ return deliverError(state, event.reason);
201
+ }
172
202
  // An eviction draws errors for EVERY frame in flight against the
173
203
  // reaped row; the trailing ones land while the retry's subscribe
174
204
  // is unacked and describe frames that predate it, so the retry
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { HostedUiTheme } from './hosted-ui.js';
2
+
1
3
  export interface BrassUser {
2
4
  sub: string;
3
5
  email?: string;
@@ -27,7 +29,7 @@ export interface BrassInitOptions {
27
29
  // `'light'`; `'auto'` follows the OS preference. An app that forces
28
30
  // its own theme should pass the matching value so the modal doesn't
29
31
  // mismatch the page that opened it.
30
- hostedUiTheme?: 'light' | 'dark' | 'auto';
32
+ hostedUiTheme?: HostedUiTheme;
31
33
  // Open a document even when this app's declared schema cannot read it
32
34
  // (default off). Off, the platform refuses such an open and the SDK asks
33
35
  // the person whether to go ahead, so a bookmark or a reload cannot open
@@ -37,9 +39,10 @@ export interface BrassInitOptions {
37
39
  // Mirror the active document selection to the page URL (default on):
38
40
  // writes the doc id (and non-default branch) using `pushState` for a
39
41
  // genuine open / branch switch and `replaceState` to pin the boot
40
- // selection, and restores from the URL on boot and `popstate`. Read
41
- // the id directly via `Brass.docIdFromUrl()`. Opt out with `false`
42
- // for an app that owns its URL state (e.g. its own router).
42
+ // selection, and restores from the URL on boot and `popstate`. The
43
+ // document the URL named is bound as `Brass.session.document`, so an
44
+ // app reads the active id from `session.document?.id`. Opt out with
45
+ // `false` for an app that owns its URL state (e.g. its own router).
43
46
  syncDocToUrl?: boolean;
44
47
  // Query-parameter name for the active doc id (default `'doc'`). Set to
45
48
  // disambiguate when the app's URL already uses `doc`.
@@ -73,3 +76,52 @@ export type UserChangeListener = (user: BrassUser | null) => void;
73
76
  // before init to get visibility into failures that an init wrapped
74
77
  // without a `.catch()` would otherwise swallow silently.
75
78
  export type ErrorListener = (error: Error) => void;
79
+
80
+ export interface UndoControllerOptions {
81
+ // Cap on retained undo steps. Older steps drop off the bottom of the
82
+ // stack as new ones land.
83
+ maxUndoSteps?: number;
84
+ }
85
+
86
+ /**
87
+ * A document's own view of how its sync is going, as the client sees it.
88
+ *
89
+ * The server records what reached it, so the facts that separate a write
90
+ * that was lost from a client that is merely slow live only here. Read it
91
+ * when a save is taking longer than it should; `Brass.onError` reports
92
+ * failures, and this reports the state a failure has not yet been declared
93
+ * for.
94
+ */
95
+ export interface DocSyncDiagnostics {
96
+ /** Ops committed locally that the server has not acknowledged. */
97
+ unackedOps: number;
98
+ /** How long the oldest of them has been waiting, in ms. 0 when none. */
99
+ oldestUnackedMs: number;
100
+ /**
101
+ * The oldest unacked op's correlator. The same id rides the op to the
102
+ * server, so it is what an operator matches a stuck save against the
103
+ * server's record of the write.
104
+ */
105
+ oldestClientOpId: string | null;
106
+ /**
107
+ * Sockets the SDK has opened since it was constructed, past the first.
108
+ * Every open document shares one socket, so this is a fact about the
109
+ * client rather than about this document, and it counts a deliberate
110
+ * close and re-open alongside a drop.
111
+ */
112
+ reconnects: number;
113
+ /**
114
+ * Since any acknowledgement landed on this socket, in ms, or null when
115
+ * none ever has. A connection answering nothing and one merely behind
116
+ * look the same from a single op's age; this is what tells them apart.
117
+ */
118
+ lastAckAgeMs: number | null;
119
+ /** Bytes on the socket against the transport's in-flight budget. */
120
+ inFlightBytes: number;
121
+ /** Ops too large for the socket, travelling over HTTP right now. */
122
+ httpOpsInFlight: number;
123
+ /** Rounds the durability handshake has spent reconciling. */
124
+ healRounds: number;
125
+ /** Whether the page is hidden, which throttles the client's timers. */
126
+ hidden: boolean;
127
+ }
package/src/undo.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  // redo stack honest across other peers' edits.
5
5
  import { UndoManager, type LoroDoc } from 'loro-crdt';
6
6
 
7
+ import type { UndoControllerOptions } from './types.js';
8
+
7
9
  // Loro's time-based merge is permanently disabled. The undo boundary here is
8
10
  // the checkpoint group bracket (`sealStep`), and the two are incompatible: a
9
11
  // nonzero merge interval merges adjacent changes by *timestamp*, and that
@@ -20,11 +22,16 @@ const UNDO_MERGE_INTERVAL_MS = 0;
20
22
  // entity sizes is a few hundred KB, well below anything that matters.
21
23
  const DEFAULT_MAX_UNDO_STEPS = 200;
22
24
 
23
- export interface UndoControllerOptions {
24
- // Cap on retained undo steps. Older steps drop off the bottom of the
25
- // stack as new ones land.
26
- maxUndoSteps?: number;
27
- }
25
+ // Commit origin the paged marker (`meta.paged:<root>`) is stamped with, and
26
+ // the one origin the undo stack ignores.
27
+ //
28
+ // The marker says which shape a root's records are stored in, so it is a fact
29
+ // about the store rather than a step the reader took: undoing it leaves the
30
+ // pages the crossing mutation sealed behind a marker that says unpaged, which
31
+ // every reader answers by handing back the page entries themselves. The write
32
+ // that carries it therefore commits alone, so nothing an app authored is
33
+ // excluded alongside it.
34
+ export const PAGING_COMMIT_ORIGIN = 'brass.paging';
28
35
 
29
36
  // Thin wrapper around Loro's UndoManager exposing only what the Document
30
37
  // needs. Owned by the Document; not part of the public SDK surface.
@@ -42,6 +49,7 @@ export class UndoController {
42
49
  this._mgr = new UndoManager(loroDoc, {
43
50
  mergeInterval: UNDO_MERGE_INTERVAL_MS,
44
51
  maxUndoSteps: options?.maxUndoSteps ?? DEFAULT_MAX_UNDO_STEPS,
52
+ excludeOriginPrefixes: [PAGING_COMMIT_ORIGIN],
45
53
  });
46
54
  // Open the implicit group so commits before the first `createCheckpoint`
47
55
  // still bracket as one undo step.
@@ -0,0 +1,91 @@
1
+ // Native "leave site?" confirmation while a document holds ops the server
2
+ // has not acked. Reloading or navigating away drops those ops with the tab,
3
+ // so the browser's own prompt is the last point at which the writer can
4
+ // choose to stay.
5
+ //
6
+ // One registry rather than a listener per document: several documents can be
7
+ // open at once (and one the app has closed can still be parked in the SDK's
8
+ // cache with ops in flight), while the prompt is a property of the page. The
9
+ // registry keys on the owning Document so a count only ever leaves when that
10
+ // document acks its ops or closes, and the listener is attached only while
11
+ // the total is above zero. A permanently attached listener prompts on every
12
+ // navigation, which teaches readers to dismiss it.
13
+
14
+ export interface UnloadTarget {
15
+ addEventListener(type: 'beforeunload', listener: (e: Event) => void): void;
16
+ removeEventListener(type: 'beforeunload', listener: (e: Event) => void): void;
17
+ }
18
+
19
+ const outstandingByOwner = new Map<object, number>();
20
+ let handler: ((e: Event) => void) | null = null;
21
+ // The target the handler is attached to, so a detach cannot go to a
22
+ // different one than the attach did.
23
+ let attachedTo: UnloadTarget | null = null;
24
+ // `undefined` means "resolve from the environment"; a test overrides it with
25
+ // a fake target, or with `null` to stand in for a non-browser host.
26
+ let targetOverride: UnloadTarget | null | undefined;
27
+
28
+ function resolveTarget(): UnloadTarget | null {
29
+ if (targetOverride !== undefined) return targetOverride;
30
+ const candidate = (globalThis as { window?: unknown }).window;
31
+ if (typeof candidate !== 'object' || candidate === null) return null;
32
+ const target = candidate as Partial<UnloadTarget>;
33
+ if (typeof target.addEventListener !== 'function') return null;
34
+ if (typeof target.removeEventListener !== 'function') return null;
35
+ return target as UnloadTarget;
36
+ }
37
+
38
+ function sync(): void {
39
+ let total = 0;
40
+ for (const count of outstandingByOwner.values()) total += count;
41
+
42
+ if (total === 0) {
43
+ if (handler && attachedTo) {
44
+ attachedTo.removeEventListener('beforeunload', handler);
45
+ }
46
+ handler = null;
47
+ attachedTo = null;
48
+ return;
49
+ }
50
+ if (handler) return;
51
+ const target = resolveTarget();
52
+ if (!target) return;
53
+ handler = (e: Event): void => {
54
+ // `preventDefault` is the whole signal. Browsers show their own wording
55
+ // and ignore anything the handler returns or assigns.
56
+ e.preventDefault();
57
+ };
58
+ attachedTo = target;
59
+ target.addEventListener('beforeunload', handler);
60
+ }
61
+
62
+ // Report how many un-acked ops `owner` holds. Zero forgets it.
63
+ /** @internal */
64
+ export function trackUnsavedWork(owner: object, count: number): void {
65
+ if (count > 0) outstandingByOwner.set(owner, count);
66
+ else outstandingByOwner.delete(owner);
67
+ sync();
68
+ }
69
+
70
+ // Forget `owner` entirely, whatever it last reported. Called from
71
+ // `Document.close()`, where the ops go away with the document.
72
+ /** @internal */
73
+ export function releaseUnsavedWork(owner: object): void {
74
+ if (!outstandingByOwner.delete(owner)) return;
75
+ sync();
76
+ }
77
+
78
+ // Point the guard at a fake target and drop every tracked owner, so one
79
+ // test's documents cannot hold the listener open across the next.
80
+ /** @internal */
81
+ export function _setUnloadTargetForTesting(
82
+ target: UnloadTarget | null | undefined,
83
+ ): void {
84
+ if (handler && attachedTo) {
85
+ attachedTo.removeEventListener('beforeunload', handler);
86
+ }
87
+ handler = null;
88
+ attachedTo = null;
89
+ outstandingByOwner.clear();
90
+ targetOverride = target;
91
+ }