@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.
- package/AGENTS.md +72 -63
- package/CHANGELOG.md +97 -0
- package/README.md +325 -235
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +245 -76
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +22 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +49 -16
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +981 -237
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +204 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +30 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1905 -322
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +335 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +590 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +356 -83
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +162 -20
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +27 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +408 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +23 -17
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +21 -22
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +262 -80
- package/src/document-checkpoints.ts +30 -0
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +120 -8
- package/src/document-streams.ts +95 -9
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +38 -13
- package/src/documents.ts +1103 -290
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3407 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +216 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +7 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2441 -502
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +686 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +387 -89
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +183 -26
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +461 -92
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
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 = (
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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:
|
|
36
|
-
*
|
|
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
|
-
|
|
94
|
+
const spilled = tailOps.filter(isUrlOp).length;
|
|
95
|
+
if (spilled === 0) {
|
|
49
96
|
return tailOps as ResolvedTailOp[];
|
|
50
97
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/topic-lifecycle.ts
CHANGED
|
@@ -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
|
|
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?:
|
|
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`.
|
|
41
|
-
// the
|
|
42
|
-
//
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
}
|