@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.
Files changed (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
package/src/session.ts ADDED
@@ -0,0 +1,1262 @@
1
+ // `Brass.session`: the SDK-owned document session.
2
+ //
3
+ // The SDK owns the active-document lifecycle (open / create / pick / switch /
4
+ // version-preview / delete-reaction) and exposes a flat command list (New,
5
+ // Open, Version history, ..., plus app-registered commands); with a
6
+ // registered canvas it also auto-captures the thumbnail. The app renders the
7
+ // commands and draws from `session.document`. Every primitive the session
8
+ // calls stays public, so an app can ignore the session and wire them directly.
9
+
10
+ import type {
11
+ CanvasSource,
12
+ Command,
13
+ CommandGroup,
14
+ CommandSurface,
15
+ Document,
16
+ DocumentOptions,
17
+ } from './documents.js';
18
+ import type {
19
+ CreateDocumentArgs,
20
+ CreateDocumentOptions,
21
+ CreateDocumentResult,
22
+ OpenDocumentByIdOptions,
23
+ OpenedDocument,
24
+ } from './open-document.js';
25
+ import type {
26
+ CreateDestinationResult,
27
+ DocumentHistoryResult,
28
+ DocumentSettingsFocus,
29
+ DocumentSettingsResult,
30
+ SelectDocumentResult,
31
+ } from './hosted-ui.js';
32
+ import type { DocumentsNamespace } from './documents-namespace.js';
33
+ import type { UrlSelection } from './doc-url-sync.js';
34
+ import type { AuthStatus } from './types.js';
35
+ import { BrassError } from './errors.js';
36
+ import { makeLiveCommand } from './document-commands.js';
37
+
38
+ // A command in `session.commands`. The SDK owns each command's `run` /
39
+ // `enabled` / `running` plus a `surface` placement hint; the app places each
40
+ // into its own chrome. The session concatenates its orphan commands ('new' /
41
+ // 'open') with the active doc's commands.
42
+ export type {
43
+ CanvasSource,
44
+ Command,
45
+ CommandGroup,
46
+ CommandSurface,
47
+ } from './documents.js';
48
+
49
+ // Coarse lifecycle state: 'unknown' (auth not yet determined), 'signed-out',
50
+ // 'empty' (signed in, no document), 'loading' (open in flight), 'ready',
51
+ // 'error' (a user-initiated open terminally failed with nothing to fall back
52
+ // to). 'unknown' is distinct from 'signed-out' so a status-gated app renders a
53
+ // neutral state during boot instead of flashing the sign-in surface at a user
54
+ // who turns out to be signed in. 'error' is distinct from 'empty' so a
55
+ // document the user navigated to but cannot open (deleted, or an access the
56
+ // server collapses to 404 to avoid disclosing existence) renders as an honest
57
+ // failure rather than the app's document-less home surface.
58
+ export type SessionStatus =
59
+ | 'unknown'
60
+ | 'signed-out'
61
+ | 'empty'
62
+ | 'loading'
63
+ | 'ready'
64
+ | 'error';
65
+
66
+ export interface SessionOpenOptions {
67
+ // Open a specific branch instead of the document's default.
68
+ branch?: string;
69
+ // Open a historical version read-only (`document.readOnly` is true).
70
+ version?: number;
71
+ // Forwarded to the Document materialization (e.g. the undo merge
72
+ // interval). Matches `documentOptions` on `OpenDocumentByIdOptions` /
73
+ // `CreateDocumentOptions`.
74
+ documentOptions?: DocumentOptions;
75
+ }
76
+
77
+ // The document session. Reached via `Brass.session`; a process-wide singleton.
78
+ export interface DocumentSession {
79
+ // The active document, or null when none is open.
80
+ readonly document: Document | null;
81
+ // The active document's display name, or null when none / not yet resolved.
82
+ readonly documentName: string | null;
83
+ // Coarse lifecycle state (see `SessionStatus`).
84
+ readonly status: SessionStatus;
85
+ // The failure behind `status === 'error'`, else null. Set when a
86
+ // user-initiated open (the URL's `?doc=` on boot, `open()`, `pick()`, a
87
+ // Back / Forward to a doc) terminally fails with no document to fall back
88
+ // to, so a status-gated app can render the failure instead of its empty
89
+ // state. `.message` is server-authored, non-disclosing copy meant to be
90
+ // rendered verbatim (the server collapses a no-access document to a `404`
91
+ // indistinguishable from a genuinely missing one, so the platform can
92
+ // evolve the wording without any app change); an app supplies its own
93
+ // recovery affordances around it and does not author the copy. `.status`
94
+ // is there for logging or an override the app absolutely needs. Cleared on
95
+ // the next open / create / close.
96
+ readonly error: BrassError | null;
97
+ // While `status` is `'loading'`, a progress message for the open in flight
98
+ // (e.g. `Downloading... 62%`), or null. Populated as a presigned snapshot
99
+ // streams in BEFORE the Document exists, so an app shows download progress
100
+ // while `document` is still null. (Once bound, `doc.status` carries
101
+ // server-side work progress.)
102
+ readonly loadingMessage: string | null;
103
+ // The open's progress as 0..1 when a total is known, else null. Pairs with
104
+ // `loadingMessage`.
105
+ readonly loadingProgress: number | null;
106
+ // The commands to render. SDK-owned; re-read on every notification.
107
+ readonly commands: readonly Command[];
108
+ // The active document's presence client, or null. A live proxy for
109
+ // `document?.presence` at session scope so pointer / camera / selection
110
+ // handlers publish without threading a doc reference; reads the active doc
111
+ // at call time, so it works across switches. Null on a read-only doc, a doc
112
+ // opened without `wsBaseUrl`, and when none is open.
113
+ readonly presence: import('./presence.js').PresenceClient | null;
114
+ // Subscribe to lifecycle + command-state changes. Fires immediately, then on
115
+ // every change to `document` / `status` / `commands`. Does NOT fire on every
116
+ // document mutation; subscribe to the `Document` for that.
117
+ subscribe(listener: (session: DocumentSession) => void): () => void;
118
+ // Restore on boot: open the document named by the URL (or last picked) if
119
+ // any, else the empty state. Safe before sign-in (resolves null). Also runs
120
+ // automatically on first sign-in.
121
+ start(): Promise<Document | null>;
122
+ // Open a document by id and make it active.
123
+ open(docId: string, options?: SessionOpenOptions): Promise<Document>;
124
+ // Create a document and make it active. Create-in-place: with no explicit
125
+ // destination it lands as a sibling of the active one. A cold create (no
126
+ // active doc) in a single-org caller lands loose; a multi-org caller is
127
+ // asked via the hosted destination dialog, resolving null on dismiss.
128
+ create(options?: CreateDocumentOptions): Promise<Document | null>;
129
+ // Open the hosted picker and make the picked document active; null on
130
+ // dismiss.
131
+ pick(): Promise<Document | null>;
132
+ // Close the active document and return to the empty state.
133
+ close(): void;
134
+ // Invoke a command by id (e.g. `Ctrl-Z` -> `session.run('undo')`). No-op for
135
+ // an unknown id.
136
+ run(commandId: string): Promise<void>;
137
+ // Register the app's canvas so the SDK auto-captures the thumbnail:
138
+ // debounced after each local edit, skipped for read-only docs and remote
139
+ // edits. Returns an unregister function.
140
+ useCanvas(canvas: CanvasSource): () => void;
141
+ // Resolves with the bound `OpenedDocument` once `status` is `ready`
142
+ // (immediately if already bound). Rejects only on terminal failure (closed
143
+ // or signed out before a document bound); never hangs. Removes the
144
+ // hand-rolled "wait for binding" race every session-backed app would write.
145
+ whenReady(): Promise<OpenedDocument>;
146
+ // Default `documentOptions` applied to every doc the session opens whose
147
+ // caller did not pass its own; caller values win per-field, the default
148
+ // fills gaps. Set once at boot for app-wide knobs (e.g.
149
+ // `{ undo: { maxUndoSteps: 500 } }`).
150
+ setDefaultDocumentOptions(options: DocumentOptions | null): void;
151
+ /** @internal The configured defaults, read by `withDefaults` through the
152
+ * singleton. */
153
+ readonly _defaultDocumentOptions: DocumentOptions | null;
154
+ /** @internal Record that the local user is deleting `docId` through the
155
+ * hosted share dialog, so the incoming `doc_gone` is treated as a local
156
+ * delete (return to the empty state) rather than a remote eviction. Called
157
+ * by the client when the hosted delete surface signals over the bridge. */
158
+ _noteLocalDelete(docId: string): void;
159
+ /** @internal Retract a note recorded by `_noteLocalDelete` when the hosted
160
+ * delete request failed, so a later remote delete of the same doc is not
161
+ * misread as local. */
162
+ _clearLocalDelete(docId: string): void;
163
+ }
164
+
165
+ // Dependency seam. The session is a pure coordinator over the public SDK
166
+ // surface; injecting these keeps it unit-testable and breaks the import cycle.
167
+ export interface DocumentSessionDeps {
168
+ isSignedIn(): boolean;
169
+ // The tri-state auth lifecycle, so `status()` reports `'unknown'` during
170
+ // boot rather than conflating "not checked yet" with `'signed-out'`.
171
+ authStatus(): AuthStatus;
172
+ onUserChange(listener: (signedIn: boolean) => void): () => void;
173
+ // `consent: 'skip'` suppresses the install-consent dialog the client
174
+ // offers on an `app_not_installed` denial. Background opens (boot
175
+ // restore, history navigation) pass it: a dialog nobody asked for must
176
+ // not pop over the app. Explicit opens (picker result, `open()`) leave
177
+ // the default so the person gets the consent moment.
178
+ openDocumentById(
179
+ docId: string,
180
+ options?: OpenDocumentByIdOptions,
181
+ consent?: 'offer' | 'skip',
182
+ ): Promise<OpenedDocument>;
183
+ // Create-only (no open). The session drives the reactive picker / acquire
184
+ // dance itself so those surfaces open over the empty state, then opens the
185
+ // returned id. Placement is expressed by context (a `siblingOf` doc, the
186
+ // picker's opaque `destination`), never an org the session names.
187
+ createDocumentId(args: CreateDocumentArgs): Promise<CreateDocumentResult>;
188
+ selectDocument(): Promise<SelectDocumentResult | null>;
189
+ // The hosted destination dialog for an ambiguous cold create. Resolves an
190
+ // opaque destination + name; the session relays the destination to the
191
+ // create verbatim.
192
+ selectCreateDestination(options?: {
193
+ name?: string;
194
+ }): Promise<CreateDestinationResult | null>;
195
+ // The hosted acquire-organization surface, opened for a cold create by a
196
+ // caller who belongs to no organization yet.
197
+ acquireOrganization(): Promise<{ organization_id: string } | null>;
198
+ documentHistory(
199
+ docId: string,
200
+ options?: { branch?: string },
201
+ ): Promise<DocumentHistoryResult | null>;
202
+ documentSettings(
203
+ docId: string,
204
+ options?: { focus?: DocumentSettingsFocus },
205
+ ): Promise<DocumentSettingsResult | null>;
206
+ // Active-selection URL sync (no-op when the app opted out of
207
+ // `syncDocToUrl`). `readUrlSelection` returns the URL's doc + branch on boot
208
+ // / `popstate`; `writeUrlSelection` pins or pushes; `clearUrlSelection`
209
+ // drops; `onUrlPopState` subscribes to Back / Forward.
210
+ readUrlSelection(): UrlSelection | null;
211
+ // Read and consume the hosted picker's one-shot pick from the URL (the
212
+ // redirect fallback's return channel), or null on an ordinary boot. Runs
213
+ // regardless of `syncDocToUrl`, so a picked document opens even when the
214
+ // app manages its own selection.
215
+ takePickSelection(): UrlSelection | null;
216
+ writeUrlSelection(
217
+ selection: UrlSelection,
218
+ options: { push: boolean },
219
+ ): void;
220
+ clearUrlSelection(): void;
221
+ onUrlPopState(listener: () => void): () => void;
222
+ // Set the page title to the active document's name, or null to restore the
223
+ // original title (no-op when opted out of `syncDocToTitle`). The client
224
+ // suffixes it with the app name (`Doc · App`).
225
+ setPageTitle(name: string | null): void;
226
+ lastSelectedDocument(): SelectDocumentResult | null;
227
+ documents: Pick<DocumentsNamespace, 'get' | 'setThumbnail'>;
228
+ reportError(error: Error): void;
229
+ // Anonymous "anyone with the link" boot. When the page was opened via a
230
+ // public-link token (`?brass_link=`), `guestLinkToken` returns it and the
231
+ // session boots by opening that document read-only through
232
+ // `openDocumentByLink` instead of the signed-in URL / last-selected
233
+ // restore. Absent (returns null) in the ordinary signed-in case.
234
+ guestLinkToken?(): string | null;
235
+ openDocumentByLink?(
236
+ token: string,
237
+ options?: OpenDocumentByIdOptions,
238
+ ): Promise<OpenedDocument>;
239
+ }
240
+
241
+ // Built-in orphan command labels (no active doc needed). Sentence case, ASCII
242
+ // ellipsis, per the copy-style rules. Both stay bare: an app's own word for
243
+ // what it holds is rarely "document" (a sketch, a model, a sheet), so a
244
+ // label naming it would read wrong in most of the apps this menu mounts in.
245
+ const SESSION_LABELS = {
246
+ new: 'New',
247
+ open: 'Open...',
248
+ } as const;
249
+
250
+ function toError(err: unknown): Error {
251
+ return err instanceof Error ? err : new Error(String(err));
252
+ }
253
+
254
+ // Fallback copy for a failed open with no server message to render (a
255
+ // network drop, no HTTP response). The HTTP case carries the server's own
256
+ // message instead, so the platform evolves the wording without an app change.
257
+ const OPEN_FAILED_MESSAGE = 'This document could not be opened';
258
+
259
+ // Copy parked on `session.error` when the open document is evicted remotely
260
+ // (a `doc_gone` the local user did not initiate). Matches the wording a
261
+ // post-eviction op frame degrades to ("the document is no longer available"),
262
+ // so a delete observed live and a fresh visit to the same deleted id read the
263
+ // same. A reload re-opens the id, hits the server 404, and swaps in the
264
+ // server-authored copy.
265
+ const DOCUMENT_GONE_MESSAGE = 'This document is no longer available';
266
+
267
+ // The error parked on `session.error` for a failed open, shaped so its
268
+ // `.message` is always presentable copy an app can render verbatim. An HTTP
269
+ // failure is returned as-is: its `.message` is the server's (non-disclosing,
270
+ // server-authored) copy, which is the whole point of rendering it verbatim.
271
+ // A non-HTTP failure has no server copy, so it falls back to a generic
272
+ // display string rather than leaking a raw `TypeError: Failed to fetch`.
273
+ function toDisplayError(err: unknown): BrassError {
274
+ if (err instanceof BrassError && typeof err.status === 'number' && err.status > 0) {
275
+ return err;
276
+ }
277
+ const status = err instanceof BrassError ? err.status ?? 0 : 0;
278
+ return new BrassError(OPEN_FAILED_MESSAGE, { status });
279
+ }
280
+
281
+ // How a bound selection reflects in the URL. 'push' records a navigation
282
+ // (back-navigable entry), 'replace' pins without an entry (boot restore,
283
+ // failed-open rebind), 'skip' leaves the URL alone (Back / Forward restore,
284
+ // already at the target).
285
+ type UrlNav = 'push' | 'replace' | 'skip';
286
+
287
+ export function createDocumentSession(
288
+ deps: DocumentSessionDeps,
289
+ ): DocumentSession {
290
+ let document: Document | null = null;
291
+ let documentName: string | null = null;
292
+ let loading = false;
293
+ // Progress for an open in flight (a presigned snapshot streaming in),
294
+ // surfaced via `session.loadingMessage` / `loadingProgress` while
295
+ // `status` is 'loading' and before the Document exists.
296
+ let loadingMessage: string | null = null;
297
+ let loadingProgress: number | null = null;
298
+ // Bumped on every open / create / close so a slower in-flight open
299
+ // cannot clobber a newer one (last call wins).
300
+ let openGeneration = 0;
301
+ // The failure that put the session in the 'error' state, or null. A new
302
+ // open attempt clears it (at the generation bump in `activate`), so it only
303
+ // survives while no newer open supersedes the one that failed.
304
+ let openError: BrassError | null = null;
305
+ // Dedups concurrent `start()` calls (an app's explicit boot call
306
+ // racing the auto-start the sign-in handler fires).
307
+ let startPromise: Promise<Document | null> | null = null;
308
+ // Signature of the last broadcast state; a notification is sent only when it
309
+ // changes, so a per-commit `doc.subscribe` that doesn't move a command's
310
+ // enabled state stays quiet.
311
+ let lastSignature: string | null = null;
312
+ // Outstanding `whenReady()` promises, settled in `emit()`: resolve once a
313
+ // document binds, reject if the user signs out before one ever does.
314
+ const whenReadyPending: Array<{
315
+ resolve: (document: OpenedDocument) => void;
316
+ reject: (error: Error) => void;
317
+ }> = [];
318
+
319
+ const listeners = new Set<(session: DocumentSession) => void>();
320
+ // Per-built-in `running` flag for orphan commands.
321
+ const orphanRunning = new Map<string, boolean>();
322
+ let canvas: CanvasSource | null = null;
323
+ // Per-doc useCanvas registration the session armed (so the session
324
+ // can detach it when the active doc swaps or useCanvas is cleared).
325
+ let sessionCanvasUnregister: (() => void) | null = null;
326
+ let defaultDocumentOptions: DocumentOptions | null = null;
327
+ // The branch the active open explicitly asked for, or null. Decides the URL
328
+ // branch: the default branch (no-branch-specified) is never pinned, so
329
+ // `?branch=` appears only for a chosen branch. `defaultBranchId` then
330
+ // refines it (a specified branch that turns out to be the default is
331
+ // dropped once known).
332
+ let activeSpecifiedBranch: string | null = null;
333
+
334
+ // Subscriptions onto the active document, dropped when it changes.
335
+ let docUnsub: (() => void) | null = null;
336
+ let goneUnsub: (() => void) | null = null;
337
+
338
+ // Doc ids the local user is deleting through the hosted share dialog. The
339
+ // dialog signals over the SDK bridge BEFORE its delete request goes out, so
340
+ // an id lands here ahead of the server's `doc_gone` frame (fanned out before
341
+ // the delete's 204). A `doc_gone` for an id in this set is the user's own
342
+ // delete: return to the empty state and clear the URL. Any other `doc_gone`
343
+ // is a remote eviction: keep the URL and park the 'error' surface. A failed
344
+ // delete retracts its id, so a later remote delete of the same doc is not
345
+ // misread as local.
346
+ const localDeletes = new Set<string>();
347
+
348
+ function status(): SessionStatus {
349
+ // 'unknown' before auth settles; the onUserChange handler re-fires
350
+ // (start() / emit()) and flips this to its resolved value.
351
+ if (deps.authStatus() === 'unknown') return 'unknown';
352
+ // A guest (opened via `?brass_link=`) is not signed in but still has a
353
+ // read-only document to load, so it never reports 'signed-out' (which
354
+ // would render the app's sign-in surface instead of the document).
355
+ const isGuest = deps.guestLinkToken?.() != null;
356
+ if (!deps.isSignedIn() && !isGuest) return 'signed-out';
357
+ if (loading) return 'loading';
358
+ if (document) return 'ready';
359
+ if (openError) return 'error';
360
+ return 'empty';
361
+ }
362
+
363
+ function commandSig(c: Command): string {
364
+ let s = `${c.id}:${c.enabled ? 1 : 0}:${c.running ? 1 : 0}:${c.label}`;
365
+ if (c.selected) s += ':S';
366
+ if (c.children) s += `:[${c.children.map(commandSig).join(',')}]`;
367
+ return s;
368
+ }
369
+
370
+ function signature(): string {
371
+ const cmds = allCommands().map(commandSig).join(',');
372
+ // `loadingMessage` is in the signature so a download-progress tick
373
+ // re-fires subscribers; it is null outside an in-flight open.
374
+ const load = loading ? `${loadingMessage ?? ''}:${loadingProgress ?? ''}` : '';
375
+ // `openError` message joins the signature so a switch from one failed open
376
+ // to a different one (same 'error' status, no bound doc) still re-fires.
377
+ const err = openError ? `${openError.status ?? ''}:${openError.message}` : '';
378
+ return `${status()}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${cmds}`;
379
+ }
380
+
381
+ function emit(): void {
382
+ const sig = signature();
383
+ if (sig === lastSignature) return;
384
+ lastSignature = sig;
385
+ // Settle pending `whenReady()` promises BEFORE notifying, so an app whose
386
+ // subscribe handler awaits whenReady sees it already resolved by the time
387
+ // its listener runs.
388
+ settleWhenReady();
389
+ for (const listener of listeners) {
390
+ try {
391
+ listener(session);
392
+ } catch (err) {
393
+ // A throwing listener must not strand the others.
394
+ console.error('[Brass] session listener threw:', err);
395
+ }
396
+ }
397
+ }
398
+
399
+ function settleWhenReady(): void {
400
+ if (whenReadyPending.length === 0) return;
401
+ const current = status();
402
+ if (current === 'ready' && document) {
403
+ const doc = document as OpenedDocument;
404
+ const pending = whenReadyPending.splice(0, whenReadyPending.length);
405
+ for (const { resolve } of pending) resolve(doc);
406
+ } else if (current === 'signed-out') {
407
+ const error = new Error(
408
+ 'Brass.session.whenReady: signed out before a document was bound.',
409
+ );
410
+ const pending = whenReadyPending.splice(0, whenReadyPending.length);
411
+ for (const { reject } of pending) reject(error);
412
+ } else if (current === 'error' && openError) {
413
+ // A user-initiated open failed terminally; reject rather than hang the
414
+ // awaiter forever. Same error object the session surfaces on `.error`.
415
+ const pending = whenReadyPending.splice(0, whenReadyPending.length);
416
+ for (const { reject } of pending) reject(openError);
417
+ }
418
+ // No branch for 'empty': it is both the idle pre-open state (an awaiter
419
+ // legitimately waits for a future open) and the post-close state. A close
420
+ // that must reject its awaiters does so explicitly via
421
+ // `rejectWhenReadyPending`, so we do not blanket-reject on 'empty' here.
422
+ }
423
+
424
+ // Reject every outstanding `whenReady()` awaiter. Used by `close()`, whose
425
+ // 'empty' target has no `settleWhenReady` branch: an open in flight when the
426
+ // app closes never binds, so without this the promise hangs forever,
427
+ // violating the documented "never hangs" contract.
428
+ function rejectWhenReadyPending(message: string): void {
429
+ if (whenReadyPending.length === 0) return;
430
+ const error = new Error(message);
431
+ const pending = whenReadyPending.splice(0, whenReadyPending.length);
432
+ for (const { reject } of pending) reject(error);
433
+ }
434
+
435
+ // Build a session-level (orphan) command. Same shape as Document's
436
+ // built-ins but runs against session state directly ('new' / 'open' need no
437
+ // active doc), with a session `emit` on each running flip.
438
+ function buildOrphanCommand(
439
+ id: string,
440
+ label: string,
441
+ group: CommandGroup,
442
+ surface: CommandSurface,
443
+ isEnabled: () => boolean,
444
+ impl: () => Promise<void> | void,
445
+ ): Command {
446
+ return makeLiveCommand(
447
+ { id, label, group, surface, isEnabled, impl },
448
+ {
449
+ running: orphanRunning,
450
+ onError: (err) => deps.reportError(toError(err)),
451
+ notify: emit,
452
+ },
453
+ );
454
+ }
455
+
456
+ const cmdNew = buildOrphanCommand(
457
+ 'new',
458
+ SESSION_LABELS.new,
459
+ 'session',
460
+ 'menu',
461
+ deps.isSignedIn,
462
+ async () => {
463
+ await create();
464
+ },
465
+ );
466
+ const cmdOpen = buildOrphanCommand(
467
+ 'open',
468
+ SESSION_LABELS.open,
469
+ 'session',
470
+ 'menu',
471
+ deps.isSignedIn,
472
+ async () => {
473
+ await pick();
474
+ },
475
+ );
476
+
477
+ const orphanCommands: readonly Command[] = [cmdNew, cmdOpen];
478
+
479
+ // Concatenate orphan commands with the active doc's commands (whose
480
+ // instances carry their own `running` state; the session re-emits on the
481
+ // doc's `'commands'` synthetic event).
482
+ function allCommands(): Command[] {
483
+ const list: Command[] = [...orphanCommands];
484
+ if (document) {
485
+ for (const cmd of document.commands) list.push(cmd);
486
+ }
487
+ return list;
488
+ }
489
+
490
+ // Swap the active document. Closes the previous one (so its WS subscription
491
+ // does not leak), drops the per-doc subscriptions, and attaches fresh ones:
492
+ // `doc.subscribe` so undo/redo enabled state tracks the live stack, and
493
+ // `doc.onGone` so a server-side delete tears the session down.
494
+ function setDocument(
495
+ next: Document | null,
496
+ name: string | null,
497
+ urlNav: UrlNav = 'skip',
498
+ ): void {
499
+ if (docUnsub) {
500
+ docUnsub();
501
+ docUnsub = null;
502
+ }
503
+ if (goneUnsub) {
504
+ goneUnsub();
505
+ goneUnsub = null;
506
+ }
507
+ // Detach the previous doc's session-armed useCanvas; re-armed on `next`
508
+ // below if a session-level canvas is still registered.
509
+ if (sessionCanvasUnregister) {
510
+ sessionCanvasUnregister();
511
+ sessionCanvasUnregister = null;
512
+ }
513
+ const previous = document;
514
+ document = next;
515
+ documentName = name;
516
+ if (next === null) {
517
+ activeSpecifiedBranch = null;
518
+ }
519
+ if (previous && previous !== next) {
520
+ try {
521
+ previous.close();
522
+ } catch {
523
+ // A close failure (already-closed, freed WASM) is non-fatal.
524
+ }
525
+ }
526
+ if (next) {
527
+ // `subscribe` fires on every change including local mutations (the
528
+ // session's listeners drive UI that re-renders on local edits). The
529
+ // doc's `'commands'` / `'branches'` synthetic events also flow through,
530
+ // so the session re-emits on command-state changes and resolves the
531
+ // default branch for the URL.
532
+ docUnsub = next.subscribe((event) => {
533
+ // A `branches` event resolves `defaultBranchId`, which `urlBranch`
534
+ // needs to drop a specified-but-default branch; re-sync in place so
535
+ // the pinned branch settles once the default is known.
536
+ const kind = (event as { kind?: string } | undefined)?.kind;
537
+ if (kind === 'branches') syncUrlForActive('replace');
538
+ // Take the doc's name whenever it moves. A rename reaches the doc
539
+ // from the server's `doc_meta` frame, which sets it directly, and
540
+ // `documentName` is what the emit signature reads: without this the
541
+ // signature is unchanged, `emit` short-circuits, and chrome
542
+ // rendering the name (the toolbar title) keeps the old one until
543
+ // something unrelated perturbs the signature.
544
+ if (next.name !== null) documentName = next.name;
545
+ updatePageTitle();
546
+ emit();
547
+ });
548
+ goneUnsub = next.onGone((reason) => {
549
+ if (document !== next) return;
550
+ const id = next.id;
551
+ if (id && localDeletes.delete(id)) {
552
+ // The user deleted this document through the hosted share dialog;
553
+ // return to the empty state and clear the URL.
554
+ close();
555
+ return;
556
+ }
557
+ if (reason === 'moved' && id) {
558
+ // A move keeps the same doc id: a cross-org/project re-home
559
+ // (`patchDocument`), a project merge, or the soft-delete move path
560
+ // all fan `doc_gone reason:'moved'` to evict the open editor, but the
561
+ // document still exists under a new location. Re-open the same id so
562
+ // the editor follows the move instead of showing a false "no longer
563
+ // available"; fall back to the gone surface only if the re-open fails
564
+ // (access was genuinely lost in the move). Detach the moved doc first
565
+ // so that failure parks the gone surface rather than rebinding a doc
566
+ // that is itself gone.
567
+ const branch = urlBranch(next);
568
+ document = null;
569
+ documentName = null;
570
+ void activate(
571
+ (onSnapshotProgress) =>
572
+ deps.openDocumentById(
573
+ id,
574
+ {
575
+ ...withDefaults(),
576
+ ...(branch ? { branch } : {}),
577
+ onSnapshotProgress,
578
+ },
579
+ 'skip',
580
+ ),
581
+ null,
582
+ 'skip',
583
+ branch,
584
+ ).catch(() => markGoneRemote());
585
+ return;
586
+ }
587
+ // Evicted from under the viewer (remote delete, or a future eviction
588
+ // reason). Keep the URL and park an 'error' surface, so it reads the
589
+ // same as visiting the deleted id fresh rather than silently emptying.
590
+ markGoneRemote();
591
+ });
592
+ // Re-arm the session-level canvas (if any) onto the new doc; capture
593
+ // itself lives inside the Document.
594
+ if (canvas !== null) {
595
+ sessionCanvasUnregister = next.useCanvas(canvas);
596
+ }
597
+ // Prefer the caller-supplied name, else read the doc's own `name`.
598
+ if (name !== null) next._setName(name);
599
+ else documentName = next.name;
600
+ }
601
+ syncUrlForActive(urlNav);
602
+ updatePageTitle();
603
+ emit();
604
+ }
605
+
606
+ // The branch to carry in the URL: only a branch the user explicitly chose
607
+ // is pinned, so a default-branch selection reads back as a bare `?doc=`. The
608
+ // default is identified two ways (because `defaultBranchId` resolves only
609
+ // after the branches list loads): no branch was specified, or the specified
610
+ // one equals the now-known default. Before the list loads a specified branch
611
+ // is shown (so a switch still pushes a distinct entry) and dropped by the
612
+ // `branches` re-sync if it was the default.
613
+ function urlBranch(doc: Document): string | null {
614
+ const specified = activeSpecifiedBranch;
615
+ if (specified === null) return null;
616
+ const defaultBranchId = doc._defaultBranchId();
617
+ if (defaultBranchId !== null && specified === defaultBranchId) return null;
618
+ return specified;
619
+ }
620
+
621
+ // Reflect the active selection into the URL per `urlNav`. A null doc clears
622
+ // the selection; a doc with no server identity (offline / test) is left
623
+ // unpinned.
624
+ function syncUrlForActive(urlNav: UrlNav): void {
625
+ if (urlNav === 'skip') return;
626
+ if (!document) {
627
+ deps.clearUrlSelection();
628
+ return;
629
+ }
630
+ if (!document.id) return;
631
+ deps.writeUrlSelection(
632
+ { docId: document.id, branchId: urlBranch(document) },
633
+ { push: urlNav === 'push' },
634
+ );
635
+ }
636
+
637
+ // The active branch's display name when it is NOT the default, else null.
638
+ // Qualifies the page title; resolves once the branch list loads and the
639
+ // `'branches'` event re-runs `updatePageTitle`.
640
+ function activeBranchName(): string | null {
641
+ return document?._currentBranchName() ?? null;
642
+ }
643
+
644
+ // Keep the page title on the active doc's name, qualified `Doc (branch)` on
645
+ // a non-default branch, or restore the original title when none is open.
646
+ // Prefers the doc's live `name` (tracks renames) over the seeded
647
+ // `documentName`, falling back while the name resolves. The app-name
648
+ // suffix (`Doc · App`) is applied one layer down in the client, which
649
+ // holds the captured original title used as the suffix.
650
+ function updatePageTitle(): void {
651
+ if (!document) {
652
+ deps.setPageTitle(null);
653
+ return;
654
+ }
655
+ const base = document.name || documentName || '';
656
+ const branch = activeBranchName();
657
+ deps.setPageTitle(base && branch ? `${base} (${branch})` : base);
658
+ }
659
+
660
+ // Fetch the active doc's record once it binds, for the display name when
661
+ // the caller didn't supply one. Best-effort: a failed lookup leaves the
662
+ // name null.
663
+ async function resolveActiveDocRecord(
664
+ doc: Document,
665
+ knownName: string | null,
666
+ ): Promise<void> {
667
+ if (!doc.id) return;
668
+ if (knownName !== null) return;
669
+ try {
670
+ const record = await deps.documents.get(doc.id);
671
+ if (document !== doc) return; // a newer open superseded this one
672
+ doc._setName(record.name);
673
+ documentName = record.name;
674
+ updatePageTitle();
675
+ emit();
676
+ } catch {
677
+ // best-effort; see the doc comment above.
678
+ }
679
+ }
680
+
681
+ // Shared open path. `load` produces the document; `knownName` is the name
682
+ // when the caller has it (picker / create), else null and resolved in the
683
+ // background. `urlNav` decides the URL reflection (see `UrlNav`).
684
+ async function activate(
685
+ load: (
686
+ onSnapshotProgress: (loaded: number, total: number | null) => void,
687
+ ) => Promise<OpenedDocument>,
688
+ knownName: string | null,
689
+ urlNav: UrlNav = 'push',
690
+ specifiedBranch: string | null = null,
691
+ // Whether a cold-open failure (no document to fall back to) parks the
692
+ // session in the 'error' state. On for user-initiated opens; off for the
693
+ // boot last-selected fallback, whose failure is a stale-persistence
694
+ // self-heal that should settle silently on empty (the user asked for
695
+ // nothing specific).
696
+ surfaceOpenError = true,
697
+ ): Promise<Document> {
698
+ const generation = ++openGeneration;
699
+ loading = true;
700
+ loadingMessage = null;
701
+ loadingProgress = null;
702
+ // A new open attempt supersedes any prior failure's error state.
703
+ openError = null;
704
+ // Detach (without closing) the active document for the load, so the app
705
+ // renders its loading surface instead of the outgoing editor sitting
706
+ // there stale and still editable. `document` is null and `status` is
707
+ // 'loading' until the new doc binds; a failed open restores the previous
708
+ // binding. The doc-event/gone subscriptions stay attached (`setDocument`
709
+ // re-points them, and the restore path watches the same doc).
710
+ const previousDocument = document;
711
+ const previousName = documentName;
712
+ const previousSpecifiedBranch = activeSpecifiedBranch;
713
+ document = null;
714
+ documentName = null;
715
+ emit();
716
+ // Surface snapshot-download progress. Ignored once a newer open supersedes
717
+ // this generation, so a slow stale download can't repaint the new open.
718
+ const onSnapshotProgress = (loaded: number, total: number | null): void => {
719
+ if (generation !== openGeneration) return;
720
+ const complete = total !== null && total > 0 && loaded >= total;
721
+ if (complete) {
722
+ // Bytes are in; the remaining work (loro import, projection, render)
723
+ // is not byte-paced, so label the phase instead of freezing at ~100%.
724
+ loadingProgress = null;
725
+ loadingMessage = 'Reading...';
726
+ } else if (total !== null && total > 0) {
727
+ loadingProgress = Math.min(1, loaded / total);
728
+ loadingMessage = `Downloading... ${Math.floor(loadingProgress * 100)}%`;
729
+ } else {
730
+ loadingProgress = null;
731
+ loadingMessage = `Downloading... ${(loaded / 1_000_000).toFixed(1)} MB`;
732
+ }
733
+ emit();
734
+ };
735
+ let doc: OpenedDocument;
736
+ try {
737
+ doc = await load(onSnapshotProgress);
738
+ } catch (err) {
739
+ if (generation === openGeneration) {
740
+ loading = false;
741
+ loadingMessage = null;
742
+ loadingProgress = null;
743
+ // Rebind the previous document so the user keeps their editor.
744
+ // 'replace' restores the URL without planting an entry for an open
745
+ // that never happened.
746
+ 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
+ if (surfaceOpenError && previousDocument === null) {
753
+ openError = toDisplayError(err);
754
+ // Leave the URL pointing at the doc that failed ('skip', not the
755
+ // 'replace' below that clears it for a null document): the error
756
+ // surface corresponds to that id, so a reload re-shows it and the
757
+ // id stays in the URL for a user to copy when requesting access.
758
+ setDocument(previousDocument, previousName, 'skip');
759
+ } else {
760
+ setDocument(previousDocument, previousName, 'replace');
761
+ }
762
+ } else {
763
+ // A newer open / close superseded this load (and captured a null
764
+ // previous), so the detached document is this generation's to dispose.
765
+ closeQuietly(previousDocument);
766
+ }
767
+ throw err;
768
+ }
769
+ if (generation !== openGeneration) {
770
+ // A newer open / close superseded this one. Discard the orphan (so its
771
+ // WS subscription doesn't leak) plus the doc this generation detached.
772
+ closeQuietly(doc);
773
+ closeQuietly(previousDocument);
774
+ return doc;
775
+ }
776
+ // A doc opened mid-preparation (drive-stub hydration, upload extraction)
777
+ // arrives empty + read-only with a pending blocking status; the SDK
778
+ // imports the content and emits the terminal status when the work
779
+ // finishes. Hold the session in 'loading' (surfacing the server message)
780
+ // and bind only on that terminal status, so the editor's first paint sees
781
+ // the content. A pending push is non-blocking and binds immediately.
782
+ const work = doc.status;
783
+ if (work !== null && work.pending && work.blocking !== false) {
784
+ loadingMessage = work.message;
785
+ loadingProgress = work.progress ?? null;
786
+ emit();
787
+ await new Promise<void>((resolve) => {
788
+ let offStatus: (() => void) | null = null;
789
+ let offGone: (() => void) | null = null;
790
+ const settle = (): void => {
791
+ offStatus?.();
792
+ offGone?.();
793
+ resolve();
794
+ };
795
+ offStatus = doc.onStatus((s) => {
796
+ if (generation !== openGeneration) {
797
+ settle();
798
+ return;
799
+ }
800
+ if (s.pending) {
801
+ loadingMessage = s.message;
802
+ loadingProgress = s.progress ?? null;
803
+ emit();
804
+ return;
805
+ }
806
+ settle();
807
+ });
808
+ // A doc deleted while preparing never reaches a terminal status; the
809
+ // gone signal is its terminal transition.
810
+ offGone = doc.onGone(() => settle());
811
+ });
812
+ if (generation !== openGeneration) {
813
+ closeQuietly(doc);
814
+ closeQuietly(previousDocument);
815
+ return doc;
816
+ }
817
+ }
818
+ loading = false;
819
+ loadingMessage = null;
820
+ loadingProgress = null;
821
+ activeSpecifiedBranch = specifiedBranch;
822
+ setDocument(doc, knownName, urlNav);
823
+ // The outgoing doc was detached at load start (so `setDocument` saw a null
824
+ // previous); dispose it now the new one is bound.
825
+ closeQuietly(previousDocument);
826
+ void resolveActiveDocRecord(doc, knownName);
827
+ return doc;
828
+ }
829
+
830
+ // Close a document the session holds outside the active slot (a detached
831
+ // previous binding, a superseded open's orphan), swallowing teardown
832
+ // failures.
833
+ function closeQuietly(target: Document | null): void {
834
+ if (!target) return;
835
+ try {
836
+ target.close();
837
+ } catch {
838
+ // non-fatal
839
+ }
840
+ }
841
+
842
+ async function doStart(): Promise<Document | null> {
843
+ // Guest boot: a `?brass_link=` token opens its document read-only,
844
+ // before (and instead of) the signed-in restore paths. A bad / expired
845
+ // token parks the 'error' state like any other failed explicit open.
846
+ const guestToken = deps.guestLinkToken?.();
847
+ if (guestToken && deps.openDocumentByLink) {
848
+ if (document) return document;
849
+ const openGuest = deps.openDocumentByLink;
850
+ try {
851
+ return await activate(
852
+ (onSnapshotProgress) => openGuest(guestToken, { onSnapshotProgress }),
853
+ null,
854
+ 'skip',
855
+ null,
856
+ );
857
+ } catch {
858
+ return null;
859
+ }
860
+ }
861
+ if (!deps.isSignedIn() || document) return document;
862
+ // A pick handed back by the hosted picker's redirect fallback opens
863
+ // before the ordinary URL / last-selection restore, and independently of
864
+ // `syncDocToUrl`. `takePickSelection` strips the one-shot params as it
865
+ // reads them, so a reload lands on the ordinary restore, not the pick.
866
+ const pick = deps.takePickSelection();
867
+ const sel = pick ?? deps.readUrlSelection();
868
+ if (sel) {
869
+ try {
870
+ // 'replace': a boot restore pins the URL selection in place, not a
871
+ // navigation, so it must not plant a history entry.
872
+ return await activate(
873
+ (onSnapshotProgress) =>
874
+ deps.openDocumentById(
875
+ sel.docId,
876
+ {
877
+ ...(sel.branchId ? { branch: sel.branchId } : {}),
878
+ onSnapshotProgress,
879
+ },
880
+ 'skip',
881
+ ),
882
+ null,
883
+ 'replace',
884
+ sel.branchId,
885
+ );
886
+ } catch {
887
+ // The user navigated to a specific `?doc=` and it could not be
888
+ // opened. `activate` has parked the session in the 'error' state and
889
+ // left the id in the URL (so the error corresponds to it and a reload
890
+ // re-shows it); return rather than fall through to a different
891
+ // last-selected doc, which would silently swap them onto content they
892
+ // did not ask for and hide the failure.
893
+ return null;
894
+ }
895
+ }
896
+ const last = deps.lastSelectedDocument();
897
+ if (last) {
898
+ try {
899
+ return await activate(
900
+ (onSnapshotProgress) =>
901
+ deps.openDocumentById(last.doc_id, { onSnapshotProgress }, 'skip'),
902
+ last.name,
903
+ 'replace',
904
+ null,
905
+ // A stale last-selection is a persistence self-heal, not a request
906
+ // the user made; settle silently on empty rather than parking an
907
+ // error.
908
+ false,
909
+ );
910
+ } catch {
911
+ // Stale last-selection; settle on the empty state.
912
+ }
913
+ }
914
+ return null;
915
+ }
916
+
917
+ function start(): Promise<Document | null> {
918
+ if (startPromise) return startPromise;
919
+ startPromise = doStart().finally(() => {
920
+ startPromise = null;
921
+ // Notify once the boot restore settles. The signed-out -> empty
922
+ // transition (signed in, no URL document) takes neither `activate`
923
+ // branch, so without this emit the `session.subscribe(render)` boot
924
+ // path would never hear that sign-in completed.
925
+ emit();
926
+ });
927
+ return startPromise;
928
+ }
929
+
930
+ function open(
931
+ docId: string,
932
+ options?: SessionOpenOptions,
933
+ ): Promise<Document> {
934
+ return activate(
935
+ (onSnapshotProgress) =>
936
+ deps.openDocumentById(docId, {
937
+ ...withDefaults(options),
938
+ onSnapshotProgress,
939
+ }),
940
+ null,
941
+ 'push',
942
+ options?.branch ?? null,
943
+ );
944
+ }
945
+
946
+ async function create(
947
+ options?: CreateDocumentOptions,
948
+ ): Promise<Document | null> {
949
+ // Placement is expressed by context, never an org this layer names.
950
+ // Create-in-place: a New doc anchors on the active one via `sibling_of`,
951
+ // resolved server-side (its project, or loose in its org). With no active
952
+ // doc the create runs cold and the server tells us if the caller must
953
+ // choose a destination.
954
+ // The wire always carries a name so the server's untitled-numbering has
955
+ // something to key on; `knownName` stays null when the caller gave none,
956
+ // so the stored "Untitled N" is adopted once the record binds.
957
+ const base: CreateDocumentArgs = { name: options?.name ?? 'Untitled' };
958
+ if (options?.app_metadata !== undefined) base.app_metadata = options.app_metadata;
959
+ if (document?.id) base.siblingOf = document.id;
960
+
961
+ // Resolve to a docId BEFORE the loading state starts, so the hosted
962
+ // picker / acquire surface opens over the empty state, not a loading
963
+ // one. The server owns the single-vs-multi decision: a `needs-*` outcome
964
+ // is where we open the corresponding hosted surface and retry with the
965
+ // opaque destination it returns, relayed verbatim.
966
+ let args = base;
967
+ let knownName = options?.name ?? null;
968
+ let result: CreateDocumentResult;
969
+ for (;;) {
970
+ result = await deps.createDocumentId(args);
971
+ if (result.kind === 'created') break;
972
+ if (result.kind === 'needs-organization') {
973
+ const acquired = await deps.acquireOrganization();
974
+ if (!acquired) return null;
975
+ args = { ...args, destination: { organization_id: acquired.organization_id } };
976
+ continue;
977
+ }
978
+ // needs-destination
979
+ const destination = await deps.selectCreateDestination(
980
+ options?.name !== undefined ? { name: options.name } : undefined,
981
+ );
982
+ if (destination === null) return null;
983
+ // The dialog's final name wins on both the wire and the display: the
984
+ // user saw and could edit it.
985
+ args = { ...args, destination, name: destination.name };
986
+ knownName = destination.name ?? knownName;
987
+ }
988
+
989
+ const docId = result.docId;
990
+ const openOptions = options?.documentOptions
991
+ ? { documentOptions: options.documentOptions }
992
+ : undefined;
993
+ // Pass the known name only when the caller or the picker supplied one;
994
+ // with no name the server owns it (resolving the next free "Untitled N"),
995
+ // and a null `knownName` is what lets `resolveActiveDocRecord` adopt the
996
+ // stored name once the record binds.
997
+ return activate(
998
+ (onSnapshotProgress) =>
999
+ deps.openDocumentById(docId, {
1000
+ ...withDefaults(openOptions),
1001
+ onSnapshotProgress,
1002
+ }),
1003
+ knownName,
1004
+ );
1005
+ }
1006
+
1007
+ async function pick(): Promise<Document | null> {
1008
+ const result = await deps.selectDocument();
1009
+ if (!result) return null;
1010
+ const opts = {
1011
+ ...withDefaults(),
1012
+ ...(result.branch_id ? { branch: result.branch_id } : {}),
1013
+ };
1014
+ return activate(
1015
+ (onSnapshotProgress) =>
1016
+ deps.openDocumentById(result.doc_id, { ...opts, onSnapshotProgress }),
1017
+ result.name,
1018
+ 'push',
1019
+ result.branch_id ?? null,
1020
+ );
1021
+ }
1022
+
1023
+ // Merge `defaultDocumentOptions` into the caller's options: caller values
1024
+ // win per-field, the default fills gaps, and nested `undo` is shallow-merged
1025
+ // so a partial override composes. Returns undefined when neither side has
1026
+ // anything.
1027
+ function withDefaults<
1028
+ T extends { documentOptions?: DocumentOptions } | undefined,
1029
+ >(options?: T): T {
1030
+ const docDefaults = session._defaultDocumentOptions;
1031
+ if (!docDefaults) return options as T;
1032
+ const callerDocOpts = options?.documentOptions;
1033
+ const mergedDocOpts: DocumentOptions = {
1034
+ ...docDefaults,
1035
+ ...callerDocOpts,
1036
+ };
1037
+ // Shallow-merge `undo` so a partial override composes. (The explicit
1038
+ // branch works around exactOptionalPropertyTypes' finicky
1039
+ // `undo?: T | undefined` -> `undo?: T` narrowing.)
1040
+ const mergedUndo = callerDocOpts?.undo
1041
+ ? { ...docDefaults.undo, ...callerDocOpts.undo }
1042
+ : docDefaults.undo;
1043
+ if (mergedUndo) mergedDocOpts.undo = mergedUndo;
1044
+ return {
1045
+ ...options,
1046
+ documentOptions: mergedDocOpts,
1047
+ } as T;
1048
+ }
1049
+
1050
+ function close(): void {
1051
+ // Bump the generation so an in-flight open can't resurrect a document
1052
+ // after an explicit close.
1053
+ openGeneration++;
1054
+ loading = false;
1055
+ // A deliberate close returns to the empty state, clearing any parked open
1056
+ // failure (an app dismissing the error surface calls this).
1057
+ openError = null;
1058
+ // Reject awaiters of an open that was in flight at close time: 'empty' has
1059
+ // no settleWhenReady branch, so they would otherwise never settle.
1060
+ rejectWhenReadyPending(
1061
+ 'Brass.session.whenReady: closed before a document was bound.',
1062
+ );
1063
+ // 'replace': clears the URL selection and restores the title without an
1064
+ // entry.
1065
+ setDocument(null, null, 'replace');
1066
+ }
1067
+
1068
+ // The active document was evicted remotely (a `doc_gone` the user did not
1069
+ // initiate). Park the 'error' state with the gone message and leave the URL
1070
+ // pointing at the now-deleted id ('skip', not the 'replace' that would clear
1071
+ // it): the error surface corresponds to that id, so a reload re-shows it and
1072
+ // the id stays in the URL. Mirrors the failed-open error path in `activate`.
1073
+ function markGoneRemote(): void {
1074
+ openGeneration++;
1075
+ loading = false;
1076
+ openError = new BrassError(DOCUMENT_GONE_MESSAGE, { status: 404 });
1077
+ setDocument(null, null, 'skip');
1078
+ }
1079
+
1080
+ // Browser Back / Forward moved the URL. Re-bind to the selection the URL now
1081
+ // names WITHOUT writing history again (the entry exists, so 'skip'). Only
1082
+ // user navigation fires `popstate`; the SDK's own push / replace do not, so
1083
+ // this can't feed back into another write.
1084
+ function handlePopState(): void {
1085
+ if (deps.authStatus() === 'unknown' || !deps.isSignedIn()) return;
1086
+ const sel = deps.readUrlSelection();
1087
+ if (!sel) {
1088
+ // Back / Forward landed on a bare URL: return to the empty state ('skip'
1089
+ // since the URL is already bare). Reset when a document OR a parked open
1090
+ // error is showing: a Forward into a now-deleted doc parks 'error' with a
1091
+ // null document, and a Back off it must clear the error and land on empty
1092
+ // rather than stranding the stale error surface over a bare URL.
1093
+ if (document || openError) {
1094
+ openGeneration++;
1095
+ loading = false;
1096
+ openError = null;
1097
+ setDocument(null, null, 'skip');
1098
+ }
1099
+ return;
1100
+ }
1101
+ const current = document;
1102
+ if (
1103
+ current &&
1104
+ current.id === sel.docId &&
1105
+ urlBranch(current) === sel.branchId
1106
+ ) {
1107
+ return; // already showing this selection
1108
+ }
1109
+ void activate(
1110
+ (onSnapshotProgress) =>
1111
+ deps.openDocumentById(
1112
+ sel.docId,
1113
+ {
1114
+ ...withDefaults(),
1115
+ ...(sel.branchId ? { branch: sel.branchId } : {}),
1116
+ onSnapshotProgress,
1117
+ },
1118
+ 'skip',
1119
+ ),
1120
+ null,
1121
+ 'skip',
1122
+ sel.branchId,
1123
+ ).catch(() => {
1124
+ // Back / Forward to a doc that can't be opened parks the 'error' state
1125
+ // (or, warm, keeps the current doc); the URL the user navigated to is
1126
+ // left as-is, so the caught rejection needs no cleanup here.
1127
+ });
1128
+ }
1129
+ deps.onUrlPopState(handlePopState);
1130
+
1131
+ // Sign-out (explicit, token expiry, or the boot resolution to signed-out)
1132
+ // drops the active document but PRESERVES the URL's `?doc=` selection. A
1133
+ // deep-linked visitor lands signed-out on `?doc=<id>`; `Brass.signIn()`
1134
+ // carries `window.location.href` as its return_to, so the id must survive
1135
+ // in the URL until that click, and `start()` restores it after sign-in.
1136
+ // A user-initiated `close()` clears the URL (return to the empty state);
1137
+ // sign-out must not, or the deep link is lost before the round-trip.
1138
+ function teardownForSignOut(): void {
1139
+ openGeneration++;
1140
+ loading = false;
1141
+ openError = null;
1142
+ setDocument(null, null, 'skip');
1143
+ }
1144
+
1145
+ // Sign-out drops the document; the first sign-in restores it. A guest
1146
+ // (opened via `?brass_link=`) is never signed in, so the initial
1147
+ // resolution arrives here with `signedIn === false`; it must boot the
1148
+ // read-only link document rather than tear down to the sign-in surface.
1149
+ deps.onUserChange((signedIn) => {
1150
+ if (signedIn || deps.guestLinkToken?.() != null) {
1151
+ if (!document) void start();
1152
+ else emit();
1153
+ } else {
1154
+ teardownForSignOut();
1155
+ }
1156
+ });
1157
+
1158
+ const session: DocumentSession = {
1159
+ get document(): Document | null {
1160
+ return document;
1161
+ },
1162
+ get documentName(): string | null {
1163
+ return documentName;
1164
+ },
1165
+ get status(): SessionStatus {
1166
+ return status();
1167
+ },
1168
+ get error(): BrassError | null {
1169
+ return status() === 'error' ? openError : null;
1170
+ },
1171
+ get loadingMessage(): string | null {
1172
+ return loading ? loadingMessage : null;
1173
+ },
1174
+ get loadingProgress(): number | null {
1175
+ return loading ? loadingProgress : null;
1176
+ },
1177
+ get commands(): readonly Command[] {
1178
+ return allCommands();
1179
+ },
1180
+ get presence(): import('./presence.js').PresenceClient | null {
1181
+ return document?.presence ?? null;
1182
+ },
1183
+ subscribe(listener): () => void {
1184
+ listeners.add(listener);
1185
+ try {
1186
+ listener(session);
1187
+ } catch (err) {
1188
+ console.error('[Brass] session listener threw:', err);
1189
+ }
1190
+ return () => {
1191
+ listeners.delete(listener);
1192
+ };
1193
+ },
1194
+ start,
1195
+ open,
1196
+ create,
1197
+ pick,
1198
+ close,
1199
+ async run(commandId): Promise<void> {
1200
+ const command = allCommands().find((c) => c.id === commandId);
1201
+ if (command) await command.run();
1202
+ },
1203
+ useCanvas(source): () => void {
1204
+ // A forwarder: store the canvas at session scope and re-arm it on the
1205
+ // active Document each time one becomes active. Apps can also call
1206
+ // `doc.useCanvas(canvas)` directly for per-doc control.
1207
+ canvas = source;
1208
+ // Detach any previous session-armed registration, then arm the active
1209
+ // doc.
1210
+ if (sessionCanvasUnregister) sessionCanvasUnregister();
1211
+ sessionCanvasUnregister = document
1212
+ ? document.useCanvas(source)
1213
+ : null;
1214
+ return () => {
1215
+ if (canvas === source) {
1216
+ canvas = null;
1217
+ if (sessionCanvasUnregister) sessionCanvasUnregister();
1218
+ sessionCanvasUnregister = null;
1219
+ }
1220
+ };
1221
+ },
1222
+ whenReady(): Promise<OpenedDocument> {
1223
+ if (document && status() === 'ready') {
1224
+ return Promise.resolve(document as OpenedDocument);
1225
+ }
1226
+ // Mirror `status()` (not raw `isSignedIn()`): a link guest is not
1227
+ // signed in yet legitimately has a read-only document loading, so only
1228
+ // a genuine 'signed-out' rejects here. This is the same terminal test
1229
+ // the async `settleWhenReady` path uses; a raw `!isSignedIn()` check
1230
+ // would reject every guest before its document binds.
1231
+ if (status() === 'signed-out') {
1232
+ return Promise.reject(
1233
+ new Error(
1234
+ 'Brass.session.whenReady: signed out before a document was bound.',
1235
+ ),
1236
+ );
1237
+ }
1238
+ // Already-terminal open failure: reject now rather than wait for a bind
1239
+ // that isn't coming.
1240
+ if (status() === 'error' && openError) {
1241
+ return Promise.reject(openError);
1242
+ }
1243
+ return new Promise((resolve, reject) => {
1244
+ whenReadyPending.push({ resolve, reject });
1245
+ });
1246
+ },
1247
+ setDefaultDocumentOptions(options): void {
1248
+ defaultDocumentOptions = options;
1249
+ },
1250
+ get _defaultDocumentOptions(): DocumentOptions | null {
1251
+ return defaultDocumentOptions;
1252
+ },
1253
+ _noteLocalDelete(docId: string): void {
1254
+ localDeletes.add(docId);
1255
+ },
1256
+ _clearLocalDelete(docId: string): void {
1257
+ localDeletes.delete(docId);
1258
+ },
1259
+ };
1260
+
1261
+ return session;
1262
+ }