@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
@@ -0,0 +1,926 @@
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
+ import { BrassError } from './errors.js';
10
+ import { makeLiveCommand } from './document-commands.js';
11
+ // Built-in orphan command labels (no active doc needed). Sentence case, ASCII
12
+ // ellipsis, per the copy-style rules. Both stay bare: an app's own word for
13
+ // what it holds is rarely "document" (a sketch, a model, a sheet), so a
14
+ // label naming it would read wrong in most of the apps this menu mounts in.
15
+ const SESSION_LABELS = {
16
+ new: 'New',
17
+ open: 'Open...',
18
+ };
19
+ function toError(err) {
20
+ return err instanceof Error ? err : new Error(String(err));
21
+ }
22
+ // Fallback copy for a failed open with no server message to render (a
23
+ // network drop, no HTTP response). The HTTP case carries the server's own
24
+ // message instead, so the platform evolves the wording without an app change.
25
+ const OPEN_FAILED_MESSAGE = 'This document could not be opened';
26
+ // Copy parked on `session.error` when the open document is evicted remotely
27
+ // (a `doc_gone` the local user did not initiate). Matches the wording a
28
+ // post-eviction op frame degrades to ("the document is no longer available"),
29
+ // so a delete observed live and a fresh visit to the same deleted id read the
30
+ // same. A reload re-opens the id, hits the server 404, and swaps in the
31
+ // server-authored copy.
32
+ const DOCUMENT_GONE_MESSAGE = 'This document is no longer available';
33
+ // The error parked on `session.error` for a failed open, shaped so its
34
+ // `.message` is always presentable copy an app can render verbatim. An HTTP
35
+ // failure is returned as-is: its `.message` is the server's (non-disclosing,
36
+ // server-authored) copy, which is the whole point of rendering it verbatim.
37
+ // A non-HTTP failure has no server copy, so it falls back to a generic
38
+ // display string rather than leaking a raw `TypeError: Failed to fetch`.
39
+ function toDisplayError(err) {
40
+ if (err instanceof BrassError && typeof err.status === 'number' && err.status > 0) {
41
+ return err;
42
+ }
43
+ const status = err instanceof BrassError ? err.status ?? 0 : 0;
44
+ return new BrassError(OPEN_FAILED_MESSAGE, { status });
45
+ }
46
+ export function createDocumentSession(deps) {
47
+ let document = null;
48
+ let documentName = null;
49
+ let loading = false;
50
+ // Progress for an open in flight (a presigned snapshot streaming in),
51
+ // surfaced via `session.loadingMessage` / `loadingProgress` while
52
+ // `status` is 'loading' and before the Document exists.
53
+ let loadingMessage = null;
54
+ let loadingProgress = null;
55
+ // Bumped on every open / create / close so a slower in-flight open
56
+ // cannot clobber a newer one (last call wins).
57
+ let openGeneration = 0;
58
+ // The failure that put the session in the 'error' state, or null. A new
59
+ // open attempt clears it (at the generation bump in `activate`), so it only
60
+ // survives while no newer open supersedes the one that failed.
61
+ let openError = null;
62
+ // Dedups concurrent `start()` calls (an app's explicit boot call
63
+ // racing the auto-start the sign-in handler fires).
64
+ let startPromise = null;
65
+ // Signature of the last broadcast state; a notification is sent only when it
66
+ // changes, so a per-commit `doc.subscribe` that doesn't move a command's
67
+ // enabled state stays quiet.
68
+ let lastSignature = null;
69
+ // Outstanding `whenReady()` promises, settled in `emit()`: resolve once a
70
+ // document binds, reject if the user signs out before one ever does.
71
+ const whenReadyPending = [];
72
+ const listeners = new Set();
73
+ // Per-built-in `running` flag for orphan commands.
74
+ const orphanRunning = new Map();
75
+ let canvas = null;
76
+ // Per-doc useCanvas registration the session armed (so the session
77
+ // can detach it when the active doc swaps or useCanvas is cleared).
78
+ let sessionCanvasUnregister = null;
79
+ let defaultDocumentOptions = null;
80
+ // The branch the active open explicitly asked for, or null. Decides the URL
81
+ // branch: the default branch (no-branch-specified) is never pinned, so
82
+ // `?branch=` appears only for a chosen branch. `defaultBranchId` then
83
+ // refines it (a specified branch that turns out to be the default is
84
+ // dropped once known).
85
+ let activeSpecifiedBranch = null;
86
+ // Subscriptions onto the active document, dropped when it changes.
87
+ let docUnsub = null;
88
+ let goneUnsub = null;
89
+ // Doc ids the local user is deleting through the hosted share dialog. The
90
+ // dialog signals over the SDK bridge BEFORE its delete request goes out, so
91
+ // an id lands here ahead of the server's `doc_gone` frame (fanned out before
92
+ // the delete's 204). A `doc_gone` for an id in this set is the user's own
93
+ // delete: return to the empty state and clear the URL. Any other `doc_gone`
94
+ // is a remote eviction: keep the URL and park the 'error' surface. A failed
95
+ // delete retracts its id, so a later remote delete of the same doc is not
96
+ // misread as local.
97
+ const localDeletes = new Set();
98
+ function status() {
99
+ // 'unknown' before auth settles; the onUserChange handler re-fires
100
+ // (start() / emit()) and flips this to its resolved value.
101
+ if (deps.authStatus() === 'unknown')
102
+ return 'unknown';
103
+ // A guest (opened via `?brass_link=`) is not signed in but still has a
104
+ // read-only document to load, so it never reports 'signed-out' (which
105
+ // would render the app's sign-in surface instead of the document).
106
+ const isGuest = deps.guestLinkToken?.() != null;
107
+ if (!deps.isSignedIn() && !isGuest)
108
+ return 'signed-out';
109
+ if (loading)
110
+ return 'loading';
111
+ if (document)
112
+ return 'ready';
113
+ if (openError)
114
+ return 'error';
115
+ return 'empty';
116
+ }
117
+ function commandSig(c) {
118
+ let s = `${c.id}:${c.enabled ? 1 : 0}:${c.running ? 1 : 0}:${c.label}`;
119
+ if (c.selected)
120
+ s += ':S';
121
+ if (c.children)
122
+ s += `:[${c.children.map(commandSig).join(',')}]`;
123
+ return s;
124
+ }
125
+ function signature() {
126
+ const cmds = allCommands().map(commandSig).join(',');
127
+ // `loadingMessage` is in the signature so a download-progress tick
128
+ // re-fires subscribers; it is null outside an in-flight open.
129
+ const load = loading ? `${loadingMessage ?? ''}:${loadingProgress ?? ''}` : '';
130
+ // `openError` message joins the signature so a switch from one failed open
131
+ // to a different one (same 'error' status, no bound doc) still re-fires.
132
+ const err = openError ? `${openError.status ?? ''}:${openError.message}` : '';
133
+ return `${status()}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${cmds}`;
134
+ }
135
+ function emit() {
136
+ const sig = signature();
137
+ if (sig === lastSignature)
138
+ return;
139
+ lastSignature = sig;
140
+ // Settle pending `whenReady()` promises BEFORE notifying, so an app whose
141
+ // subscribe handler awaits whenReady sees it already resolved by the time
142
+ // its listener runs.
143
+ settleWhenReady();
144
+ for (const listener of listeners) {
145
+ try {
146
+ listener(session);
147
+ }
148
+ catch (err) {
149
+ // A throwing listener must not strand the others.
150
+ console.error('[Brass] session listener threw:', err);
151
+ }
152
+ }
153
+ }
154
+ function settleWhenReady() {
155
+ if (whenReadyPending.length === 0)
156
+ return;
157
+ const current = status();
158
+ if (current === 'ready' && document) {
159
+ const doc = document;
160
+ const pending = whenReadyPending.splice(0, whenReadyPending.length);
161
+ for (const { resolve } of pending)
162
+ resolve(doc);
163
+ }
164
+ else if (current === 'signed-out') {
165
+ const error = new Error('Brass.session.whenReady: signed out before a document was bound.');
166
+ const pending = whenReadyPending.splice(0, whenReadyPending.length);
167
+ for (const { reject } of pending)
168
+ reject(error);
169
+ }
170
+ else if (current === 'error' && openError) {
171
+ // A user-initiated open failed terminally; reject rather than hang the
172
+ // awaiter forever. Same error object the session surfaces on `.error`.
173
+ const pending = whenReadyPending.splice(0, whenReadyPending.length);
174
+ for (const { reject } of pending)
175
+ reject(openError);
176
+ }
177
+ // No branch for 'empty': it is both the idle pre-open state (an awaiter
178
+ // legitimately waits for a future open) and the post-close state. A close
179
+ // that must reject its awaiters does so explicitly via
180
+ // `rejectWhenReadyPending`, so we do not blanket-reject on 'empty' here.
181
+ }
182
+ // Reject every outstanding `whenReady()` awaiter. Used by `close()`, whose
183
+ // 'empty' target has no `settleWhenReady` branch: an open in flight when the
184
+ // app closes never binds, so without this the promise hangs forever,
185
+ // violating the documented "never hangs" contract.
186
+ function rejectWhenReadyPending(message) {
187
+ if (whenReadyPending.length === 0)
188
+ return;
189
+ const error = new Error(message);
190
+ const pending = whenReadyPending.splice(0, whenReadyPending.length);
191
+ for (const { reject } of pending)
192
+ reject(error);
193
+ }
194
+ // Build a session-level (orphan) command. Same shape as Document's
195
+ // built-ins but runs against session state directly ('new' / 'open' need no
196
+ // active doc), with a session `emit` on each running flip.
197
+ function buildOrphanCommand(id, label, group, surface, isEnabled, impl) {
198
+ return makeLiveCommand({ id, label, group, surface, isEnabled, impl }, {
199
+ running: orphanRunning,
200
+ onError: (err) => deps.reportError(toError(err)),
201
+ notify: emit,
202
+ });
203
+ }
204
+ const cmdNew = buildOrphanCommand('new', SESSION_LABELS.new, 'session', 'menu', deps.isSignedIn, async () => {
205
+ await create();
206
+ });
207
+ const cmdOpen = buildOrphanCommand('open', SESSION_LABELS.open, 'session', 'menu', deps.isSignedIn, async () => {
208
+ await pick();
209
+ });
210
+ const orphanCommands = [cmdNew, cmdOpen];
211
+ // Concatenate orphan commands with the active doc's commands (whose
212
+ // instances carry their own `running` state; the session re-emits on the
213
+ // doc's `'commands'` synthetic event).
214
+ function allCommands() {
215
+ const list = [...orphanCommands];
216
+ if (document) {
217
+ for (const cmd of document.commands)
218
+ list.push(cmd);
219
+ }
220
+ return list;
221
+ }
222
+ // Swap the active document. Closes the previous one (so its WS subscription
223
+ // does not leak), drops the per-doc subscriptions, and attaches fresh ones:
224
+ // `doc.subscribe` so undo/redo enabled state tracks the live stack, and
225
+ // `doc.onGone` so a server-side delete tears the session down.
226
+ function setDocument(next, name, urlNav = 'skip') {
227
+ if (docUnsub) {
228
+ docUnsub();
229
+ docUnsub = null;
230
+ }
231
+ if (goneUnsub) {
232
+ goneUnsub();
233
+ goneUnsub = null;
234
+ }
235
+ // Detach the previous doc's session-armed useCanvas; re-armed on `next`
236
+ // below if a session-level canvas is still registered.
237
+ if (sessionCanvasUnregister) {
238
+ sessionCanvasUnregister();
239
+ sessionCanvasUnregister = null;
240
+ }
241
+ const previous = document;
242
+ document = next;
243
+ documentName = name;
244
+ if (next === null) {
245
+ activeSpecifiedBranch = null;
246
+ }
247
+ if (previous && previous !== next) {
248
+ try {
249
+ previous.close();
250
+ }
251
+ catch {
252
+ // A close failure (already-closed, freed WASM) is non-fatal.
253
+ }
254
+ }
255
+ if (next) {
256
+ // `subscribe` fires on every change including local mutations (the
257
+ // session's listeners drive UI that re-renders on local edits). The
258
+ // doc's `'commands'` / `'branches'` synthetic events also flow through,
259
+ // so the session re-emits on command-state changes and resolves the
260
+ // default branch for the URL.
261
+ docUnsub = next.subscribe((event) => {
262
+ // A `branches` event resolves `defaultBranchId`, which `urlBranch`
263
+ // needs to drop a specified-but-default branch; re-sync in place so
264
+ // the pinned branch settles once the default is known.
265
+ const kind = event?.kind;
266
+ if (kind === 'branches')
267
+ syncUrlForActive('replace');
268
+ // Take the doc's name whenever it moves. A rename reaches the doc
269
+ // from the server's `doc_meta` frame, which sets it directly, and
270
+ // `documentName` is what the emit signature reads: without this the
271
+ // signature is unchanged, `emit` short-circuits, and chrome
272
+ // rendering the name (the toolbar title) keeps the old one until
273
+ // something unrelated perturbs the signature.
274
+ if (next.name !== null)
275
+ documentName = next.name;
276
+ updatePageTitle();
277
+ emit();
278
+ });
279
+ goneUnsub = next.onGone((reason) => {
280
+ if (document !== next)
281
+ return;
282
+ const id = next.id;
283
+ if (id && localDeletes.delete(id)) {
284
+ // The user deleted this document through the hosted share dialog;
285
+ // return to the empty state and clear the URL.
286
+ close();
287
+ return;
288
+ }
289
+ if (reason === 'moved' && id) {
290
+ // A move keeps the same doc id: a cross-org/project re-home
291
+ // (`patchDocument`), a project merge, or the soft-delete move path
292
+ // all fan `doc_gone reason:'moved'` to evict the open editor, but the
293
+ // document still exists under a new location. Re-open the same id so
294
+ // the editor follows the move instead of showing a false "no longer
295
+ // available"; fall back to the gone surface only if the re-open fails
296
+ // (access was genuinely lost in the move). Detach the moved doc first
297
+ // so that failure parks the gone surface rather than rebinding a doc
298
+ // that is itself gone.
299
+ const branch = urlBranch(next);
300
+ document = null;
301
+ documentName = null;
302
+ void activate((onSnapshotProgress) => deps.openDocumentById(id, {
303
+ ...withDefaults(),
304
+ ...(branch ? { branch } : {}),
305
+ onSnapshotProgress,
306
+ }, 'skip'), null, 'skip', branch).catch(() => markGoneRemote());
307
+ return;
308
+ }
309
+ // Evicted from under the viewer (remote delete, or a future eviction
310
+ // reason). Keep the URL and park an 'error' surface, so it reads the
311
+ // same as visiting the deleted id fresh rather than silently emptying.
312
+ markGoneRemote();
313
+ });
314
+ // Re-arm the session-level canvas (if any) onto the new doc; capture
315
+ // itself lives inside the Document.
316
+ if (canvas !== null) {
317
+ sessionCanvasUnregister = next.useCanvas(canvas);
318
+ }
319
+ // Prefer the caller-supplied name, else read the doc's own `name`.
320
+ if (name !== null)
321
+ next._setName(name);
322
+ else
323
+ documentName = next.name;
324
+ }
325
+ syncUrlForActive(urlNav);
326
+ updatePageTitle();
327
+ emit();
328
+ }
329
+ // The branch to carry in the URL: only a branch the user explicitly chose
330
+ // is pinned, so a default-branch selection reads back as a bare `?doc=`. The
331
+ // default is identified two ways (because `defaultBranchId` resolves only
332
+ // after the branches list loads): no branch was specified, or the specified
333
+ // one equals the now-known default. Before the list loads a specified branch
334
+ // is shown (so a switch still pushes a distinct entry) and dropped by the
335
+ // `branches` re-sync if it was the default.
336
+ function urlBranch(doc) {
337
+ const specified = activeSpecifiedBranch;
338
+ if (specified === null)
339
+ return null;
340
+ const defaultBranchId = doc._defaultBranchId();
341
+ if (defaultBranchId !== null && specified === defaultBranchId)
342
+ return null;
343
+ return specified;
344
+ }
345
+ // Reflect the active selection into the URL per `urlNav`. A null doc clears
346
+ // the selection; a doc with no server identity (offline / test) is left
347
+ // unpinned.
348
+ function syncUrlForActive(urlNav) {
349
+ if (urlNav === 'skip')
350
+ return;
351
+ if (!document) {
352
+ deps.clearUrlSelection();
353
+ return;
354
+ }
355
+ if (!document.id)
356
+ return;
357
+ deps.writeUrlSelection({ docId: document.id, branchId: urlBranch(document) }, { push: urlNav === 'push' });
358
+ }
359
+ // The active branch's display name when it is NOT the default, else null.
360
+ // Qualifies the page title; resolves once the branch list loads and the
361
+ // `'branches'` event re-runs `updatePageTitle`.
362
+ function activeBranchName() {
363
+ return document?._currentBranchName() ?? null;
364
+ }
365
+ // Keep the page title on the active doc's name, qualified `Doc (branch)` on
366
+ // a non-default branch, or restore the original title when none is open.
367
+ // Prefers the doc's live `name` (tracks renames) over the seeded
368
+ // `documentName`, falling back while the name resolves. The app-name
369
+ // suffix (`Doc · App`) is applied one layer down in the client, which
370
+ // holds the captured original title used as the suffix.
371
+ function updatePageTitle() {
372
+ if (!document) {
373
+ deps.setPageTitle(null);
374
+ return;
375
+ }
376
+ const base = document.name || documentName || '';
377
+ const branch = activeBranchName();
378
+ deps.setPageTitle(base && branch ? `${base} (${branch})` : base);
379
+ }
380
+ // Fetch the active doc's record once it binds, for the display name when
381
+ // the caller didn't supply one. Best-effort: a failed lookup leaves the
382
+ // name null.
383
+ async function resolveActiveDocRecord(doc, knownName) {
384
+ if (!doc.id)
385
+ return;
386
+ if (knownName !== null)
387
+ return;
388
+ try {
389
+ const record = await deps.documents.get(doc.id);
390
+ if (document !== doc)
391
+ return; // a newer open superseded this one
392
+ doc._setName(record.name);
393
+ documentName = record.name;
394
+ updatePageTitle();
395
+ emit();
396
+ }
397
+ catch {
398
+ // best-effort; see the doc comment above.
399
+ }
400
+ }
401
+ // Shared open path. `load` produces the document; `knownName` is the name
402
+ // when the caller has it (picker / create), else null and resolved in the
403
+ // background. `urlNav` decides the URL reflection (see `UrlNav`).
404
+ async function activate(load, knownName, urlNav = 'push', specifiedBranch = null,
405
+ // Whether a cold-open failure (no document to fall back to) parks the
406
+ // session in the 'error' state. On for user-initiated opens; off for the
407
+ // boot last-selected fallback, whose failure is a stale-persistence
408
+ // self-heal that should settle silently on empty (the user asked for
409
+ // nothing specific).
410
+ surfaceOpenError = true) {
411
+ const generation = ++openGeneration;
412
+ loading = true;
413
+ loadingMessage = null;
414
+ loadingProgress = null;
415
+ // A new open attempt supersedes any prior failure's error state.
416
+ openError = null;
417
+ // Detach (without closing) the active document for the load, so the app
418
+ // renders its loading surface instead of the outgoing editor sitting
419
+ // there stale and still editable. `document` is null and `status` is
420
+ // 'loading' until the new doc binds; a failed open restores the previous
421
+ // binding. The doc-event/gone subscriptions stay attached (`setDocument`
422
+ // re-points them, and the restore path watches the same doc).
423
+ const previousDocument = document;
424
+ const previousName = documentName;
425
+ const previousSpecifiedBranch = activeSpecifiedBranch;
426
+ document = null;
427
+ documentName = null;
428
+ emit();
429
+ // Surface snapshot-download progress. Ignored once a newer open supersedes
430
+ // this generation, so a slow stale download can't repaint the new open.
431
+ const onSnapshotProgress = (loaded, total) => {
432
+ if (generation !== openGeneration)
433
+ return;
434
+ const complete = total !== null && total > 0 && loaded >= total;
435
+ if (complete) {
436
+ // Bytes are in; the remaining work (loro import, projection, render)
437
+ // is not byte-paced, so label the phase instead of freezing at ~100%.
438
+ loadingProgress = null;
439
+ loadingMessage = 'Reading...';
440
+ }
441
+ else if (total !== null && total > 0) {
442
+ loadingProgress = Math.min(1, loaded / total);
443
+ loadingMessage = `Downloading... ${Math.floor(loadingProgress * 100)}%`;
444
+ }
445
+ else {
446
+ loadingProgress = null;
447
+ loadingMessage = `Downloading... ${(loaded / 1_000_000).toFixed(1)} MB`;
448
+ }
449
+ emit();
450
+ };
451
+ let doc;
452
+ try {
453
+ doc = await load(onSnapshotProgress);
454
+ }
455
+ catch (err) {
456
+ if (generation === openGeneration) {
457
+ loading = false;
458
+ loadingMessage = null;
459
+ loadingProgress = null;
460
+ // Rebind the previous document so the user keeps their editor.
461
+ // 'replace' restores the URL without planting an entry for an open
462
+ // that never happened.
463
+ activeSpecifiedBranch = previousSpecifiedBranch;
464
+ // With no document to fall back to, a rebind lands on the empty state,
465
+ // which reads as the app's document-less home and hides the failure.
466
+ // Park the session in 'error' instead so a status-gated app renders
467
+ // the failure. A warm swap (previous doc present) keeps the editor and
468
+ // only rejects the caller, so the working surface is never clobbered.
469
+ if (surfaceOpenError && previousDocument === null) {
470
+ openError = toDisplayError(err);
471
+ // Leave the URL pointing at the doc that failed ('skip', not the
472
+ // 'replace' below that clears it for a null document): the error
473
+ // surface corresponds to that id, so a reload re-shows it and the
474
+ // id stays in the URL for a user to copy when requesting access.
475
+ setDocument(previousDocument, previousName, 'skip');
476
+ }
477
+ else {
478
+ setDocument(previousDocument, previousName, 'replace');
479
+ }
480
+ }
481
+ else {
482
+ // A newer open / close superseded this load (and captured a null
483
+ // previous), so the detached document is this generation's to dispose.
484
+ closeQuietly(previousDocument);
485
+ }
486
+ throw err;
487
+ }
488
+ if (generation !== openGeneration) {
489
+ // A newer open / close superseded this one. Discard the orphan (so its
490
+ // WS subscription doesn't leak) plus the doc this generation detached.
491
+ closeQuietly(doc);
492
+ closeQuietly(previousDocument);
493
+ return doc;
494
+ }
495
+ // A doc opened mid-preparation (drive-stub hydration, upload extraction)
496
+ // arrives empty + read-only with a pending blocking status; the SDK
497
+ // imports the content and emits the terminal status when the work
498
+ // finishes. Hold the session in 'loading' (surfacing the server message)
499
+ // and bind only on that terminal status, so the editor's first paint sees
500
+ // the content. A pending push is non-blocking and binds immediately.
501
+ const work = doc.status;
502
+ if (work !== null && work.pending && work.blocking !== false) {
503
+ loadingMessage = work.message;
504
+ loadingProgress = work.progress ?? null;
505
+ emit();
506
+ await new Promise((resolve) => {
507
+ let offStatus = null;
508
+ let offGone = null;
509
+ const settle = () => {
510
+ offStatus?.();
511
+ offGone?.();
512
+ resolve();
513
+ };
514
+ offStatus = doc.onStatus((s) => {
515
+ if (generation !== openGeneration) {
516
+ settle();
517
+ return;
518
+ }
519
+ if (s.pending) {
520
+ loadingMessage = s.message;
521
+ loadingProgress = s.progress ?? null;
522
+ emit();
523
+ return;
524
+ }
525
+ settle();
526
+ });
527
+ // A doc deleted while preparing never reaches a terminal status; the
528
+ // gone signal is its terminal transition.
529
+ offGone = doc.onGone(() => settle());
530
+ });
531
+ if (generation !== openGeneration) {
532
+ closeQuietly(doc);
533
+ closeQuietly(previousDocument);
534
+ return doc;
535
+ }
536
+ }
537
+ loading = false;
538
+ loadingMessage = null;
539
+ loadingProgress = null;
540
+ activeSpecifiedBranch = specifiedBranch;
541
+ setDocument(doc, knownName, urlNav);
542
+ // The outgoing doc was detached at load start (so `setDocument` saw a null
543
+ // previous); dispose it now the new one is bound.
544
+ closeQuietly(previousDocument);
545
+ void resolveActiveDocRecord(doc, knownName);
546
+ return doc;
547
+ }
548
+ // Close a document the session holds outside the active slot (a detached
549
+ // previous binding, a superseded open's orphan), swallowing teardown
550
+ // failures.
551
+ function closeQuietly(target) {
552
+ if (!target)
553
+ return;
554
+ try {
555
+ target.close();
556
+ }
557
+ catch {
558
+ // non-fatal
559
+ }
560
+ }
561
+ async function doStart() {
562
+ // Guest boot: a `?brass_link=` token opens its document read-only,
563
+ // before (and instead of) the signed-in restore paths. A bad / expired
564
+ // token parks the 'error' state like any other failed explicit open.
565
+ const guestToken = deps.guestLinkToken?.();
566
+ if (guestToken && deps.openDocumentByLink) {
567
+ if (document)
568
+ return document;
569
+ const openGuest = deps.openDocumentByLink;
570
+ try {
571
+ return await activate((onSnapshotProgress) => openGuest(guestToken, { onSnapshotProgress }), null, 'skip', null);
572
+ }
573
+ catch {
574
+ return null;
575
+ }
576
+ }
577
+ if (!deps.isSignedIn() || document)
578
+ return document;
579
+ // A pick handed back by the hosted picker's redirect fallback opens
580
+ // before the ordinary URL / last-selection restore, and independently of
581
+ // `syncDocToUrl`. `takePickSelection` strips the one-shot params as it
582
+ // reads them, so a reload lands on the ordinary restore, not the pick.
583
+ const pick = deps.takePickSelection();
584
+ const sel = pick ?? deps.readUrlSelection();
585
+ if (sel) {
586
+ try {
587
+ // 'replace': a boot restore pins the URL selection in place, not a
588
+ // navigation, so it must not plant a history entry.
589
+ return await activate((onSnapshotProgress) => deps.openDocumentById(sel.docId, {
590
+ ...(sel.branchId ? { branch: sel.branchId } : {}),
591
+ onSnapshotProgress,
592
+ }, 'skip'), null, 'replace', sel.branchId);
593
+ }
594
+ catch {
595
+ // The user navigated to a specific `?doc=` and it could not be
596
+ // opened. `activate` has parked the session in the 'error' state and
597
+ // left the id in the URL (so the error corresponds to it and a reload
598
+ // re-shows it); return rather than fall through to a different
599
+ // last-selected doc, which would silently swap them onto content they
600
+ // did not ask for and hide the failure.
601
+ return null;
602
+ }
603
+ }
604
+ const last = deps.lastSelectedDocument();
605
+ if (last) {
606
+ try {
607
+ return await activate((onSnapshotProgress) => deps.openDocumentById(last.doc_id, { onSnapshotProgress }, 'skip'), last.name, 'replace', null,
608
+ // A stale last-selection is a persistence self-heal, not a request
609
+ // the user made; settle silently on empty rather than parking an
610
+ // error.
611
+ false);
612
+ }
613
+ catch {
614
+ // Stale last-selection; settle on the empty state.
615
+ }
616
+ }
617
+ return null;
618
+ }
619
+ function start() {
620
+ if (startPromise)
621
+ return startPromise;
622
+ startPromise = doStart().finally(() => {
623
+ startPromise = null;
624
+ // Notify once the boot restore settles. The signed-out -> empty
625
+ // transition (signed in, no URL document) takes neither `activate`
626
+ // branch, so without this emit the `session.subscribe(render)` boot
627
+ // path would never hear that sign-in completed.
628
+ emit();
629
+ });
630
+ return startPromise;
631
+ }
632
+ function open(docId, options) {
633
+ return activate((onSnapshotProgress) => deps.openDocumentById(docId, {
634
+ ...withDefaults(options),
635
+ onSnapshotProgress,
636
+ }), null, 'push', options?.branch ?? null);
637
+ }
638
+ async function create(options) {
639
+ // Placement is expressed by context, never an org this layer names.
640
+ // Create-in-place: a New doc anchors on the active one via `sibling_of`,
641
+ // resolved server-side (its project, or loose in its org). With no active
642
+ // doc the create runs cold and the server tells us if the caller must
643
+ // choose a destination.
644
+ // The wire always carries a name so the server's untitled-numbering has
645
+ // something to key on; `knownName` stays null when the caller gave none,
646
+ // so the stored "Untitled N" is adopted once the record binds.
647
+ const base = { name: options?.name ?? 'Untitled' };
648
+ if (options?.app_metadata !== undefined)
649
+ base.app_metadata = options.app_metadata;
650
+ if (document?.id)
651
+ base.siblingOf = document.id;
652
+ // Resolve to a docId BEFORE the loading state starts, so the hosted
653
+ // picker / acquire surface opens over the empty state, not a loading
654
+ // one. The server owns the single-vs-multi decision: a `needs-*` outcome
655
+ // is where we open the corresponding hosted surface and retry with the
656
+ // opaque destination it returns, relayed verbatim.
657
+ let args = base;
658
+ let knownName = options?.name ?? null;
659
+ let result;
660
+ for (;;) {
661
+ result = await deps.createDocumentId(args);
662
+ if (result.kind === 'created')
663
+ break;
664
+ if (result.kind === 'needs-organization') {
665
+ const acquired = await deps.acquireOrganization();
666
+ if (!acquired)
667
+ return null;
668
+ args = { ...args, destination: { organization_id: acquired.organization_id } };
669
+ continue;
670
+ }
671
+ // needs-destination
672
+ const destination = await deps.selectCreateDestination(options?.name !== undefined ? { name: options.name } : undefined);
673
+ if (destination === null)
674
+ return null;
675
+ // The dialog's final name wins on both the wire and the display: the
676
+ // user saw and could edit it.
677
+ args = { ...args, destination, name: destination.name };
678
+ knownName = destination.name ?? knownName;
679
+ }
680
+ const docId = result.docId;
681
+ const openOptions = options?.documentOptions
682
+ ? { documentOptions: options.documentOptions }
683
+ : undefined;
684
+ // Pass the known name only when the caller or the picker supplied one;
685
+ // with no name the server owns it (resolving the next free "Untitled N"),
686
+ // and a null `knownName` is what lets `resolveActiveDocRecord` adopt the
687
+ // stored name once the record binds.
688
+ return activate((onSnapshotProgress) => deps.openDocumentById(docId, {
689
+ ...withDefaults(openOptions),
690
+ onSnapshotProgress,
691
+ }), knownName);
692
+ }
693
+ async function pick() {
694
+ const result = await deps.selectDocument();
695
+ if (!result)
696
+ return null;
697
+ const opts = {
698
+ ...withDefaults(),
699
+ ...(result.branch_id ? { branch: result.branch_id } : {}),
700
+ };
701
+ return activate((onSnapshotProgress) => deps.openDocumentById(result.doc_id, { ...opts, onSnapshotProgress }), result.name, 'push', result.branch_id ?? null);
702
+ }
703
+ // Merge `defaultDocumentOptions` into the caller's options: caller values
704
+ // win per-field, the default fills gaps, and nested `undo` is shallow-merged
705
+ // so a partial override composes. Returns undefined when neither side has
706
+ // anything.
707
+ function withDefaults(options) {
708
+ const docDefaults = session._defaultDocumentOptions;
709
+ if (!docDefaults)
710
+ return options;
711
+ const callerDocOpts = options?.documentOptions;
712
+ const mergedDocOpts = {
713
+ ...docDefaults,
714
+ ...callerDocOpts,
715
+ };
716
+ // Shallow-merge `undo` so a partial override composes. (The explicit
717
+ // branch works around exactOptionalPropertyTypes' finicky
718
+ // `undo?: T | undefined` -> `undo?: T` narrowing.)
719
+ const mergedUndo = callerDocOpts?.undo
720
+ ? { ...docDefaults.undo, ...callerDocOpts.undo }
721
+ : docDefaults.undo;
722
+ if (mergedUndo)
723
+ mergedDocOpts.undo = mergedUndo;
724
+ return {
725
+ ...options,
726
+ documentOptions: mergedDocOpts,
727
+ };
728
+ }
729
+ function close() {
730
+ // Bump the generation so an in-flight open can't resurrect a document
731
+ // after an explicit close.
732
+ openGeneration++;
733
+ loading = false;
734
+ // A deliberate close returns to the empty state, clearing any parked open
735
+ // failure (an app dismissing the error surface calls this).
736
+ openError = null;
737
+ // Reject awaiters of an open that was in flight at close time: 'empty' has
738
+ // no settleWhenReady branch, so they would otherwise never settle.
739
+ rejectWhenReadyPending('Brass.session.whenReady: closed before a document was bound.');
740
+ // 'replace': clears the URL selection and restores the title without an
741
+ // entry.
742
+ setDocument(null, null, 'replace');
743
+ }
744
+ // The active document was evicted remotely (a `doc_gone` the user did not
745
+ // initiate). Park the 'error' state with the gone message and leave the URL
746
+ // pointing at the now-deleted id ('skip', not the 'replace' that would clear
747
+ // it): the error surface corresponds to that id, so a reload re-shows it and
748
+ // the id stays in the URL. Mirrors the failed-open error path in `activate`.
749
+ function markGoneRemote() {
750
+ openGeneration++;
751
+ loading = false;
752
+ openError = new BrassError(DOCUMENT_GONE_MESSAGE, { status: 404 });
753
+ setDocument(null, null, 'skip');
754
+ }
755
+ // Browser Back / Forward moved the URL. Re-bind to the selection the URL now
756
+ // names WITHOUT writing history again (the entry exists, so 'skip'). Only
757
+ // user navigation fires `popstate`; the SDK's own push / replace do not, so
758
+ // this can't feed back into another write.
759
+ function handlePopState() {
760
+ if (deps.authStatus() === 'unknown' || !deps.isSignedIn())
761
+ return;
762
+ const sel = deps.readUrlSelection();
763
+ if (!sel) {
764
+ // Back / Forward landed on a bare URL: return to the empty state ('skip'
765
+ // since the URL is already bare). Reset when a document OR a parked open
766
+ // error is showing: a Forward into a now-deleted doc parks 'error' with a
767
+ // null document, and a Back off it must clear the error and land on empty
768
+ // rather than stranding the stale error surface over a bare URL.
769
+ if (document || openError) {
770
+ openGeneration++;
771
+ loading = false;
772
+ openError = null;
773
+ setDocument(null, null, 'skip');
774
+ }
775
+ return;
776
+ }
777
+ const current = document;
778
+ if (current &&
779
+ current.id === sel.docId &&
780
+ urlBranch(current) === sel.branchId) {
781
+ return; // already showing this selection
782
+ }
783
+ void activate((onSnapshotProgress) => deps.openDocumentById(sel.docId, {
784
+ ...withDefaults(),
785
+ ...(sel.branchId ? { branch: sel.branchId } : {}),
786
+ onSnapshotProgress,
787
+ }, 'skip'), null, 'skip', sel.branchId).catch(() => {
788
+ // Back / Forward to a doc that can't be opened parks the 'error' state
789
+ // (or, warm, keeps the current doc); the URL the user navigated to is
790
+ // left as-is, so the caught rejection needs no cleanup here.
791
+ });
792
+ }
793
+ deps.onUrlPopState(handlePopState);
794
+ // Sign-out (explicit, token expiry, or the boot resolution to signed-out)
795
+ // drops the active document but PRESERVES the URL's `?doc=` selection. A
796
+ // deep-linked visitor lands signed-out on `?doc=<id>`; `Brass.signIn()`
797
+ // carries `window.location.href` as its return_to, so the id must survive
798
+ // in the URL until that click, and `start()` restores it after sign-in.
799
+ // A user-initiated `close()` clears the URL (return to the empty state);
800
+ // sign-out must not, or the deep link is lost before the round-trip.
801
+ function teardownForSignOut() {
802
+ openGeneration++;
803
+ loading = false;
804
+ openError = null;
805
+ setDocument(null, null, 'skip');
806
+ }
807
+ // Sign-out drops the document; the first sign-in restores it. A guest
808
+ // (opened via `?brass_link=`) is never signed in, so the initial
809
+ // resolution arrives here with `signedIn === false`; it must boot the
810
+ // read-only link document rather than tear down to the sign-in surface.
811
+ deps.onUserChange((signedIn) => {
812
+ if (signedIn || deps.guestLinkToken?.() != null) {
813
+ if (!document)
814
+ void start();
815
+ else
816
+ emit();
817
+ }
818
+ else {
819
+ teardownForSignOut();
820
+ }
821
+ });
822
+ const session = {
823
+ get document() {
824
+ return document;
825
+ },
826
+ get documentName() {
827
+ return documentName;
828
+ },
829
+ get status() {
830
+ return status();
831
+ },
832
+ get error() {
833
+ return status() === 'error' ? openError : null;
834
+ },
835
+ get loadingMessage() {
836
+ return loading ? loadingMessage : null;
837
+ },
838
+ get loadingProgress() {
839
+ return loading ? loadingProgress : null;
840
+ },
841
+ get commands() {
842
+ return allCommands();
843
+ },
844
+ get presence() {
845
+ return document?.presence ?? null;
846
+ },
847
+ subscribe(listener) {
848
+ listeners.add(listener);
849
+ try {
850
+ listener(session);
851
+ }
852
+ catch (err) {
853
+ console.error('[Brass] session listener threw:', err);
854
+ }
855
+ return () => {
856
+ listeners.delete(listener);
857
+ };
858
+ },
859
+ start,
860
+ open,
861
+ create,
862
+ pick,
863
+ close,
864
+ async run(commandId) {
865
+ const command = allCommands().find((c) => c.id === commandId);
866
+ if (command)
867
+ await command.run();
868
+ },
869
+ useCanvas(source) {
870
+ // A forwarder: store the canvas at session scope and re-arm it on the
871
+ // active Document each time one becomes active. Apps can also call
872
+ // `doc.useCanvas(canvas)` directly for per-doc control.
873
+ canvas = source;
874
+ // Detach any previous session-armed registration, then arm the active
875
+ // doc.
876
+ if (sessionCanvasUnregister)
877
+ sessionCanvasUnregister();
878
+ sessionCanvasUnregister = document
879
+ ? document.useCanvas(source)
880
+ : null;
881
+ return () => {
882
+ if (canvas === source) {
883
+ canvas = null;
884
+ if (sessionCanvasUnregister)
885
+ sessionCanvasUnregister();
886
+ sessionCanvasUnregister = null;
887
+ }
888
+ };
889
+ },
890
+ whenReady() {
891
+ if (document && status() === 'ready') {
892
+ return Promise.resolve(document);
893
+ }
894
+ // Mirror `status()` (not raw `isSignedIn()`): a link guest is not
895
+ // signed in yet legitimately has a read-only document loading, so only
896
+ // a genuine 'signed-out' rejects here. This is the same terminal test
897
+ // the async `settleWhenReady` path uses; a raw `!isSignedIn()` check
898
+ // would reject every guest before its document binds.
899
+ if (status() === 'signed-out') {
900
+ return Promise.reject(new Error('Brass.session.whenReady: signed out before a document was bound.'));
901
+ }
902
+ // Already-terminal open failure: reject now rather than wait for a bind
903
+ // that isn't coming.
904
+ if (status() === 'error' && openError) {
905
+ return Promise.reject(openError);
906
+ }
907
+ return new Promise((resolve, reject) => {
908
+ whenReadyPending.push({ resolve, reject });
909
+ });
910
+ },
911
+ setDefaultDocumentOptions(options) {
912
+ defaultDocumentOptions = options;
913
+ },
914
+ get _defaultDocumentOptions() {
915
+ return defaultDocumentOptions;
916
+ },
917
+ _noteLocalDelete(docId) {
918
+ localDeletes.add(docId);
919
+ },
920
+ _clearLocalDelete(docId) {
921
+ localDeletes.delete(docId);
922
+ },
923
+ };
924
+ return session;
925
+ }
926
+ //# sourceMappingURL=session.js.map