@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
@@ -0,0 +1,15 @@
1
+ // The typed way for in-repo tooling (the benches, the fuzz harness) to wrap a
2
+ // LoroDoc it already holds. It sits in a module of its own, outside what the
3
+ // package entry reaches, so it can name Loro's types while the SDK's public
4
+ // surface stays engine-neutral.
5
+
6
+ import type { LoroDoc } from 'loro-crdt';
7
+
8
+ import { Document, type DocumentOptions } from './documents.js';
9
+
10
+ export function documentFromEngine(
11
+ engine: LoroDoc,
12
+ options?: DocumentOptions,
13
+ ): Document {
14
+ return Document._fromEngine(engine, options);
15
+ }
@@ -9,7 +9,7 @@
9
9
  import { createEmitter } from './emitter.js';
10
10
 
11
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
12
+ // frame's `reason`. New literals land
13
13
  // here as the platform grows transitions that evict open subscribers.
14
14
  // `moved`: the doc changed organization or project, so its access gate
15
15
  // changed and open subscribers re-establish under the new location.
@@ -19,7 +19,7 @@ export type DocumentGoneListener = (reason: DocumentGoneReason) => void;
19
19
  export type DocumentGoneUnsubscribe = () => void;
20
20
 
21
21
  // Opaque status of slow server-side work (hydration, extraction, push).
22
- // Mirrors the wire shape (CONTRACT.md "Server-pushed `doc_status`"). The app
22
+ // Mirrors the wire shape of the server's status frame. The app
23
23
  // renders `message` verbatim, draws a bar from `progress`, and waits while
24
24
  // `pending`. No kind / phase / error code: the server owns all wording and can
25
25
  // change phases without any app change.
@@ -39,14 +39,14 @@ export type DocumentStatusListener = (status: DocumentWorkStatus) => void;
39
39
  export type DocumentStatusUnsubscribe = () => void;
40
40
 
41
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
42
+ // server's `tracking_state`. The
43
43
  // app reads `state` to decide which affordance to show, never to pick wording
44
44
  // (that is server-authored `label` / `detail`).
45
45
  export type DocumentDriveTrackingState =
46
46
  | 'unhydrated'
47
47
  | 'synced'
48
- | 'ahead'
49
48
  | 'behind'
49
+ | 'ahead'
50
50
  | 'diverged';
51
51
 
52
52
  // Which way the mirror flows. `two_way` links round-trip; `import_only` links
@@ -54,11 +54,11 @@ export type DocumentDriveTrackingState =
54
54
  // reads this to decide whether to show a Publish affordance at all.
55
55
  export type DriveMirrorDirection = 'two_way' | 'import_only';
56
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
57
+ // The drive sync state of an open document. Present only on drive-linked
58
+ // docs; `null` otherwise. Lets an app show "you have unpublished edits"
59
+ // without polling, while the hosted toolbar owns publishing itself. Words are
60
60
  // server-authored (`label` / `detail`, rendered verbatim); the app branches
