@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,63 @@
1
+ // Tail ops arrive in one of two shapes and are used in only one.
2
+ //
3
+ // An op that fit its durable row carries its bytes inline. One that
4
+ // spilled to storage carries a short-lived signed URL instead, because a
5
+ // join response holding several megabytes of base64 is a response the
6
+ // server cannot build. Both mean the same thing to a reader: bytes to
7
+ // apply, in seq order.
8
+ //
9
+ // Resolving at the parse boundary is what keeps that difference from
10
+ // spreading. Every path that folds a tail in (a cold open, a reconnect,
11
+ // a version preview) then works on inline blobs exactly as before, and
12
+ // none of them has to be async to do it.
13
+
14
+ import { BrassError } from './errors.js';
15
+
16
+ /** A tail op as the server sends it. */
17
+ export type WireTailOp =
18
+ | { seq: number; op_blob_b64: string }
19
+ | { seq: number; op_blob_url: string };
20
+
21
+ /** A tail op with its bytes in hand. */
22
+ export interface ResolvedTailOp {
23
+ seq: number;
24
+ op_blob_b64: string;
25
+ }
26
+
27
+ function isUrlOp(op: WireTailOp): op is { seq: number; op_blob_url: string } {
28
+ return typeof (op as { op_blob_url?: unknown }).op_blob_url === 'string';
29
+ }
30
+
31
+ /**
32
+ * Fetch any tail op whose bytes live behind a signed URL, leaving inline
33
+ * ops untouched, and return the tail in its original order.
34
+ *
35
+ * Ordering matters and concurrency does not disturb it: the fetches run
36
+ * together but each result lands back at its own index, so the caller
37
+ * still applies ops in ascending seq.
38
+ *
39
+ * A failed fetch throws rather than dropping the op. Skipping one would
40
+ * leave the reader a document missing an edit it will never be sent
41
+ * again, which reads as data loss; failing the join sends it back through
42
+ * a path that can succeed.
43
+ */
44
+ export async function resolveTailOps(
45
+ tailOps: readonly WireTailOp[],
46
+ fetchImpl: typeof fetch = fetch,
47
+ ): Promise<ResolvedTailOp[]> {
48
+ if (!tailOps.some(isUrlOp)) {
49
+ return tailOps as ResolvedTailOp[];
50
+ }
51
+ return Promise.all(
52
+ tailOps.map(async (op) => {
53
+ if (!isUrlOp(op)) return op;
54
+ // The URL is pre-signed, so it carries no Brass headers: adding one
55
+ // would fall outside the signature and be refused.
56
+ const response = await fetchImpl(op.op_blob_url, { method: 'GET' });
57
+ if (!response.ok) {
58
+ throw new BrassError('Tail op fetch failed', { status: response.status });
59
+ }
60
+ return { seq: op.seq, op_blob_b64: await response.text() };
61
+ }),
62
+ );
63
+ }
@@ -0,0 +1,217 @@
1
+ // Pure per-topic subscription lifecycle for `BrassWsClient`. The client
2
+ // owns the socket, timers, and frame queues; every decision about a
3
+ // topic's subscription (what to send, when to hold or drop queued frames,
4
+ // when to surface an error) comes from `step`'s transition table.
5
+ //
6
+ // Phases:
7
+ // - `idle`: not wanted. Errors and acks for it are ignored.
8
+ // - `subscribing`: wanted, first subscribe on this connection unacked
9
+ // (or not yet sendable). Topic-scoped frames are held until the ack.
10
+ // - `established`: acked; frames flow.
11
+ // - `retrying`: the one silent re-subscribe this connection grants is in
12
+ // flight after a mid-stream `not_subscribed` eviction. Further
13
+ // `not_subscribed` errors describe frames that predate the retry and
14
+ // are swallowed; any other reason is the retry's own rejection.
15
+ // - `tornDown`: rejected or evicted with the retry spent. Terminal until
16
+ // a fresh `user_subscribe` or the next socket transition prunes it.
17
+ // `retrySpent` survives into a same-connection re-subscribe so the
18
+ // retry stays one-per-topic-per-connection.
19
+ //
20
+ // Invariant: every transition into `tornDown` emits `send_unsubscribe`,
21
+ // so no server-side subscription row can outlive the client-side state
22
+ // (an orphan row would have its `last_seen` kept fresh by the heartbeat,
23
+ // making it immune to the server's stale reaper).
24
+
25
+ export type TopicPhase =
26
+ | 'idle'
27
+ | 'subscribing'
28
+ | 'established'
29
+ | 'retrying'
30
+ | 'tornDown';
31
+
32
+ export interface TopicState {
33
+ phase: TopicPhase;
34
+ // True once this connection's one silent re-subscribe is consumed.
35
+ // Reset by `socket_open`.
36
+ retrySpent: boolean;
37
+ }
38
+
39
+ export const IDLE_TOPIC: TopicState = { phase: 'idle', retrySpent: false };
40
+
41
+ export type TopicEvent =
42
+ | { type: 'user_subscribe' }
43
+ | { type: 'user_unsubscribe' }
44
+ | { type: 'socket_open' }
45
+ | { type: 'socket_closed' }
46
+ | { type: 'subscribed_ack' }
47
+ | { type: 'error'; reason: string };
48
+
49
+ export type TopicEffect =
50
+ | { kind: 'send_subscribe' }
51
+ | { kind: 'send_unsubscribe' }
52
+ // Flush the frames held for the unacked subscribe, in order.
53
+ | { kind: 'flush_held' }
54
+ | { kind: 'drop_held' }
55
+ // Detach the topic's callbacks: further server frames must not reach
56
+ // them and the user's unsubscribe becomes a no-op.
57
+ | { kind: 'drop_listeners' }
58
+ | { kind: 'fire_error'; reason: string };
59
+
60
+ export interface StepResult {
61
+ state: TopicState;
62
+ effects: TopicEffect[];
63
+ }
64
+
65
+ // True while topic-scoped frames must be held back: sending before the
66
+ // `subscribed` ack races the server's SUB# row write and draws a spurious
67
+ // `forbidden`.
68
+ export function holdsFrames(state: TopicState): boolean {
69
+ return state.phase === 'subscribing' || state.phase === 'retrying';
70
+ }
71
+
72
+ const SEND_SUBSCRIBE: TopicEffect = { kind: 'send_subscribe' };
73
+ const FLUSH_HELD: TopicEffect = { kind: 'flush_held' };
74
+ const DROP_HELD: TopicEffect = { kind: 'drop_held' };
75
+
76
+ // Rejection / spent-retry teardown. `send_unsubscribe` releases any
77
+ // server-side row a completed retry may have re-created (the invariant in
78
+ // the header); `unsubscribe` is idempotent server-side, so sending it when
79
+ // no row exists is safe.
80
+ function tearDown(state: TopicState, reason: string): StepResult {
81
+ return {
82
+ state: { phase: 'tornDown', retrySpent: state.retrySpent },
83
+ effects: [
84
+ DROP_HELD,
85
+ { kind: 'drop_listeners' },
86
+ { kind: 'send_unsubscribe' },
87
+ { kind: 'fire_error', reason },
88
+ ],
89
+ };
90
+ }
91
+
92
+ // A (re)opened socket grants a fresh retry and a fresh subscribe.
93
+ function reopened(): StepResult {
94
+ return {
95
+ state: { phase: 'subscribing', retrySpent: false },
96
+ effects: [SEND_SUBSCRIBE],
97
+ };
98
+ }
99
+
100
+ function unsubscribed(): StepResult {
101
+ return {
102
+ state: IDLE_TOPIC,
103
+ effects: [DROP_HELD, { kind: 'send_unsubscribe' }],
104
+ };
105
+ }
106
+
107
+ export function step(state: TopicState, event: TopicEvent): StepResult {
108
+ switch (state.phase) {
109
+ case 'idle':
110
+ if (event.type === 'user_subscribe') {
111
+ return {
112
+ state: { phase: 'subscribing', retrySpent: false },
113
+ effects: [SEND_SUBSCRIBE],
114
+ };
115
+ }
116
+ return { state, effects: [] };
117
+
118
+ case 'subscribing':
119
+ switch (event.type) {
120
+ case 'subscribed_ack':
121
+ return {
122
+ state: { ...state, phase: 'established' },
123
+ effects: [FLUSH_HELD],
124
+ };
125
+ case 'error':
126
+ // Never established on this connection: the rejection is final.
127
+ return tearDown(state, event.reason);
128
+ case 'socket_open':
129
+ return reopened();
130
+ case 'socket_closed':
131
+ return { state, effects: [DROP_HELD] };
132
+ case 'user_unsubscribe':
133
+ return unsubscribed();
134
+ case 'user_subscribe':
135
+ return { state, effects: [] };
136
+ }
137
+ break;
138
+
139
+ case 'established':
140
+ switch (event.type) {
141
+ case 'error':
142
+ // A mid-stream `not_subscribed` is an eviction (the server
143
+ // dropped the subscription row while this client stayed alive,
144
+ // e.g. a presence reap that raced a live-but-quiet connection),
145
+ // not a rejection: recover silently with one re-subscribe per
146
+ // connection, keeping the listeners and held-frame queue. Any
147
+ // other reason is a genuine refusal (role, scope, membership)
148
+ // that a re-subscribe would just repeat.
149
+ if (event.reason === 'not_subscribed' && !state.retrySpent) {
150
+ return {
151
+ state: { phase: 'retrying', retrySpent: true },
152
+ effects: [SEND_SUBSCRIBE],
153
+ };
154
+ }
155
+ return tearDown(state, event.reason);
156
+ case 'subscribed_ack':
157
+ return { state, effects: [FLUSH_HELD] };
158
+ case 'socket_open':
159
+ return reopened();
160
+ case 'socket_closed':
161
+ return { state: { ...state, phase: 'subscribing' }, effects: [DROP_HELD] };
162
+ case 'user_unsubscribe':
163
+ return unsubscribed();
164
+ case 'user_subscribe':
165
+ return { state, effects: [] };
166
+ }
167
+ break;
168
+
169
+ case 'retrying':
170
+ switch (event.type) {
171
+ case 'error':
172
+ // An eviction draws errors for EVERY frame in flight against the
173
+ // reaped row; the trailing ones land while the retry's subscribe
174
+ // is unacked and describe frames that predate it, so the retry
175
+ // supersedes them. A non-`not_subscribed` reason in this window
176
+ // is the retry's own rejection.
177
+ if (event.reason === 'not_subscribed') {
178
+ return { state, effects: [] };
179
+ }
180
+ return tearDown(state, event.reason);
181
+ case 'subscribed_ack':
182
+ return {
183
+ state: { ...state, phase: 'established' },
184
+ effects: [FLUSH_HELD],
185
+ };
186
+ case 'socket_open':
187
+ return reopened();
188
+ case 'socket_closed':
189
+ return { state: { ...state, phase: 'subscribing' }, effects: [DROP_HELD] };
190
+ case 'user_unsubscribe':
191
+ return unsubscribed();
192
+ case 'user_subscribe':
193
+ return { state, effects: [] };
194
+ }
195
+ break;
196
+
197
+ case 'tornDown':
198
+ switch (event.type) {
199
+ case 'user_subscribe':
200
+ // Same-connection re-subscribe: a spent retry stays spent, so
201
+ // the fresh subscribe rides the retrying phase's error handling.
202
+ return {
203
+ state: {
204
+ phase: state.retrySpent ? 'retrying' : 'subscribing',
205
+ retrySpent: state.retrySpent,
206
+ },
207
+ effects: [SEND_SUBSCRIBE],
208
+ };
209
+ case 'socket_open':
210
+ case 'socket_closed':
211
+ return { state: IDLE_TOPIC, effects: [] };
212
+ default:
213
+ return { state, effects: [] };
214
+ }
215
+ }
216
+ return { state, effects: [] };
217
+ }
package/src/types.ts ADDED
@@ -0,0 +1,75 @@
1
+ export interface BrassUser {
2
+ sub: string;
3
+ email?: string;
4
+ display_name?: string;
5
+ avatar_url?: string;
6
+ }
7
+
8
+ export interface BrassInitOptions {
9
+ // With nothing passed, the SDK resolves all four production origins.
10
+ // Naming any one of the four below opts out of that entirely, so a
11
+ // stack is addressed by its own origins rather than by a mix.
12
+ authBaseUrl?: string;
13
+ // Origin for the Brass data API (documents, organizations, users). The
14
+ // auth surface uses `authBaseUrl`.
15
+ apiBaseUrl?: string;
16
+ // WebSocket endpoint for document op and presence subscriptions. On
17
+ // its own hostname, so it cannot be derived from `apiBaseUrl`. Passing
18
+ // any other `*BaseUrl` leaves it unset on purpose: a production ws
19
+ // origin beside a hand-overridden HTTP origin would connect to a
20
+ // different stack and silently drop events, whereas unset fails loudly
21
+ // on `subscribe`.
22
+ wsBaseUrl?: string;
23
+ // Origin the Brass-hosted UI pages are served from. The hosted-UI
24
+ // launchers load `${dashboardBaseUrl}/embed/*` in a modal iframe.
25
+ dashboardBaseUrl?: string;
26
+ // Pin the hosted-UI modal + embed page to a theme. Defaults to
27
+ // `'light'`; `'auto'` follows the OS preference. An app that forces
28
+ // its own theme should pass the matching value so the modal doesn't
29
+ // mismatch the page that opened it.
30
+ hostedUiTheme?: 'light' | 'dark' | 'auto';
31
+ // Open a document even when this app's declared schema cannot read it
32
+ // (default off). Off, the platform refuses such an open and the SDK asks
33
+ // the person whether to go ahead, so a bookmark or a reload cannot open
34
+ // one silently. On, every open carries the answer already, which is what
35
+ // an app driving opens with nobody watching needs.
36
+ openIncompatibleDocuments?: boolean;
37
+ // Mirror the active document selection to the page URL (default on):
38
+ // writes the doc id (and non-default branch) using `pushState` for a
39
+ // genuine open / branch switch and `replaceState` to pin the boot
40
+ // selection, and restores from the URL on boot and `popstate`. Read
41
+ // the id directly via `Brass.docIdFromUrl()`. Opt out with `false`
42
+ // for an app that owns its URL state (e.g. its own router).
43
+ syncDocToUrl?: boolean;
44
+ // Query-parameter name for the active doc id (default `'doc'`). Set to
45
+ // disambiguate when the app's URL already uses `doc`.
46
+ docUrlParam?: string;
47
+ // Query-parameter name for a non-default branch (default `'branch'`).
48
+ // Present only when the active document is on a non-default branch.
49
+ branchUrlParam?: string;
50
+ // Set `document.title` to the active document's name (default on), so
51
+ // history entries are distinguishable, restoring the original title
52
+ // when no document is open. While a document is open the title is
53
+ // suffixed with the app name (`Doc name · App name`), taken from the
54
+ // page's own `<title>` at the first sync. Opt out with `false` for an
55
+ // app that manages its own title.
56
+ syncDocToTitle?: boolean;
57
+ }
58
+
59
+ // `'unknown'` is the bootstrap window before `Brass.init` settles,
60
+ // distinct from `'signed-out'` (init settled, no user) so an app can
61
+ // render a neutral state instead of flashing its sign-in CTA at a user
62
+ // who turns out to be signed in. Then tracks every sign-in / sign-out.
63
+ // `'guest'` is the anonymous-link case: the page was opened via a
64
+ // public-link token (`?brass_link=`), so there is no signed-in user but
65
+ // the session still opens a read-only document. An app renders its
66
+ // working surface for `'signed-in'` OR `'guest'` (read-only), and its
67
+ // sign-in CTA only for `'signed-out'`.
68
+ export type AuthStatus = 'unknown' | 'signed-in' | 'signed-out' | 'guest';
69
+
70
+ export type UserChangeListener = (user: BrassUser | null) => void;
71
+
72
+ // Fired when Brass.init fails (bad / missing app_id, etc.). Register
73
+ // before init to get visibility into failures that an init wrapped
74
+ // without a `.catch()` would otherwise swallow silently.
75
+ export type ErrorListener = (error: Error) => void;
package/src/undo.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Per-Document undo/redo, backed by Loro's UndoManager: undo is
2
+ // local-peer-scoped (reverts only this peer's ops), group bracketing collapses
3
+ // same-stroke edits into one entry, and remote-import bookkeeping keeps the
4
+ // redo stack honest across other peers' edits.
5
+ import { UndoManager, type LoroDoc } from 'loro-crdt';
6
+
7
+ // Loro's time-based merge is permanently disabled. The undo boundary here is
8
+ // the checkpoint group bracket (`sealStep`), and the two are incompatible: a
9
+ // nonzero merge interval merges adjacent changes by *timestamp*, and that
10
+ // merge reaches ACROSS a `groupEnd()` / `groupStart()` seal, collapsing two
11
+ // separately-sealed steps into one. That silently violates the "every
12
+ // checkpoint is an undo boundary" contract (one Ctrl-Z reverts more than the
13
+ // last operation and the rendered state jumps past where the user expected).
14
+ // Same-stroke collapsing is still handled, by the open group not the clock:
15
+ // every commit between two seals brackets into one entry. Apps tune
16
+ // granularity via `setAutoCheckpointIntervalMs` and `createCheckpoint`.
17
+ const UNDO_MERGE_INTERVAL_MS = 0;
18
+
19
+ // Cap on retained undo steps (Loro's default is 100). 200 at typical AEC
20
+ // entity sizes is a few hundred KB, well below anything that matters.
21
+ const DEFAULT_MAX_UNDO_STEPS = 200;
22
+
23
+ export interface UndoControllerOptions {
24
+ // Cap on retained undo steps. Older steps drop off the bottom of the
25
+ // stack as new ones land.
26
+ maxUndoSteps?: number;
27
+ }
28
+
29
+ // Thin wrapper around Loro's UndoManager exposing only what the Document
30
+ // needs. Owned by the Document; not part of the public SDK surface.
31
+ export class UndoController {
32
+ private readonly _mgr: UndoManager;
33
+ // Whether an implicit group is open. The Document keeps one open between
34
+ // checkpoints so `createCheckpoint` is THE undo boundary: every commit
35
+ // between two checkpoints collapses into one step. `sealStep` closes and
36
+ // reopens the group, the only way to force a boundary into Loro's stack
37
+ // (Loro has no "force boundary here" primitive). This is a hard boundary
38
+ // only because the merge interval is disabled (see above).
39
+ private _groupOpen = false;
40
+
41
+ constructor(loroDoc: LoroDoc, options?: UndoControllerOptions) {
42
+ this._mgr = new UndoManager(loroDoc, {
43
+ mergeInterval: UNDO_MERGE_INTERVAL_MS,
44
+ maxUndoSteps: options?.maxUndoSteps ?? DEFAULT_MAX_UNDO_STEPS,
45
+ });
46
+ // Open the implicit group so commits before the first `createCheckpoint`
47
+ // still bracket as one undo step.
48
+ this._mgr.groupStart();
49
+ this._groupOpen = true;
50
+ }
51
+
52
+ // undo/redo run OUTSIDE the implicit group: Loro groups every commit while a
53
+ // group is open, INCLUDING the one a `redo()` / `undo()` itself produces, so
54
+ // running them inside would fold the reapplied step into the group and the
55
+ // next sealed edit would merge with it (one Ctrl-Z reverts both). Closing
56
+ // first also makes unsealed pending commits their own boundary.
57
+ undo(): boolean {
58
+ return this.outsideGroup(() => this._mgr.undo());
59
+ }
60
+
61
+ redo(): boolean {
62
+ return this.outsideGroup(() => this._mgr.redo());
63
+ }
64
+
65
+ private outsideGroup(op: () => boolean): boolean {
66
+ if (this._groupOpen) {
67
+ this._mgr.groupEnd();
68
+ this._groupOpen = false;
69
+ }
70
+ try {
71
+ return op();
72
+ } finally {
73
+ this._mgr.groupStart();
74
+ this._groupOpen = true;
75
+ }
76
+ }
77
+
78
+ canUndo(): boolean {
79
+ return this._mgr.canUndo();
80
+ }
81
+
82
+ canRedo(): boolean {
83
+ return this._mgr.canRedo();
84
+ }
85
+
86
+ // Seal the current undo step: close the implicit group (commits since the
87
+ // previous seal collapse into one entry) and open a fresh one.
88
+ // `createCheckpoint` and the auto-checkpoint scheduler call this so every
89
+ // checkpoint is a Ctrl-Z boundary. Idempotent: calling twice with no
90
+ // intervening commit is safe (Loro collapses the empty group).
91
+ sealStep(): void {
92
+ if (this._groupOpen) {
93
+ this._mgr.groupEnd();
94
+ this._groupOpen = false;
95
+ }
96
+ this._mgr.groupStart();
97
+ this._groupOpen = true;
98
+ }
99
+
100
+ // Drop both stacks. Called by `_applySnapshot`: the prior entries reference a
101
+ // state the new snapshot superseded.
102
+ clear(): void {
103
+ // Close the open group first so `clear()` sees a clean state (defensive;
104
+ // Loro's `clear` drops both stacks regardless).
105
+ if (this._groupOpen) {
106
+ this._mgr.groupEnd();
107
+ this._groupOpen = false;
108
+ }
109
+ this._mgr.clear();
110
+ this._mgr.groupStart();
111
+ this._groupOpen = true;
112
+ }
113
+
114
+ free(): void {
115
+ if (this._groupOpen) {
116
+ try {
117
+ this._mgr.groupEnd();
118
+ } catch {
119
+ // Best-effort; the underlying manager is about to free.
120
+ }
121
+ this._groupOpen = false;
122
+ }
123
+ this._mgr.free();
124
+ }
125
+ }