@brass-build/client 0.1.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 +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Document lifecycle signals: server-pushed listener-fanout surfaces a
|
|
2
|
+
// Document exposes, separate from the CRDT state it wraps. Pure subscriber
|
|
3
|
+
// registries with a load-bearing emit discipline:
|
|
4
|
+
//
|
|
5
|
+
// - `GoneSignal` fire-once-then-cache (a doc is evicted once)
|
|
6
|
+
// - `WorkStatusSignal` latest-retained, re-emit per phase
|
|
7
|
+
// - `DriveStatusSignal` latest-retained, fire-on-attach, dedup-on-emit
|
|
8
|
+
|
|
9
|
+
import { createEmitter } from './emitter.js';
|
|
10
|
+
|
|
11
|
+
// Reason a Document's underlying doc was evicted. Mirrors the `doc_gone`
|
|
12
|
+
// frame's `reason` (CONTRACT.md "Server-pushed `doc_gone`"). New literals land
|
|
13
|
+
// here as the platform grows transitions that evict open subscribers.
|
|
14
|
+
// `moved`: the doc changed organization or project, so its access gate
|
|
15
|
+
// changed and open subscribers re-establish under the new location.
|
|
16
|
+
export type DocumentGoneReason = 'deleted' | 'moved';
|
|
17
|
+
|
|
18
|
+
export type DocumentGoneListener = (reason: DocumentGoneReason) => void;
|
|
19
|
+
export type DocumentGoneUnsubscribe = () => void;
|
|
20
|
+
|
|
21
|
+
// Opaque status of slow server-side work (hydration, extraction, push).
|
|
22
|
+
// Mirrors the wire shape (CONTRACT.md "Server-pushed `doc_status`"). The app
|
|
23
|
+
// renders `message` verbatim, draws a bar from `progress`, and waits while
|
|
24
|
+
// `pending`. No kind / phase / error code: the server owns all wording and can
|
|
25
|
+
// change phases without any app change.
|
|
26
|
+
export interface DocumentWorkStatus {
|
|
27
|
+
message: string;
|
|
28
|
+
progress?: number;
|
|
29
|
+
pending: boolean;
|
|
30
|
+
// Whether this work locks editing. Inbound work (hydration, extraction)
|
|
31
|
+
// replaces content, so the doc is read-only until it lands
|
|
32
|
+
// (`blocking: true`); an outbound push leaves content untouched, so editing
|
|
33
|
+
// stays open (`blocking: false`). Optional; absence is treated as blocking.
|
|
34
|
+
// `doc.readOnly` already folds this in, so apps rarely read it directly.
|
|
35
|
+
blocking?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type DocumentStatusListener = (status: DocumentWorkStatus) => void;
|
|
39
|
+
export type DocumentStatusUnsubscribe = () => void;
|
|
40
|
+
|
|
41
|
+
// Coarse divergence state of a drive-linked doc vs its source. Mirrors the
|
|
42
|
+
// server's `tracking_state` (CONTRACT.md "Drive document tracking state"). The
|
|
43
|
+
// app reads `state` to decide which affordance to show, never to pick wording
|
|
44
|
+
// (that is server-authored `label` / `detail`).
|
|
45
|
+
export type DocumentDriveTrackingState =
|
|
46
|
+
| 'unhydrated'
|
|
47
|
+
| 'synced'
|
|
48
|
+
| 'ahead'
|
|
49
|
+
| 'behind'
|
|
50
|
+
| 'diverged';
|
|
51
|
+
|
|
52
|
+
// Which way the mirror flows. `two_way` links round-trip; `import_only` links
|
|
53
|
+
// are one-directional (edits stay in Brass, no publish-back path). An app
|
|
54
|
+
// reads this to decide whether to show a Publish affordance at all.
|
|
55
|
+
export type DriveMirrorDirection = 'two_way' | 'import_only';
|
|
56
|
+
|
|
57
|
+
// The drive sync state of an open document, the read-side companion to
|
|
58
|
+
// `doc.pushToDrive(...)`. Present only on drive-linked docs; `null` otherwise.
|
|
59
|
+
// Lets an app show "you have unpublished edits" without polling. Words are
|
|
60
|
+
// server-authored (`label` / `detail`, rendered verbatim); the app branches
|
|
61
|
+
// only on `state` / `canPush` to decide whether to show an affordance.
|
|
62
|
+
export interface DocumentDriveStatus {
|
|
63
|
+
// Adapter type token; an app may key an icon off it. The human label is
|
|
64
|
+
// `driveName`, so an app never maps the type to a vendor name itself.
|
|
65
|
+
driveType: string;
|
|
66
|
+
// The drive's user-facing name, server-authored and rendered verbatim.
|
|
67
|
+
driveName: string;
|
|
68
|
+
// The divergence state. `ahead` / `diverged` mean unpublished local edits.
|
|
69
|
+
state: DocumentDriveTrackingState;
|
|
70
|
+
// True when publishing would do something (local edits to save back and the
|
|
71
|
+
// link is not broken): the cue to surface a "Publish" affordance.
|
|
72
|
+
canPush: boolean;
|
|
73
|
+
// Server-authored short pill text (`null` when nothing worth surfacing).
|
|
74
|
+
label: string | null;
|
|
75
|
+
// Server-authored detail sentence for a tooltip (`null` when none).
|
|
76
|
+
detail: string | null;
|
|
77
|
+
// The link's backing connection was removed; the doc can no longer sync. The
|
|
78
|
+
// app surfaces a terminal "reconnect needed" state.
|
|
79
|
+
broken: boolean;
|
|
80
|
+
// True when a push overwrites the source wholesale instead of 3-way merging
|
|
81
|
+
// (the file-roundtrip path), so an app can warn that the source's newer
|
|
82
|
+
// version will be replaced, not merged.
|
|
83
|
+
pushReplacesRemote: boolean;
|
|
84
|
+
// Whether the link round-trips (`two_way`) or only imports (`import_only`).
|
|
85
|
+
// On `import_only`, `canPush` is always false; an app surfaces the class so
|
|
86
|
+
// a one-way import doesn't read as a live two-way mirror.
|
|
87
|
+
mirrorDirection: DriveMirrorDirection;
|
|
88
|
+
// Current external head ref, empty when the adapter could not produce one.
|
|
89
|
+
ref: string;
|
|
90
|
+
// ISO timestamp the ref was last refreshed, empty on a fresh stub.
|
|
91
|
+
refUpdatedAt: string;
|
|
92
|
+
// The source item's display name, for a "linked to <name>" tooltip.
|
|
93
|
+
itemName: string;
|
|
94
|
+
// Deep link to the source, so a link indicator can open it.
|
|
95
|
+
itemUrl: string;
|
|
96
|
+
// True while a push is in flight (submitted, running server-side, not yet
|
|
97
|
+
// confirmed). A push is asynchronous, so `state` stays `ahead` until it
|
|
98
|
+
// lands; an indicator reads this to hold a "Publishing..." state and hide
|
|
99
|
+
// the Publish affordance rather than re-deriving it. `detail` carries the
|
|
100
|
+
// server-authored progress line while it is `true`.
|
|
101
|
+
publishing: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type DocumentDriveStatusListener = (
|
|
105
|
+
status: DocumentDriveStatus,
|
|
106
|
+
) => void;
|
|
107
|
+
export type DocumentDriveStatusUnsubscribe = () => void;
|
|
108
|
+
|
|
109
|
+
// Each signal is a `createEmitter` composed with the retain / attach / dedup
|
|
110
|
+
// policy its wire event needs, wrapped in a small class only to keep the
|
|
111
|
+
// domain-named methods (`mark`, `emit`, `status`, `clearListeners`) the
|
|
112
|
+
// Document calls. The shared emit discipline (swallow-and-log, fire-on-attach
|
|
113
|
+
// in a microtask, retain the last value) lives in the primitive; the class
|
|
114
|
+
// picks the options.
|
|
115
|
+
|
|
116
|
+
// Fire-once-then-cache signal for `doc_gone`: listeners registered AFTER the
|
|
117
|
+
// event still fire exactly once on attach (the `Promise`-resolution pattern),
|
|
118
|
+
// so callback-wiring after the WS frame may have landed has no race. `mark` is
|
|
119
|
+
// idempotent (both `doc:<id>` and `presence:doc:<id>` fan out a frame, so the
|
|
120
|
+
// one-shot collapses them to a single listener fire).
|
|
121
|
+
export class GoneSignal {
|
|
122
|
+
private readonly _emitter = createEmitter<DocumentGoneReason>({
|
|
123
|
+
fireOnAttach: true,
|
|
124
|
+
retain: 'oneShot',
|
|
125
|
+
onError: (err) => {
|
|
126
|
+
// A consumer throw must not strand the others or break the WS router.
|
|
127
|
+
console.error('[Brass] onGone listener threw:', err);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
get reason(): DocumentGoneReason | null {
|
|
132
|
+
return this._emitter.retained ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
on(cb: DocumentGoneListener): DocumentGoneUnsubscribe {
|
|
136
|
+
return this._emitter.on(cb);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
mark(reason: DocumentGoneReason): void {
|
|
140
|
+
this._emitter.emit(reason);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Drop app-side listeners (suspend / close). The cached reason is NOT
|
|
144
|
+
// cleared: a `_markGone` that already fired stays terminal across a suspend.
|
|
145
|
+
clearListeners(): void {
|
|
146
|
+
this._emitter.clear();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Latest-retained, re-emit-per-phase signal for `doc_status`. Not one-shot:
|
|
151
|
+
// phases arrive repeatedly until terminal (`pending: false`), and the latest
|
|
152
|
+
// is retained so a late subscriber still sees the final state.
|
|
153
|
+
export class WorkStatusSignal {
|
|
154
|
+
private readonly _emitter = createEmitter<DocumentWorkStatus>({
|
|
155
|
+
fireOnAttach: true,
|
|
156
|
+
retain: 'latest',
|
|
157
|
+
onError: (err) => {
|
|
158
|
+
console.error('[Brass] onStatus listener threw:', err);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
get status(): DocumentWorkStatus | null {
|
|
163
|
+
return this._emitter.retained ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
on(cb: DocumentStatusListener): DocumentStatusUnsubscribe {
|
|
167
|
+
return this._emitter.on(cb);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
emit(status: DocumentWorkStatus): void {
|
|
171
|
+
this._emitter.emit(status);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
clearListeners(): void {
|
|
175
|
+
this._emitter.clear();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Latest-retained, fire-on-attach signal for `doc_drive_status`. Like
|
|
180
|
+
// `WorkStatusSignal`, but `emit` short-circuits when the new status
|
|
181
|
+
// deep-equals the retained one: drive status fans out on *every* METADATA row
|
|
182
|
+
// change (rename, thumbnail, edit), so the equality gate keeps `onDriveStatus`
|
|
183
|
+
// from re-firing when the tracking state didn't move.
|
|
184
|
+
export class DriveStatusSignal {
|
|
185
|
+
private readonly _emitter = createEmitter<DocumentDriveStatus>({
|
|
186
|
+
fireOnAttach: true,
|
|
187
|
+
retain: 'latest',
|
|
188
|
+
equals: driveStatusEqual,
|
|
189
|
+
onError: (err) => {
|
|
190
|
+
console.error('[Brass] onDriveStatus listener threw:', err);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
get status(): DocumentDriveStatus | null {
|
|
195
|
+
return this._emitter.retained ?? null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
on(cb: DocumentDriveStatusListener): DocumentDriveStatusUnsubscribe {
|
|
199
|
+
return this._emitter.on(cb);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
emit(status: DocumentDriveStatus): void {
|
|
203
|
+
this._emitter.emit(status);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
clearListeners(): void {
|
|
207
|
+
this._emitter.clear();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Field-by-field equality for the drive status. The shape is flat (all
|
|
212
|
+
// scalars), so a key compare suffices; explicit rather than JSON.stringify so
|
|
213
|
+
// a future non-scalar field forces a conscious update here.
|
|
214
|
+
function driveStatusEqual(
|
|
215
|
+
a: DocumentDriveStatus,
|
|
216
|
+
b: DocumentDriveStatus,
|
|
217
|
+
): boolean {
|
|
218
|
+
return (
|
|
219
|
+
a.driveType === b.driveType &&
|
|
220
|
+
a.driveName === b.driveName &&
|
|
221
|
+
a.state === b.state &&
|
|
222
|
+
a.canPush === b.canPush &&
|
|
223
|
+
a.label === b.label &&
|
|
224
|
+
a.detail === b.detail &&
|
|
225
|
+
a.broken === b.broken &&
|
|
226
|
+
a.pushReplacesRemote === b.pushReplacesRemote &&
|
|
227
|
+
a.mirrorDirection === b.mirrorDirection &&
|
|
228
|
+
a.ref === b.ref &&
|
|
229
|
+
a.refUpdatedAt === b.refUpdatedAt &&
|
|
230
|
+
a.publishing === b.publishing &&
|
|
231
|
+
a.itemName === b.itemName &&
|
|
232
|
+
a.itemUrl === b.itemUrl
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Per-stream scope of a Document. A document is a container of streams:
|
|
2
|
+
// named, independently-loadable CRDT units. `doc.stream(name)` resolves
|
|
3
|
+
// one and returns this wrapper; only the streams an app touches are
|
|
4
|
+
// downloaded, imported, and subscribed, so open cost scales with use,
|
|
5
|
+
// not with the whole document.
|
|
6
|
+
//
|
|
7
|
+
// Each non-default stream is backed by its own internal Document wired
|
|
8
|
+
// to the stream's line (its own snapshot + op channel); the wrapper
|
|
9
|
+
// exposes the per-stream subset of the Document surface. The `default`
|
|
10
|
+
// stream wraps the parent Document itself, so `doc.stream('default')`
|
|
11
|
+
// and the doc-level handles read the same state.
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
Document,
|
|
15
|
+
DocumentMutator,
|
|
16
|
+
DocumentSubscriber,
|
|
17
|
+
DocumentUnsubscribe,
|
|
18
|
+
EntityProxy,
|
|
19
|
+
} from './documents.js';
|
|
20
|
+
import type { DocumentStreamInfo } from './documents-namespace.js';
|
|
21
|
+
import type { ListHandle, MapHandle, OrderedMapHandle } from './handles.js';
|
|
22
|
+
|
|
23
|
+
// The wiring `Document.stream()` runs on: list/create manifests over
|
|
24
|
+
// HTTP, and open a stream's line (join + WS subscribe) returning the
|
|
25
|
+
// internal Document that holds its state. Installed by the SDK open
|
|
26
|
+
// factory; absent on offline / test docs, where stream methods throw
|
|
27
|
+
// with a clear message.
|
|
28
|
+
export interface StreamRuntime {
|
|
29
|
+
list(): Promise<DocumentStreamInfo[]>;
|
|
30
|
+
create(name: string): Promise<DocumentStreamInfo>;
|
|
31
|
+
openLine(lineId: string): Promise<Document>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class DocumentStream {
|
|
35
|
+
// The stream's name within the document ('default' for the document's
|
|
36
|
+
// own default state).
|
|
37
|
+
readonly name: string;
|
|
38
|
+
/** @internal The Document holding this stream's state. For a
|
|
39
|
+
* non-default stream that is its own line's Document; for `default` it
|
|
40
|
+
* is the parent itself, which is why `_ownsInner` gates the close. */
|
|
41
|
+
readonly _inner: Document;
|
|
42
|
+
// True when this wrapper owns its inner Document (a non-default
|
|
43
|
+
// stream): closing the parent closes it. The default stream's inner IS
|
|
44
|
+
// the parent, which owns its own lifecycle.
|
|
45
|
+
private readonly _ownsInner: boolean;
|
|
46
|
+
|
|
47
|
+
/** @internal */
|
|
48
|
+
constructor(name: string, inner: Document, ownsInner: boolean) {
|
|
49
|
+
this.name = name;
|
|
50
|
+
this._inner = inner;
|
|
51
|
+
this._ownsInner = ownsInner;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Typed container handles, scoped to this stream. Same surface and
|
|
55
|
+
// semantics as the doc-level `doc.map` / `doc.list` / `doc.orderedMap`.
|
|
56
|
+
map<T = unknown>(key: string): MapHandle<T> {
|
|
57
|
+
return this._inner.map<T>(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
list<T = unknown>(key: string): ListHandle<T> {
|
|
61
|
+
return this._inner.list<T>(key);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
orderedMap<T = unknown>(mapKey: string, orderKey: string): OrderedMapHandle<T> {
|
|
65
|
+
return this._inner.orderedMap<T>(mapKey, orderKey);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
mutate<E extends object = EntityProxy>(
|
|
69
|
+
fn: (d: DocumentMutator<E>) => void,
|
|
70
|
+
): void {
|
|
71
|
+
this._inner.mutate<E>(fn);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
toJSON(): Record<string, unknown> {
|
|
75
|
+
return this._inner.toJSON();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
subscribe(cb: DocumentSubscriber): DocumentUnsubscribe {
|
|
79
|
+
return this._inner.subscribe(cb);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Per-stream undo/redo: each stream keeps its own local-edit stack
|
|
83
|
+
// (its edits are its own CRDT commits). The doc-level `doc.undo()`
|
|
84
|
+
// covers only the default stream.
|
|
85
|
+
undo(): boolean {
|
|
86
|
+
return this._inner.undo();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
redo(): boolean {
|
|
90
|
+
return this._inner.redo();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Seal the stream's open undo group, so the next local edit starts a
|
|
94
|
+
// fresh undo step. Local and synchronous (no server call). Call it
|
|
95
|
+
// when a user operation completes: commits group into one step until
|
|
96
|
+
// sealed (the same same-stroke collapsing the doc-level undo has), so
|
|
97
|
+
// an app that never seals gets one giant step per session. The
|
|
98
|
+
// cross-stream operation recipe in `docs/streams.md` is built on this.
|
|
99
|
+
sealUndoStep(): void {
|
|
100
|
+
this._inner._sealUndoStep();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @internal The stream's live head seq (advanced by its own op channel),
|
|
104
|
+
* or `null` before the transport resolves it. Internal for the same
|
|
105
|
+
* reason the document's is, and a stream has even less use for one: it
|
|
106
|
+
* carries no checkpoint machinery to anchor. */
|
|
107
|
+
get currentSeq(): number | null {
|
|
108
|
+
return this._inner.currentSeq;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @internal Preview this stream at a historical line seq, or return it
|
|
112
|
+
* to its live head when `seq` is undefined. Driven by the parent
|
|
113
|
+
* Document's version preview so a partitioned document previews
|
|
114
|
+
* consistently across streams. A no-op when the inner has no checkout
|
|
115
|
+
* fetcher (a guest line, which holds no token to fetch at-version), so
|
|
116
|
+
* such a stream stays live. */
|
|
117
|
+
async _previewAt(seq: number | undefined): Promise<void> {
|
|
118
|
+
const inner = this._inner;
|
|
119
|
+
if (seq !== undefined && inner._hasCheckoutFetcher()) {
|
|
120
|
+
await inner._checkoutVersion(seq);
|
|
121
|
+
} else if (inner._isCheckedOut()) {
|
|
122
|
+
inner.returnToHead();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @internal Close the stream's own transport + state. Called by the
|
|
127
|
+
* parent Document's close; a no-op for the default stream, whose inner
|
|
128
|
+
* is the parent itself. */
|
|
129
|
+
_close(): void {
|
|
130
|
+
if (this._ownsInner) this._inner.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Document thumbnail auto-capture: screenshots a registered canvas after each
|
|
2
|
+
// local edit settles and uploads it as the thumbnail. One debounce timer, an
|
|
3
|
+
// injected uploader, and a one-shot WebGL diagnostic.
|
|
4
|
+
|
|
5
|
+
// A canvas the SDK screenshots for the thumbnail. A getter form is accepted
|
|
6
|
+
// for apps that build or rebuild the canvas lazily.
|
|
7
|
+
export type CanvasSource =
|
|
8
|
+
| HTMLCanvasElement
|
|
9
|
+
| (() => HTMLCanvasElement | null);
|
|
10
|
+
|
|
11
|
+
export type ThumbnailUploader = (
|
|
12
|
+
image: Blob,
|
|
13
|
+
width: number,
|
|
14
|
+
height: number,
|
|
15
|
+
) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
// Debounce window: a screenshot is taken this long after the last local edit,
|
|
18
|
+
// so a continuous drag uploads once when it settles, not once per frame.
|
|
19
|
+
const THUMBNAIL_CAPTURE_DEBOUNCE_MS = 1500;
|
|
20
|
+
|
|
21
|
+
// Longest-edge cap (device pixels) for an uploaded thumbnail: a generous
|
|
22
|
+
// product ceiling, an upper bound on the size any Brass surface sensibly
|
|
23
|
+
// paints a thumbnail at with retina headroom. The largest surface (the
|
|
24
|
+
// version-history timeline, ~200px) clears 2x DPR with room, while this still
|
|
25
|
+
// sheds the multi-megapixel frames a full-resolution 3D / map / CAD canvas
|
|
26
|
+
// would upload. Standalone (not derived from a CSS size) because the display
|
|
27
|
+
// sizes live in the frontend and this is a published SDK; a smaller
|
|
28
|
+
// per-surface derivative is a server-side concern.
|
|
29
|
+
const MAX_THUMBNAIL_CAPTURE_EDGE = 512;
|
|
30
|
+
|
|
31
|
+
// Cap the longest edge to `maxEdge`, preserving aspect ratio, never upscaling:
|
|
32
|
+
// a canvas within the cap is uploaded at native size (`scaled: false`), so the
|
|
33
|
+
// common small-canvas case skips the offscreen redraw. The returned dimensions
|
|
34
|
+
// are what the uploader reports, so they stay matched to the bytes.
|
|
35
|
+
export function fitWithinMaxEdge(
|
|
36
|
+
width: number,
|
|
37
|
+
height: number,
|
|
38
|
+
maxEdge: number,
|
|
39
|
+
): { width: number; height: number; scaled: boolean } {
|
|
40
|
+
const longest = Math.max(width, height);
|
|
41
|
+
if (!Number.isFinite(longest) || longest <= 0 || longest <= maxEdge) {
|
|
42
|
+
return { width, height, scaled: false };
|
|
43
|
+
}
|
|
44
|
+
const ratio = maxEdge / longest;
|
|
45
|
+
return {
|
|
46
|
+
width: Math.max(1, Math.round(width * ratio)),
|
|
47
|
+
height: Math.max(1, Math.round(height * ratio)),
|
|
48
|
+
scaled: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ThumbnailCaptureHost {
|
|
53
|
+
// True when a capture may run: the doc is live (not closed / suspended) and
|
|
54
|
+
// showing head (not a historical checkout), so a viewer or checkout never
|
|
55
|
+
// re-uploads a frame.
|
|
56
|
+
isCaptureAllowed(): boolean;
|
|
57
|
+
// True when the doc is still attached, re-checked after the async toBlob +
|
|
58
|
+
// upload boundary so a capture in flight when the doc closes does not upload.
|
|
59
|
+
isAttached(): boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ThumbnailCapture {
|
|
63
|
+
// Canvas registered via `useCanvas`; `null` for the common non-canvas app.
|
|
64
|
+
private _canvas: CanvasSource | null = null;
|
|
65
|
+
private _timer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
+
// The canvas already warned about `preserveDrawingBuffer: false`, so the
|
|
67
|
+
// warn fires once per canvas; reset on `useCanvas` for a re-register.
|
|
68
|
+
private _warnedFor: HTMLCanvasElement | null = null;
|
|
69
|
+
// Injected upload hook (the Document owns no HTTP client). `null` on the
|
|
70
|
+
// offline / test path, where the canvas is tracked but never captured, so
|
|
71
|
+
// apps can register one early without hitting the server in tests.
|
|
72
|
+
private _uploader: ThumbnailUploader | null = null;
|
|
73
|
+
|
|
74
|
+
constructor(private readonly host: ThumbnailCaptureHost) {}
|
|
75
|
+
|
|
76
|
+
// Register a canvas (element or lazy getter) to screenshot as the thumbnail.
|
|
77
|
+
// Returns an unregister handle; re-registering replaces. The caller
|
|
78
|
+
// (Document) arms its Loro subscriber so local commits reach `schedule()`.
|
|
79
|
+
useCanvas(source: CanvasSource): () => void {
|
|
80
|
+
this._canvas = source;
|
|
81
|
+
// A re-register gets one more chance to surface the missing-buffer warn.
|
|
82
|
+
this._warnedFor = null;
|
|
83
|
+
return () => {
|
|
84
|
+
if (this._canvas === source) {
|
|
85
|
+
this._canvas = null;
|
|
86
|
+
this.cancel();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setUploader(uploader: ThumbnailUploader | null): void {
|
|
92
|
+
this._uploader = uploader;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Called from the Document's local-commit dispatch; debounces so a burst of
|
|
96
|
+
// edits produces ONE upload when it settles.
|
|
97
|
+
schedule(): void {
|
|
98
|
+
if (this._canvas === null) return;
|
|
99
|
+
if (this._timer !== null) clearTimeout(this._timer);
|
|
100
|
+
this._timer = setTimeout(() => {
|
|
101
|
+
this._timer = null;
|
|
102
|
+
void this._capture();
|
|
103
|
+
}, THUMBNAIL_CAPTURE_DEBOUNCE_MS);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Cancel a pending capture (canvas unregistered / suspend / close).
|
|
107
|
+
cancel(): void {
|
|
108
|
+
if (this._timer !== null) {
|
|
109
|
+
clearTimeout(this._timer);
|
|
110
|
+
this._timer = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async _capture(): Promise<void> {
|
|
115
|
+
if (!this.host.isCaptureAllowed()) return;
|
|
116
|
+
if (this._canvas === null) return;
|
|
117
|
+
if (this._uploader === null) return;
|
|
118
|
+
const el =
|
|
119
|
+
typeof this._canvas === 'function' ? this._canvas() : this._canvas;
|
|
120
|
+
if (!el) return;
|
|
121
|
+
this._warnIfWebglContextWillProduceBlankCapture(el);
|
|
122
|
+
try {
|
|
123
|
+
const target = fitWithinMaxEdge(
|
|
124
|
+
el.width,
|
|
125
|
+
el.height,
|
|
126
|
+
MAX_THUMBNAIL_CAPTURE_EDGE,
|
|
127
|
+
);
|
|
128
|
+
const source = target.scaled ? this._downscale(el, target) : el;
|
|
129
|
+
const image = await new Promise<Blob>((resolve, reject) => {
|
|
130
|
+
source.toBlob((blob) => {
|
|
131
|
+
if (blob) resolve(blob);
|
|
132
|
+
else reject(new Error('thumbnail capture: canvas produced no image'));
|
|
133
|
+
}, 'image/png');
|
|
134
|
+
});
|
|
135
|
+
if (!this.host.isAttached()) return;
|
|
136
|
+
await this._uploader(image, target.width, target.height);
|
|
137
|
+
} catch {
|
|
138
|
+
// Best-effort: rate-limit rejection, lack of write access, or a
|
|
139
|
+
// transient failure is swallowed; the next local edit reschedules.
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Redraw an oversized canvas into an offscreen 2D canvas at the capped
|
|
144
|
+
// dimensions. `drawImage` reads the source's front buffer, so the WebGL
|
|
145
|
+
// `preserveDrawingBuffer` caveat above applies here too. If a 2D context
|
|
146
|
+
// can't be acquired, fall back to the full-resolution source: one oversized
|
|
147
|
+
// upload beats a missing thumbnail.
|
|
148
|
+
private _downscale(
|
|
149
|
+
el: HTMLCanvasElement,
|
|
150
|
+
target: { width: number; height: number },
|
|
151
|
+
): HTMLCanvasElement {
|
|
152
|
+
const off = document.createElement('canvas');
|
|
153
|
+
off.width = target.width;
|
|
154
|
+
off.height = target.height;
|
|
155
|
+
const ctx = off.getContext('2d');
|
|
156
|
+
if (!ctx) return el;
|
|
157
|
+
ctx.drawImage(el, 0, 0, target.width, target.height);
|
|
158
|
+
return off;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Warn once per canvas if its WebGL context lacks
|
|
162
|
+
// `preserveDrawingBuffer: true`: without it `toBlob` reads back a blank
|
|
163
|
+
// frame (the spec lets the browser discard the drawing buffer after
|
|
164
|
+
// compositing), so the upload silently stores a blank PNG with no
|
|
165
|
+
// diagnostic. Harmless on 2D / SVG canvases (no WebGL context to inspect).
|
|
166
|
+
private _warnIfWebglContextWillProduceBlankCapture(
|
|
167
|
+
el: HTMLCanvasElement,
|
|
168
|
+
): void {
|
|
169
|
+
if (this._warnedFor === el) return;
|
|
170
|
+
if (typeof el.getContext !== 'function') return;
|
|
171
|
+
let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
|
|
172
|
+
try {
|
|
173
|
+
gl =
|
|
174
|
+
(el.getContext('webgl2') as WebGL2RenderingContext | null) ??
|
|
175
|
+
(el.getContext('webgl') as WebGLRenderingContext | null);
|
|
176
|
+
} catch {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!gl) return;
|
|
180
|
+
const attributes = gl.getContextAttributes();
|
|
181
|
+
if (attributes && attributes.preserveDrawingBuffer === false) {
|
|
182
|
+
this._warnedFor = el;
|
|
183
|
+
console.warn(
|
|
184
|
+
'@brass-build/client: useCanvas captured a blank thumbnail because ' +
|
|
185
|
+
'the WebGL context was created with preserveDrawingBuffer: false. ' +
|
|
186
|
+
'Pass preserveDrawingBuffer: true when creating the WebGL context ' +
|
|
187
|
+
'(e.g., new THREE.WebGLRenderer({ preserveDrawingBuffer: true })).',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|