61
- // only on `state` / `canPush` to decide whether to show an affordance.
61
+ // only on `state` / `canPush`.
62
62
  export interface DocumentDriveStatus {
63
63
  // Adapter type token; an app may key an icon off it. The human label is
64
64
  // `driveName`, so an app never maps the type to a vendor name itself.
@@ -99,6 +99,11 @@ export interface DocumentDriveStatus {
99
99
  // the Publish affordance rather than re-deriving it. `detail` carries the
100
100
  // server-authored progress line while it is `true`.
101
101
  publishing: boolean;
102
+ /** The drive's own words about the file the last publish delivered, set
103
+ * only where the drive answered that it could not read it. The publish
104
+ * succeeded and the artifact exists; the drive's reader rejects it, which
105
+ * a person otherwise meets as an error inside the drive. */
106
+ sourceUnreadable?: string;
102
107
  }
103
108
 
104
109
  export type DocumentDriveStatusListener = (
@@ -106,6 +111,32 @@ export type DocumentDriveStatusListener = (
106
111
  ) => void;
107
112
  export type DocumentDriveStatusUnsubscribe = () => void;
108
113
 
114
+ // Where a document that mirrors nothing can be published: the drive its
115
+ // project is linked to, which takes it as a NEW file. A drive-linked
116
+ // document has `driveStatus` and no targets; this is the state before the
117
+ // first publish, which is what a document added to a linked project (an
118
+ // upload, or one an app created there) sits in.
119
+ //
120
+ // Words are server-authored (`detail`, rendered verbatim), as everywhere
121
+ // else a drive is described.
122
+ export interface DrivePublishTarget {
123
+ // Adapter type token; an app may key an icon off it.
124
+ driveType: string;
125
+ // The drive's user-facing name.
126
+ driveName: string;
127
+ // What the linked container is called on the drive, so a surface can say
128
+ // where the file lands.
129
+ containerName: string;
130
+ // The sentence a publish affordance renders, verbatim.
131
+ detail: string;
132
+ // True while a publish is in flight, so the affordance holds rather than
133
+ // inviting a second artifact.
134
+ publishing: boolean;
135
+ // The last publish attempt failed, in which case `detail` carries the
136
+ // server's account of why rather than the resting sentence.
137
+ publishFailed: boolean;
138
+ }
139
+
109
140
  // Each signal is a `createEmitter` composed with the retain / attach / dedup
110
141
  // policy its wire event needs, wrapped in a small class only to keep the
111
142
  // domain-named methods (`mark`, `emit`, `status`, `clearListeners`) the
@@ -208,6 +239,86 @@ export class DriveStatusSignal {
208
239
  }
209
240
  }
210
241
 
242
+ // How the publish of a document that mirrors nothing is going. Server-
243
+ // authored: `detail` replaces the target's resting sentence while present,
244
+ // and is rendered verbatim.
245
+ export interface DrivePublishState {
246
+ // A publish is running on the server.
247
+ publishing: boolean;
248
+ // The last publish attempt failed; `detail` says what the server made of
249
+ // it.
250
+ failed: boolean;
251
+ // The sentence to render, or null to fall back to the target's resting
252
+ // copy.
253
+ detail: string | null;
254
+ }
255
+
256
+ const RESTING_PUBLISH: DrivePublishState = {
257
+ publishing: false,
258
+ failed: false,
259
+ detail: null,
260
+ };
261
+
262
+ // Where an unlinked document can be published, and how its publish is going.
263
+ //
264
+ // The TARGETS are seeded from the join alone, because the set only changes
265
+ // when the document becomes a mirror and the drive status arriving is that
266
+ // event, so the open factory clears them when one does. The STATE moves
267
+ // while the document is open: the first publish creates the artifact, so
268
+ // until it lands there is no drive status to report progress or failure
269
+ // through, and a `doc_drive_publish` frame carries it instead.
270
+ export class DrivePublishTargetSignal {
271
+ private _target: DrivePublishTarget | null = null;
272
+ private _state: DrivePublishState = RESTING_PUBLISH;
273
+ private readonly _listeners = new Set<() => void>();
274
+
275
+ get target(): DrivePublishTarget | null {
276
+ return this._target;
277
+ }
278
+
279
+ get state(): DrivePublishState {
280
+ return this._state;
281
+ }
282
+
283
+ set(target: DrivePublishTarget | null): void {
284
+ this._target = target;
285
+ // The join states the same thing the frames do, so a reopen starts from
286
+ // the server's current answer rather than from the last frame's.
287
+ this._state = {
288
+ publishing: target?.publishing === true,
289
+ failed: target?.publishFailed === true,
290
+ detail: null,
291
+ };
292
+ this._notify();
293
+ }
294
+
295
+ setState(state: DrivePublishState): void {
296
+ this._state = state;
297
+ this._notify();
298
+ }
299
+
300
+ onState(cb: () => void): () => void {
301
+ this._listeners.add(cb);
302
+ return () => {
303
+ this._listeners.delete(cb);
304
+ };
305
+ }
306
+
307
+ // Paired with the other signals' method of the same name: a parked or
308
+ // closed Document must not reach an app callback registered by a session
309
+ // that is over.
310
+ clearListeners(): void {
311
+ this._listeners.clear();
312
+ }
313
+
314
+ // Both mutators notify. A reader renders the targets and the state
315
+ // together, so a change to either is a change to what it draws, and one
316
+ // silent mutator would leave that reader showing the other half.
317
+ private _notify(): void {
318
+ for (const cb of this._listeners) cb();
319
+ }
320
+ }
321
+
211
322
  // Field-by-field equality for the drive status. The shape is flat (all
212
323
  // scalars), so a key compare suffices; explicit rather than JSON.stringify so
213
324
  // a future non-scalar field forces a conscious update here.
@@ -228,6 +339,7 @@ function driveStatusEqual(
228
339
  a.ref === b.ref &&
229
340
  a.refUpdatedAt === b.refUpdatedAt &&
230
341
  a.publishing === b.publishing &&
342
+ a.sourceUnreadable === b.sourceUnreadable &&
231
343
  a.itemName === b.itemName &&
232
344
  a.itemUrl === b.itemUrl
233
345
  );
@@ -18,7 +18,14 @@ import type {
18
18
  EntityProxy,
19
19
  } from './documents.js';
20
20
  import type { DocumentStreamInfo } from './documents-namespace.js';
21
- import type { ListHandle, MapHandle, OrderedMapHandle } from './handles.js';
21
+ import type {
22
+ DocumentGoneListener,
23
+ DocumentGoneUnsubscribe,
24
+ DocumentStatusListener,
25
+ DocumentStatusUnsubscribe,
26
+ DocumentWorkStatus,
27
+ } from './document-signals.js';
28
+ import type { ListHandle, MapHandle, OrderedMapHandle } from './handle-types.js';
22
29
 
23
30
  // The wiring `Document.stream()` runs on: list/create manifests over
24
31
  // HTTP, and open a stream's line (join + WS subscribe) returning the
@@ -79,6 +86,50 @@ export class DocumentStream {
79
86
  return this._inner.subscribe(cb);
80
87
  }
81
88
 
89
+ // Whether this stream's writes are landing, which is a property of its own
90
+ // line: its snapshot carries its own capacity verdict, its ops are its own,
91
+ // and its own grant decides whether it takes writes. `subscribe` fires when
92
+ // `savingCount` or `atCapacity` moves, as it does on the document.
93
+
94
+ // True when this stream refuses local writes: `mutate` throws and edit
95
+ // affordances should be disabled.
96
+ get readOnly(): boolean {
97
+ return this._inner.readOnly;
98
+ }
99
+
100
+ // Locally-issued ops on this stream the server has not acked yet. This
101
+ // stream's own line: `doc.savingCount` is the one that adds the streams up,
102
+ // and the default stream's inner IS the document, so reading that here would
103
+ // report every other stream's writes as this one's.
104
+ get savingCount(): number {
105
+ return this._inner._ownSavingCount;
106
+ }
107
+
108
+ // True when this stream has stopped accepting changes because it reached the
109
+ // largest size the platform can keep. The ceiling is per stream, so a
110
+ // document can hold one full stream and keep taking writes on the rest.
111
+ get atCapacity(): boolean {
112
+ return this._inner.atCapacity;
113
+ }
114
+
115
+ // The latest opaque work-status for this stream, or `null` when no slow
116
+ // server-side work is in flight. Render `message` verbatim.
117
+ get status(): DocumentWorkStatus | null {
118
+ return this._inner.status;
119
+ }
120
+
121
+ // Subscribe to this stream's work-status updates. Fires once on attach when
122
+ // a status is already known.
123
+ onStatus(cb: DocumentStatusListener): DocumentStatusUnsubscribe {
124
+ return this._inner.onStatus(cb);
125
+ }
126
+
127
+ // Fires once when the server reports this stream's state evicted. A late
128
+ // listener fires on registration if the eviction already happened.
129
+ onGone(cb: DocumentGoneListener): DocumentGoneUnsubscribe {
130
+ return this._inner.onGone(cb);
131
+ }
132
+
82
133
  // Per-stream undo/redo: each stream keeps its own local-edit stack
83
134
  // (its edits are its own CRDT commits). The doc-level `doc.undo()`
84
135
  // covers only the default stream.
@@ -109,18 +160,40 @@ export class DocumentStream {
109
160
  }
110
161
 
111
162
  /** @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> {
163
+ * to its live head. Driven by the parent Document's version preview so a
164
+ * partitioned document previews consistently across streams.
165
+ * `parentPreviewing` says which of the two a `seq`-less call means: the
166
+ * parent stepping into a version that records nothing for this line, or the
167
+ * parent returning to its head. A no-op when the inner has no checkout
168
+ * fetcher (a guest line, which holds no token to fetch at-version), so such
169
+ * a stream stays live; a guest line refuses writes anyway. */
170
+ async _previewAt(seq: number | undefined, parentPreviewing = false): Promise<void> {
118
171
  const inner = this._inner;
119
172
  if (seq !== undefined && inner._hasCheckoutFetcher()) {
120
173
  await inner._checkoutVersion(seq);
121
- } else if (inner._isCheckedOut()) {
122
- inner.returnToHead();
174
+ return;
175
+ }
176
+ if (seq === undefined && parentPreviewing) {
177
+ // The preview's checkpoint records no seq for this line, because the
178
+ // stream did not exist when it was taken. There is no historical state
179
+ // this stream can honestly show, and returning it to its head shows two
180
+ // moments at once: the document's past beside this stream's present.
181
+ // Worse, it would leave the stream taking writes while the document
182
+ // reports read-only, and those writes land on the live head and outlive
183
+ // the preview. Hold it read-only for as long as the parent is browsing.
184
+ inner._setVersionBrowsing(true);
185
+ return;
123
186
  }
187
+ inner._setVersionBrowsing(false);
188
+ // Returned whether or not a preview has LANDED on this line, because a
189
+ // checkout of it can be in flight and unapplied, and the return is what
190
+ // supersedes that fetch (it bumps the line's own generation before it
191
+ // looks at anything). Asking whether the line is checked out first is
192
+ // what lets an in-flight one land after the reader has already gone back
193
+ // to the head, leaving the stream showing a moment the document left.
194
+ // Throws on a line with no live head of its own, which the callers
195
+ // treat the same as any other per-stream failure.
196
+ inner.returnToHead();
124
197
  }
125
198
 
126
199
  /** @internal Close the stream's own transport + state. Called by the
@@ -129,4 +202,17 @@ export class DocumentStream {
129
202
  _close(): void {
130
203
  if (this._ownsInner) this._inner.close();
131
204
  }
205
+
206
+ /** @internal Park the stream's own state alongside the parent's park, so a
207
+ * stream an app holds across `doc.close()` stops calling it back and refuses
208
+ * writes the way the parent does. A no-op for the default stream, whose
209
+ * inner is the parent and is already parking. */
210
+ _suspend(): void {
211
+ if (this._ownsInner) this._inner._suspend();
212
+ }
213
+
214
+ /** @internal Pair of `_suspend`, run when the parent resumes. */
215
+ _resume(): void {
216
+ if (this._ownsInner) this._inner._resume();
217
+ }
132
218
  }
@@ -1,6 +1,14 @@
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.
1
+ // Document thumbnail auto-capture: screenshots a registered canvas and uploads
2
+ // it as the thumbnail.
3
+ //
4
+ // The edit trigger debounces a capture after each local commit, so a
5
+ // continuous drag uploads once when it settles. The seed trigger covers a
6
+ // document whose picture is older than its content: an import writes no local
7
+ // commit on the reader's client, and a document nobody has edited yet has none
8
+ // at all. It samples the canvas and uploads the frame it settles on, because
9
+ // the drawn-but-wrong empty editor is on screen from the first frame (measured
10
+ // on the deployed DXF editor: grid at ~0.4s, drawing 0.8s to 4.2s after the
11
+ // records land), so neither blankness nor a fixed delay separates the two.
4
12
 
5
13
  // A canvas the SDK screenshots for the thumbnail. A getter form is accepted
6
14
  // for apps that build or rebuild the canvas lazily.
@@ -18,6 +26,22 @@ export type ThumbnailUploader = (
18
26
  // so a continuous drag uploads once when it settles, not once per frame.
19
27
  const THUMBNAIL_CAPTURE_DEBOUNCE_MS = 1500;
20
28
 
29
+ // Seed watch: sample interval, equal samples that count as settled, give-up
30
+ // bound, and captures per watch. The window is a bound rather than a wait,
31
+ // since the watch ends as soon as it has what it needs; what it sizes is how
32
+ // long a document nothing has drawn yet stays worth sampling. A cold
33
+ // environment (first load of the bundle, first parse, a slow machine) has been
34
+ // measured taking over twenty seconds to draw a 150 KB DXF.
35
+ const SEED_SAMPLE_MS = 500;
36
+ const SEED_WINDOW_MS = 120_000;
37
+ const SEED_SETTLED_SAMPLES = 2;
38
+ const SEED_MAX_CAPTURES = 2;
39
+
40
+ // Edge of the square the fingerprint downsamples to. Small enough that a
41
+ // sample costs nothing on the render thread, large enough that a drawing does
42
+ // not average away into the background it sits on.
43
+ const FINGERPRINT_EDGE = 64;
44
+
21
45
  // Longest-edge cap (device pixels) for an uploaded thumbnail: a generous
22
46
  // product ceiling, an upper bound on the size any Brass surface sensibly
23
47
  // paints a thumbnail at with retina headroom. The largest surface (the
@@ -57,6 +81,9 @@ export interface ThumbnailCaptureHost {
57
81
  // True when the doc is still attached, re-checked after the async toBlob +
58
82
  // upload boundary so a capture in flight when the doc closes does not upload.
59
83
  isAttached(): boolean;
84
+ // Called once an upload lands, so the host can record which version of the
85
+ // document now has a picture.
86
+ onCaptured?(): void;
60
87
  }
61
88
 
62
89
  export class ThumbnailCapture {
@@ -70,6 +97,26 @@ export class ThumbnailCapture {
70
97
  // offline / test path, where the canvas is tracked but never captured, so
71
98
  // apps can register one early without hitting the server in tests.
72
99
  private _uploader: ThumbnailUploader | null = null;
100
+ // Seed-watch state: the interval, the fingerprint the watch will not upload,
101
+ // the last sample and how many times it has repeated, the sample budget, and
102
+ // how many frames the watch has captured.
103
+ private _seedTimer: ReturnType<typeof setInterval> | null = null;
104
+ private _seedHeld: string | null = null;
105
+ private _seedLast: string | null = null;
106
+ private _seedRepeats = 0;
107
+ private _seedSamplesLeft = 0;
108
+ private _seedCaptures = 0;
109
+ // A seed was owed while no canvas was registered, so an app that builds its
110
+ // renderer after it has the content still gets one.
111
+ private _seedOwed = false;
112
+ // Whether this instance has landed an upload. Decides whether an edit may
113
+ // supersede a seed watch, and nothing else.
114
+ private _uploaded = false;
115
+ // A failed upload has been reported, so a document the server keeps
116
+ // refusing warns once rather than once per edit.
117
+ private _warnedCaptureFailure = false;
118
+ // Detaches the page-lifecycle listeners while a canvas is registered.
119
+ private _detachLifecycle: (() => void) | null = null;
73
120
 
74
121
  constructor(private readonly host: ThumbnailCaptureHost) {}
75
122
 
@@ -80,9 +127,16 @@ export class ThumbnailCapture {
80
127
  this._canvas = source;
81
128
  // A re-register gets one more chance to surface the missing-buffer warn.
82
129
  this._warnedFor = null;
130
+ this._attachLifecycle();
131
+ if (this._seedOwed) {
132
+ this._seedOwed = false;
133
+ this.armSeed();
134
+ }
83
135
  return () => {
84
136
  if (this._canvas === source) {
85
137
  this._canvas = null;
138
+ this._detachLifecycle?.();
139
+ this._detachLifecycle = null;
86
140
  this.cancel();
87
141
  }
88
142
  };
@@ -96,6 +150,16 @@ export class ThumbnailCapture {
96
150
  // edits produces ONE upload when it settles.
97
151
  schedule(): void {
98
152
  if (this._canvas === null) return;
153
+ // An edit supersedes a seed watch once the document HAS a picture: the
154
+ // person is drawing, so the debounce owns it from here and a second upload
155
+ // of the same moment costs a second snapshot seal. Until then the watch
156
+ // runs on, because an edit is often the reader's last act before they
157
+ // leave, and cancelling on it hands the document's only chance at a
158
+ // picture to a debounce that has to outlive the tab.
159
+ if (this._uploaded) {
160
+ this._stopSeed();
161
+ this._seedOwed = false;
162
+ }
99
163
  if (this._timer !== null) clearTimeout(this._timer);
100
164
  this._timer = setTimeout(() => {
101
165
  this._timer = null;
@@ -103,20 +167,188 @@ export class ThumbnailCapture {
103
167
  }, THUMBNAIL_CAPTURE_DEBOUNCE_MS);
104
168
  }
105
169
 
170
+ // Called when the document's picture is older than its content: at open, and
171
+ // again whenever content arrives with no local commit behind it. Arming a
172
+ // running watch refreshes its budget, so a burst of arriving ops is one
173
+ // watch that outlives the burst.
174
+ armSeed(): void {
175
+ const el = this._resolveCanvas();
176
+ if (el === null) {
177
+ this._seedOwed = true;
178
+ return;
179
+ }
180
+ this._seedSamplesLeft = Math.ceil(SEED_WINDOW_MS / SEED_SAMPLE_MS);
181
+ if (this._seedTimer !== null) return;
182
+ this._seedHeld = null;
183
+ this._seedLast = null;
184
+ this._seedRepeats = 0;
185
+ this._seedCaptures = 0;
186
+ this._seedTimer = setInterval(() => this._sampleSeed(), SEED_SAMPLE_MS);
187
+ }
188
+
189
+ // A page going away is a deadline: take the frame now rather than waiting
190
+ // out a debounce the tab will not survive. `visibilitychange` covers the
191
+ // tab switch as well, which is the case with room for the upload to finish.
192
+ private _attachLifecycle(): void {
193
+ if (this._detachLifecycle !== null) return;
194
+ if (typeof document === 'undefined' || typeof window === 'undefined') {
195
+ return;
196
+ }
197
+ const onHidden = (): void => {
198
+ if (document.visibilityState === 'visible') return;
199
+ this._flushPending();
200
+ };
201
+ const onPageHide = (): void => this._flushPending();
202
+ document.addEventListener('visibilitychange', onHidden);
203
+ window.addEventListener('pagehide', onPageHide);
204
+ this._detachLifecycle = () => {
205
+ document.removeEventListener('visibilitychange', onHidden);
206
+ window.removeEventListener('pagehide', onPageHide);
207
+ };
208
+ }
209
+
210
+ private _flushPending(): void {
211
+ if (this._timer !== null) {
212
+ clearTimeout(this._timer);
213
+ this._timer = null;
214
+ void this._capture();
215
+ return;
216
+ }
217
+ // A watch holding a frame it has not uploaded takes it, for the same
218
+ // reason its own deadline does: this frame beats no frame.
219
+ if (
220
+ this._seedTimer !== null &&
221
+ this._seedLast !== null &&
222
+ this._seedLast !== this._seedHeld
223
+ ) {
224
+ this._takeSeed(this._seedLast);
225
+ }
226
+ }
227
+
106
228
  // Cancel a pending capture (canvas unregistered / suspend / close).
107
229
  cancel(): void {
108
230
  if (this._timer !== null) {
109
231
  clearTimeout(this._timer);
110
232
  this._timer = null;
111
233
  }
234
+ this._stopSeed();
235
+ }
236
+
237
+ // Release for good. The page-lifecycle listeners are held on `document` and
238
+ // `window` rather than on anything the Document owns, so cancelling the
239
+ // timers does not reach them: without this they outlive every closed
240
+ // document, each retaining its uploader closure and firing on every tab
241
+ // switch for the life of the page.
242
+ destroy(): void {
243
+ this.cancel();
244
+ this._detachLifecycle?.();
245
+ this._detachLifecycle = null;
246
+ this._canvas = null;
247
+ this._seedOwed = false;
248
+ }
249
+
250
+ private _stopSeed(): void {
251
+ if (this._seedTimer !== null) {
252
+ clearInterval(this._seedTimer);
253
+ this._seedTimer = null;
254
+ }
255
+ this._seedHeld = null;
256
+ this._seedLast = null;
257
+ this._seedRepeats = 0;
258
+ }
259
+
260
+ private _sampleSeed(): void {
261
+ if (!this.host.isCaptureAllowed()) {
262
+ this._stopSeed();
263
+ return;
264
+ }
265
+ const el = this._resolveCanvas();
266
+ if (el === null) {
267
+ this._stopSeed();
268
+ this._seedOwed = true;
269
+ return;
270
+ }
271
+ const now = this._fingerprint(el);
272
+ this._seedSamplesLeft -= 1;
273
+ // A canvas with no layout yet is not a frame, so it neither settles nor
274
+ // counts against the budget's meaning; the watch just waits for one.
275
+ if (now === null) {
276
+ if (this._seedSamplesLeft <= 0) this._stopSeed();
277
+ return;
278
+ }
279
+ if (now !== this._seedHeld) {
280
+ if (now === this._seedLast) this._seedRepeats += 1;
281
+ else this._seedRepeats = 0;
282
+ this._seedLast = now;
283
+ if (this._seedRepeats >= SEED_SETTLED_SAMPLES - 1) {
284
+ this._takeSeed(now);
285
+ return;
286
+ }
287
+ }
288
+ if (this._seedSamplesLeft <= 0) {
289
+ // Still redrawing at the deadline: the latest frame is a truer picture
290
+ // of the document than no picture, and a further wait has no end.
291
+ const moved = this._seedLast !== null && this._seedLast !== this._seedHeld;
292
+ if (moved) this._takeSeed(this._seedLast!);
293
+ else this._stopSeed();
294
+ }
295
+ }
296
+
297
+ // Upload a settled frame. The first one is a picture of the document, or of
298
+ // the editor about to draw it: an app that mounts its canvas before it has
299
+ // drawn holds an empty grid for as long as the drawing takes. So the watch
300
+ // stays on for a second settle, which lands at the same version and
301
+ // overwrites the first. A third is an app redrawing on its own.
302
+ private _takeSeed(frame: string): void {
303
+ this._seedHeld = frame;
304
+ this._seedRepeats = 0;
305
+ this._seedCaptures += 1;
306
+ if (this._seedCaptures >= SEED_MAX_CAPTURES || this._seedSamplesLeft <= 0) {
307
+ this._stopSeed();
308
+ }
309
+ void this._capture();
310
+ }
311
+
312
+ // A cheap perceptual fingerprint: the canvas downsampled into a small square
313
+ // and folded into a string. It reads through the same front buffer `toBlob`
314
+ // does, so a canvas the capture would read blank fingerprints blank too and
315
+ // the watch expires instead of uploading it.
316
+ private _fingerprint(el: HTMLCanvasElement): string | null {
317
+ // A canvas the page has not laid out yet (the browser's 1x1 default until
318
+ // the app sizes it) carries no frame to compare.
319
+ if (el.width <= 1 || el.height <= 1) return null;
320
+ try {
321
+ const off = document.createElement('canvas');
322
+ off.width = FINGERPRINT_EDGE;
323
+ off.height = FINGERPRINT_EDGE;
324
+ const ctx = off.getContext('2d', { willReadFrequently: true });
325
+ if (!ctx) return 'unreadable';
326
+ ctx.drawImage(el, 0, 0, FINGERPRINT_EDGE, FINGERPRINT_EDGE);
327
+ const { data } = ctx.getImageData(0, 0, FINGERPRINT_EDGE, FINGERPRINT_EDGE);
328
+ let h1 = 0x811c9dc5;
329
+ let h2 = 0x01000193;
330
+ for (let i = 0; i < data.length; i += 4) {
331
+ const px = (data[i]! << 24) | (data[i + 1]! << 16) | (data[i + 2]! << 8) | data[i + 3]!;
332
+ h1 = Math.imul(h1 ^ px, 0x01000193);
333
+ h2 = Math.imul(h2 + px, 0x85ebca6b) ^ (h2 >>> 13);
334
+ }
335
+ return `${h1 >>> 0}:${h2 >>> 0}`;
336
+ } catch {
337
+ // A tainted canvas throws here exactly as it would in the capture, so
338
+ // treating every read as the same value expires the watch.
339
+ return 'unreadable';
340
+ }
341
+ }
342
+
343
+ private _resolveCanvas(): HTMLCanvasElement | null {
344
+ if (this._canvas === null) return null;
345
+ return typeof this._canvas === 'function' ? this._canvas() : this._canvas;
112
346
  }
113
347
 
114
348
  private async _capture(): Promise<void> {
115
349
  if (!this.host.isCaptureAllowed()) return;
116
- if (this._canvas === null) return;
117
350
  if (this._uploader === null) return;
118
- const el =
119
- typeof this._canvas === 'function' ? this._canvas() : this._canvas;
351
+ const el = this._resolveCanvas();
120
352
  if (!el) return;
121
353
  this._warnIfWebglContextWillProduceBlankCapture(el);
122
354
  try {
@@ -134,9 +366,21 @@ export class ThumbnailCapture {
134
366
  });
135
367
  if (!this.host.isAttached()) return;
136
368
  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.
369
+ this._uploaded = true;
370
+ this.host.onCaptured?.();
371
+ } catch (err) {
372
+ // Best-effort: a rate-limit rejection, a lack of write access, or a
373
+ // transient failure leaves the document on its last picture, and the
374
+ // next edit or arrival reschedules. Reported once, because the refusals
375
+ // that persist (no write access, an oversized image) are otherwise
376
+ // indistinguishable from a capture that never ran.
377
+ if (!this._warnedCaptureFailure) {
378
+ this._warnedCaptureFailure = true;
379
+ console.warn(
380
+ '@brass-build/client: uploading the document thumbnail failed: ' +
381
+ (err instanceof Error ? err.message : String(err)),
382
+ );
383
+ }
140
384
  }
141
385
  }
142
386