@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,678 @@
1
+ // One WS connection per SDK instance, lazily opened on the first `subscribe`.
2
+ // Multiplexes N user callbacks onto one WS-level subscription per topic: the
3
+ // subscribe frame is sent on the first callback for a topic, the unsubscribe
4
+ // frame when the last detaches.
5
+ //
6
+ // Reconnect: on `close` back off (1s..30s) and replay the "wanted-state" set
7
+ // of topics. After ~5 minutes of failure *while foregrounded* bail and fire
8
+ // `onError`, keeping the wanted-state so a later resume / `subscribe`
9
+ // recovers.
10
+ //
11
+ // Background-tab resilience: a hidden tab throttles timers but wall-clock
12
+ // (which the give-up budget measures) keeps advancing, so running the budget
13
+ // while hidden would bail on a healthy connection. The reconnect loop pauses
14
+ // while `document.hidden`; foregrounding (`visibilitychange`) or coming back
15
+ // `online` resets the backoff + budget and reconnects with a fresh token.
16
+ //
17
+ // Heartbeat: send `{ type: 'ping' }` every 60 seconds while any topic is
18
+ // subscribed. One interval serves two purposes: it resets the server's
19
+ // 10-minute idle timeout (only client-originated frames do), and it is the
20
+ // presence liveness signal (the server refreshes `last_seen` on each ping;
21
+ // the staleness threshold is 3 missed heartbeats). The server replies
22
+ // `pong`; a topic-less `{ type: 'error', reason: 'bad_message' }` is
23
+ // accepted as a no-op too, so a deploy without the `ping` handler is
24
+ // tolerated. Both are dropped.
25
+
26
+ import {
27
+ IDLE_TOPIC,
28
+ holdsFrames,
29
+ step,
30
+ type TopicEffect,
31
+ type TopicEvent,
32
+ type TopicState,
33
+ } from './topic-lifecycle.js';
34
+ import { SDK_CLIENT_ID } from './version.js';
35
+
36
+ interface WsClientOptions {
37
+ wsBaseUrl: string;
38
+ appId: string;
39
+ getAccessToken: () => Promise<string | null>;
40
+ // Resolves the current app assertion (the auth proxy mints it alongside
41
+ // the access token). Sent as the `app_assertion` connect param so the
42
+ // server can enforce Layer 2 on the socket the same way it does on HTTP;
43
+ // absent / null just means the connection is not app-attested (the
44
+ // server treats it as first-party, un-gated). Optional so a client
45
+ // constructed without it (or on the first-party dashboard path) connects
46
+ // unchanged.
47
+ getAppAssertion?: () => Promise<string | null>;
48
+ // The anonymous link-guest token (`?brass_link=`), or null on the
49
+ // ordinary signed-in path. When `getAccessToken` returns null but this
50
+ // returns a token, the socket connects with `brass_link=<token>` instead
51
+ // of `token=<accessToken>`, and the server admits a read-only,
52
+ // single-doc-scoped guest connection. Optional so a normal client is
53
+ // unchanged.
54
+ getGuestLinkToken?: () => string | null;
55
+ // WebSocket constructor injection for tests; defaults to
56
+ // `globalThis.WebSocket`.
57
+ webSocketImpl?: typeof WebSocket;
58
+ // Surfaces transport errors (auth fail, reconnect bail, subscribe forbidden)
59
+ // to the client's onError channel.
60
+ fireError: (error: Error) => void;
61
+ }
62
+
63
+ export type WsTopicCallback = (frame: unknown) => void;
64
+
65
+ const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
66
+ const RECONNECT_GIVE_UP_MS = 5 * 60 * 1000;
67
+ const HEARTBEAT_INTERVAL_MS = 60_000;
68
+ // Cap on the frames held for one topic's unacked subscribe, matching the
69
+ // `outboundQueue` bound. Both hold the same kind of mid-reconnect frames
70
+ // (a disconnect rolls every wanted topic back to `subscribing`, so ops sent
71
+ // during the reconnect window are HELD here rather than in `outboundQueue`),
72
+ // so both need the same memory ceiling or a doc edited through a long outage
73
+ // grows one queue without bound. Generous for a single user's in-flight ops.
74
+ const MAX_QUEUED_FRAMES = 1024;
75
+
76
+ type Listeners = Set<WsTopicCallback>;
77
+
78
+ export class BrassWsClient {
79
+ private ws: WebSocket | null = null;
80
+ private connecting = false;
81
+ // Monotonic connect-attempt token. `close()` and each new attempt bump it,
82
+ // so a continuation parked on the token / assertion awaits in
83
+ // `ensureConnected` can tell its attempt was superseded and bail instead
84
+ // of adopting a socket nobody wants (a zombie the heartbeat would keep
85
+ // alive past the gateway idle timeout). Mechanical: any await added to
86
+ // the connect path is covered by re-checking the epoch after it.
87
+ private connectEpoch = 0;
88
+ // Per-topic lifecycle state, driven exclusively through the pure
89
+ // `step` transition table in `topic-lifecycle.ts`: handlers translate
90
+ // frames / user calls into events via `dispatch` and execute the
91
+ // returned effects. Wanted topics (the ones a reconnect replays) are
92
+ // the entries in a non-`tornDown` phase; the listener map below is the
93
+ // callback data those decisions act on.
94
+ private topics = new Map<string, TopicState>();
95
+ private listeners = new Map<string, Listeners>();
96
+ // Outbound frames queued while the socket is not OPEN. Drained on `open`
97
+ // (after subscribe replay) so an optimistic `mutate()` survives a reconnect
98
+ // window.
99
+ private outboundQueue: unknown[] = [];
100
+ // Per-topic queues of frames held while `holdsFrames` says the topic's
101
+ // subscribe is unacked: a topic-scoped frame (`presence`, `op`) sent
102
+ // before the `subscribed` reply races the SUB# row write and the
103
+ // server's conditional update fails as a spurious `forbidden`. The
104
+ // machine's `flush_held` / `drop_held` effects drain or discard them.
105
+ // Bounded per topic (`MAX_QUEUED_FRAMES`, drop-oldest) so a doc edited
106
+ // through a long outage can't grow it without limit.
107
+ private pendingTopicSends = new Map<string, unknown[]>();
108
+ // Warn-once latch for held-queue overflow drops, reset when a topic's held
109
+ // frames flush on the `subscribed` ack, so each unacked window reports once.
110
+ private heldOverflowReported = false;
111
+ private reconnectAttempt = 0;
112
+ private reconnectStartedAt: number | null = null;
113
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
114
+ // Warn-once latch for outbound-queue overflow drops; reset when the queue
115
+ // drains on a successful (re)connect, so each disconnect reports at most
116
+ // once.
117
+ private outboundOverflowReported = false;
118
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
119
+ private closedByUser = false;
120
+ // Page-lifecycle listeners, installed lazily on first connect and removed on
121
+ // user close. Guarded for non-DOM environments (Node, SSR).
122
+ private visibilityListener: (() => void) | null = null;
123
+ private onlineListener: (() => void) | null = null;
124
+
125
+ constructor(private readonly opts: WsClientOptions) {}
126
+
127
+ // Returns an unsubscribe that detaches THIS callback only; the WS-level
128
+ // subscription stays open while other callbacks remain, and the unsubscribe
129
+ // frame is sent only when the last detaches.
130
+ subscribe(topic: string, callback: WsTopicCallback): () => void {
131
+ let set = this.listeners.get(topic);
132
+ const firstForTopic = !set;
133
+ if (!set) {
134
+ set = new Set();
135
+ this.listeners.set(topic, set);
136
+ }
137
+ set.add(callback);
138
+
139
+ if (firstForTopic) {
140
+ this.dispatch(topic, { type: 'user_subscribe' });
141
+ void this.ensureConnected();
142
+ } else if (!this.ws && !this.connecting && !this.reconnectTimer) {
143
+ // The wanted-state held this topic but there's no socket and no
144
+ // reconnect in flight (a prior give-up or a drop while hidden): a fresh
145
+ // subscribe is the retry signal. (A pending reconnect timer is left
146
+ // alone so its backoff isn't reset.)
147
+ this.resume();
148
+ }
149
+
150
+ return () => {
151
+ const current = this.listeners.get(topic);
152
+ if (!current) return;
153
+ current.delete(callback);
154
+ if (current.size > 0) return;
155
+ this.listeners.delete(topic);
156
+ this.dispatch(topic, { type: 'user_unsubscribe' });
157
+ // Drop the connection once no topic is wanted, so no idle socket lingers.
158
+ if (this.listeners.size === 0) {
159
+ this.close();
160
+ }
161
+ };
162
+ }
163
+
164
+ // Feed one event through the topic's machine, commit the new state, then
165
+ // execute the effects. `idle` results are pruned; `tornDown` entries are
166
+ // kept so a same-connection re-subscribe inherits the spent retry, and
167
+ // the next socket transition prunes them.
168
+ private dispatch(topic: string, event: TopicEvent): void {
169
+ const prior = this.topics.get(topic) ?? IDLE_TOPIC;
170
+ const { state, effects } = step(prior, event);
171
+ if (state.phase === 'idle') {
172
+ this.topics.delete(topic);
173
+ } else if (event.type === 'user_subscribe') {
174
+ // Re-insert so the map's order (the reconnect replay order) tracks
175
+ // subscription order even for a topic re-subscribed after teardown.
176
+ this.topics.delete(topic);
177
+ this.topics.set(topic, state);
178
+ } else {
179
+ this.topics.set(topic, state);
180
+ }
181
+ for (const effect of effects) {
182
+ this.runEffect(topic, effect);
183
+ }
184
+ }
185
+
186
+ private runEffect(topic: string, effect: TopicEffect): void {
187
+ switch (effect.kind) {
188
+ case 'send_subscribe':
189
+ // Dropped when the socket is not open; `socket_open` re-derives a
190
+ // `send_subscribe` for every wanted topic, so nothing is lost.
191
+ this.sendIfOpen({ type: 'subscribe', topic });
192
+ return;
193
+ case 'send_unsubscribe':
194
+ this.sendIfOpen({ type: 'unsubscribe', topic });
195
+ return;
196
+ case 'flush_held': {
197
+ const queued = this.pendingTopicSends.get(topic);
198
+ if (queued) {
199
+ this.pendingTopicSends.delete(topic);
200
+ for (const f of queued) this.sendFrame(f);
201
+ }
202
+ // The unacked window closed; re-arm the overflow warning for the next.
203
+ this.heldOverflowReported = false;
204
+ return;
205
+ }
206
+ case 'drop_held':
207
+ this.pendingTopicSends.delete(topic);
208
+ return;
209
+ case 'drop_listeners':
210
+ this.listeners.delete(topic);
211
+ return;
212
+ case 'fire_error':
213
+ this.opts.fireError(
214
+ new Error(`Brass WebSocket subscribe(${topic}): ${effect.reason}`),
215
+ );
216
+ return;
217
+ }
218
+ }
219
+
220
+ // Send an arbitrary frame. Queued when the socket is not open and drained on
221
+ // the next `open` (covering a mutate during the subscribe handshake gap and
222
+ // ops that must survive a mid-session reconnect). Failures (overflow, closed
223
+ // transport) are silently dropped.
224
+ send(frame: unknown): void {
225
+ // Topic-scoped frames sent before the `subscribed` ack race the SUB# row
226
+ // write and surface as spurious `forbidden`, so hold them until the ack
227
+ // (see `pendingTopicSends`). The machine's `holdsFrames` covers every
228
+ // unacked wanted state (fresh subscribe, reconnect replay, retry).
229
+ const topic = topicOfFrame(frame);
230
+ const topicState = topic !== undefined ? this.topics.get(topic) : undefined;
231
+
232
+ if (topic !== undefined && topicState && holdsFrames(topicState)) {
233
+ let queue = this.pendingTopicSends.get(topic);
234
+ if (!queue) {
235
+ queue = [];
236
+ this.pendingTopicSends.set(topic, queue);
237
+ }
238
+ // Bounded like `outboundQueue`: drop the oldest held frame on
239
+ // overflow. Safe to drop here because an op is re-asserted by the
240
+ // per-doc resend tracker (keyed independently on `client_op_id`), and
241
+ // a dropped presence frame is superseded by the next `set()`.
242
+ if (queue.length >= MAX_QUEUED_FRAMES) {
243
+ queue.shift();
244
+ if (!this.heldOverflowReported) {
245
+ this.heldOverflowReported = true;
246
+ this.opts.fireError(
247
+ new Error(
248
+ 'Brass WebSocket: held-frame queue overflowed while a subscribe was unacked; the oldest queued frames were dropped',
249
+ ),
250
+ );
251
+ }
252
+ }
253
+ queue.push(frame);
254
+ // Ensure a connection is in flight; the machine re-emits the
255
+ // subscribe on `socket_open`.
256
+ void this.ensureConnected();
257
+ return;
258
+ }
259
+
260
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
261
+ this.sendFrame(frame);
262
+ return;
263
+ }
264
+ // Bounded so a misbehaving caller can't leak memory; 1024 is generous for
265
+ // a single user's mid-session ops during a reconnect (each typically <1KB).
266
+ if (this.outboundQueue.length >= 1024) {
267
+ this.outboundQueue.shift();
268
+ // A dropped frame can be an op the caller believes is saved-pending;
269
+ // surface it, once per overflow episode so a runaway producer can't
270
+ // flood the error channel.
271
+ if (!this.outboundOverflowReported) {
272
+ this.outboundOverflowReported = true;
273
+ this.opts.fireError(
274
+ new Error(
275
+ 'Brass WebSocket: outbound queue overflowed while disconnected; the oldest queued frames were dropped and will not reach the server',
276
+ ),
277
+ );
278
+ }
279
+ }
280
+ this.outboundQueue.push(frame);
281
+ // Kick the connect path if no socket is open yet (a caller that `send`s
282
+ // without ever subscribing), or the queue would never drain.
283
+ if (!this.ws && !this.connecting && !this.closedByUser) {
284
+ void this.ensureConnected();
285
+ }
286
+ }
287
+
288
+ close(): void {
289
+ this.closedByUser = true;
290
+ // Supersede any in-flight connect attempt, and clear `connecting` so a
291
+ // subscribe / send that lands after this close starts its own fresh
292
+ // attempt through the normal entry path (fresh token, lifecycle
293
+ // listeners re-armed) instead of finding the entry guard held by a
294
+ // continuation that is about to bail.
295
+ this.connectEpoch++;
296
+ this.connecting = false;
297
+ this.removeLifecycleListeners();
298
+ if (this.reconnectTimer) {
299
+ clearTimeout(this.reconnectTimer);
300
+ this.reconnectTimer = null;
301
+ }
302
+ this.stopHeartbeat();
303
+ const ws = this.ws;
304
+ this.ws = null;
305
+ // `socket_closed` per topic: drops held frames and rolls wanted topics
306
+ // back to `subscribing` so a later resume replays them.
307
+ for (const topic of this.topics.keys()) {
308
+ this.dispatch(topic, { type: 'socket_closed' });
309
+ }
310
+ this.outboundQueue = [];
311
+ if (ws && ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
312
+ try {
313
+ ws.close();
314
+ } catch {
315
+ // Closing an already-closing socket is fine.
316
+ }
317
+ }
318
+ }
319
+
320
+ private async ensureConnected(): Promise<void> {
321
+ if (this.ws && this.ws.readyState === this.ws.OPEN) return;
322
+ if (this.connecting) return;
323
+ this.ensureLifecycleListeners();
324
+ this.connecting = true;
325
+ this.closedByUser = false;
326
+ const epoch = ++this.connectEpoch;
327
+ try {
328
+ const token = await this.opts.getAccessToken();
329
+ if (epoch !== this.connectEpoch) return;
330
+ // Anonymous link guest: no access token, but a link token authorises a
331
+ // read-only connection.
332
+ const guestToken = token ? null : this.opts.getGuestLinkToken?.() ?? null;
333
+ if (!token && !guestToken) {
334
+ this.connecting = false;
335
+ this.opts.fireError(
336
+ new Error('Brass WebSocket subscribe: not signed in'),
337
+ );
338
+ return;
339
+ }
340
+ const Ctor = this.opts.webSocketImpl ?? globalThis.WebSocket;
341
+ if (!Ctor) {
342
+ this.connecting = false;
343
+ this.opts.fireError(
344
+ new Error('Brass WebSocket subscribe: WebSocket is not available'),
345
+ );
346
+ return;
347
+ }
348
+ const assertion =
349
+ token && this.opts.getAppAssertion
350
+ ? await this.opts.getAppAssertion()
351
+ : null;
352
+ if (epoch !== this.connectEpoch) return;
353
+ const url = token
354
+ ? this.buildUrl(token, assertion)
355
+ : this.buildGuestUrl(guestToken as string);
356
+ // The OPEN guard at the top returns only for a socket that is OPEN; a
357
+ // CONNECTING/CLOSING one falls through to here, so a prior socket can
358
+ // still be around. Adopt the new socket as `this.ws` FIRST, then close
359
+ // the old one: its `close` then fails the identity guard below and is
360
+ // ignored, rather than nulling out the live socket.
361
+ const prev = this.ws;
362
+ const ws = new Ctor(url);
363
+ this.ws = ws;
364
+ // Guard every lifecycle handler on socket identity. A superseded
365
+ // socket's delayed `close` (common on a throttled hidden tab) would
366
+ // otherwise run `onClose`, null out the live `this.ws`, roll every
367
+ // topic back to `subscribing`, and schedule a spurious reconnect.
368
+ ws.addEventListener('open', () => {
369
+ if (this.ws === ws) this.onOpen();
370
+ });
371
+ ws.addEventListener('message', (ev: MessageEvent) => {
372
+ if (this.ws === ws) this.onMessage(ev);
373
+ });
374
+ ws.addEventListener('close', () => {
375
+ if (this.ws === ws) this.onClose();
376
+ });
377
+ ws.addEventListener('error', () => {
378
+ // The `close` handler runs after `error` and drives reconnect;
379
+ // swallowing keeps a transient network error from bubbling.
380
+ });
381
+ if (prev) {
382
+ try {
383
+ prev.close();
384
+ } catch {
385
+ // Closing an already-closing socket is fine.
386
+ }
387
+ }
388
+ } catch (err) {
389
+ // A superseded attempt's failure is not this client's failure: the
390
+ // `connecting` flag now belongs to the newer attempt, so leave it.
391
+ if (epoch !== this.connectEpoch) return;
392
+ this.connecting = false;
393
+ this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
394
+ }
395
+ }
396
+
397
+ private buildUrl(token: string, assertion: string | null): string {
398
+ const base = this.opts.wsBaseUrl.replace(/\/$/, '');
399
+ const sep = base.includes('?') ? '&' : '?';
400
+ const assertionParam = assertion
401
+ ? `&app_assertion=${encodeURIComponent(assertion)}`
402
+ : '';
403
+ // `client` feeds the ws.connect audit so the deployed SDK version
404
+ // distribution is measurable per live connection.
405
+ return `${base}${sep}app_id=${encodeURIComponent(
406
+ this.opts.appId,
407
+ )}&token=${encodeURIComponent(token)}${assertionParam}&client=${encodeURIComponent(SDK_CLIENT_ID)}`;
408
+ }
409
+
410
+ // Anonymous link-guest connect URL: the link token authorises the socket
411
+ // (no access token, no app assertion). The server resolves it to a
412
+ // read-only, single-doc-scoped connection.
413
+ private buildGuestUrl(guestToken: string): string {
414
+ const base = this.opts.wsBaseUrl.replace(/\/$/, '');
415
+ const sep = base.includes('?') ? '&' : '?';
416
+ return `${base}${sep}app_id=${encodeURIComponent(
417
+ this.opts.appId,
418
+ )}&brass_link=${encodeURIComponent(guestToken)}`;
419
+ }
420
+
421
+ private onOpen(): void {
422
+ this.connecting = false;
423
+ this.reconnectAttempt = 0;
424
+ this.reconnectStartedAt = null;
425
+ // `socket_open` per topic: re-subscribes every wanted topic (in
426
+ // subscription order) with a fresh retry grant, and prunes `tornDown`
427
+ // leftovers. Runs before the outbound drain so the server sees the
428
+ // subscribes first; otherwise an op right after a reconnect races its
429
+ // subscribe and gets `bad_message` (not subscribed yet).
430
+ for (const topic of this.topics.keys()) {
431
+ this.dispatch(topic, { type: 'socket_open' });
432
+ }
433
+ this.flushOutbound();
434
+ this.startHeartbeat();
435
+ }
436
+
437
+ private flushOutbound(): void {
438
+ if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
439
+ const drained = this.outboundQueue;
440
+ this.outboundQueue = [];
441
+ this.outboundOverflowReported = false;
442
+ for (const frame of drained) {
443
+ this.sendFrame(frame);
444
+ }
445
+ }
446
+
447
+ private onMessage(ev: MessageEvent): void {
448
+ let frame: unknown;
449
+ try {
450
+ frame = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));
451
+ } catch {
452
+ // Drop garbage; the server protocol is strict JSON.
453
+ return;
454
+ }
455
+ if (!isObject(frame)) return;
456
+ const type = frame.type;
457
+ if (typeof type !== 'string') return;
458
+
459
+ if (type === 'subscribed' && typeof frame.topic === 'string') {
460
+ // `flush_held` drains the frames held back for the unacked subscribe.
461
+ this.dispatch(frame.topic, { type: 'subscribed_ack' });
462
+ return;
463
+ }
464
+ if (type === 'unsubscribed') {
465
+ // No callbacks fire; the user-side unsubscribe already returned.
466
+ return;
467
+ }
468
+ if (type === 'pong') {
469
+ // Heartbeat reply; drop silently (the round-trip already reset API
470
+ // Gateway's idle-timeout).
471
+ return;
472
+ }
473
+ if (type === 'error') {
474
+ const topic = typeof frame.topic === 'string' ? frame.topic : undefined;
475
+ const reason = typeof frame.reason === 'string' ? frame.reason : 'error';
476
+ // A deploy without the `ping` handler replies to our ping with a
477
+ // topic-less `bad_message` instead of `pong`; treat it as a heartbeat
478
+ // ack (see the header).
479
+ if (reason === 'bad_message' && !topic) return;
480
+ if (topic) {
481
+ // The machine decides: silent retry, swallow, or teardown +
482
+ // `fire_error`. A topic with no live state (already unsubscribed
483
+ // or torn down our side) swallows: the canonical case is a
484
+ // presence-update racing a `doc_gone` eviction, whose late
485
+ // `forbidden` reply would otherwise surface as a misleading
486
+ // "subscribe(...): forbidden" with no subscribe in flight to fail.
487
+ this.dispatch(topic, { type: 'error', reason });
488
+ return;
489
+ }
490
+ this.opts.fireError(
491
+ new Error(`Brass WebSocket subscribe: ${reason}`),
492
+ );
493
+ return;
494
+ }
495
+ // Topic-scoped server frames: fan out to every callback for the topic.
496
+ if (typeof frame.topic === 'string') {
497
+ const set = this.listeners.get(frame.topic);
498
+ if (!set) return;
499
+ for (const cb of set) {
500
+ try {
501
+ cb(frame);
502
+ } catch {
503
+ // A bad listener must not break the others or kill the read loop.
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ private onClose(): void {
510
+ this.stopHeartbeat();
511
+ this.ws = null;
512
+ // Held frames are tied to the connection's unacked subscribes;
513
+ // `socket_closed` drops them and preserves wantedness for the replay.
514
+ for (const topic of this.topics.keys()) {
515
+ this.dispatch(topic, { type: 'socket_closed' });
516
+ }
517
+ this.connecting = false;
518
+ if (this.closedByUser) return;
519
+ if (this.listeners.size === 0) return;
520
+ this.scheduleReconnect();
521
+ }
522
+
523
+ private scheduleReconnect(): void {
524
+ // Don't run the loop while hidden: throttled timers but advancing
525
+ // wall-clock would burn the give-up budget without real attempts and bail
526
+ // on a healthy connection. `resume()` restarts with a fresh budget on
527
+ // foreground / network recovery.
528
+ if (this.isHidden()) return;
529
+ if (this.reconnectStartedAt === null) {
530
+ this.reconnectStartedAt = Date.now();
531
+ }
532
+ if (Date.now() - this.reconnectStartedAt >= RECONNECT_GIVE_UP_MS) {
533
+ this.opts.fireError(
534
+ new Error(
535
+ 'Brass WebSocket subscribe: reconnect gave up after 5 minutes',
536
+ ),
537
+ );
538
+ // Keep the wanted-state: a resume / fresh `subscribe` recovers by
539
+ // replaying it. The budget resets so resume starts clean; clearing
540
+ // listeners here would make the bail permanent (the background-tab bug
541
+ // this guards against).
542
+ this.reconnectAttempt = 0;
543
+ this.reconnectStartedAt = null;
544
+ return;
545
+ }
546
+ const idx = Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1);
547
+ const delay = RECONNECT_DELAYS_MS[idx]!;
548
+ this.reconnectAttempt += 1;
549
+ this.reconnectTimer = setTimeout(() => {
550
+ this.reconnectTimer = null;
551
+ void this.ensureConnected();
552
+ }, delay);
553
+ }
554
+
555
+ // Foreground return / network recovery: reset the backoff + give-up budget
556
+ // and reconnect promptly with a fresh token instead of waiting out a long
557
+ // delay or staying dead after a bail. No-op when nothing is wanted, the user
558
+ // closed us, or a socket is already open.
559
+ private resume(): void {
560
+ if (this.closedByUser) return;
561
+ if (this.listeners.size === 0) return;
562
+ if (this.ws && this.ws.readyState === this.ws.OPEN) return;
563
+ if (this.reconnectTimer) {
564
+ clearTimeout(this.reconnectTimer);
565
+ this.reconnectTimer = null;
566
+ }
567
+ this.reconnectAttempt = 0;
568
+ this.reconnectStartedAt = null;
569
+ void this.ensureConnected();
570
+ }
571
+
572
+ private isHidden(): boolean {
573
+ const doc = (globalThis as { document?: { hidden?: boolean } }).document;
574
+ return doc?.hidden === true;
575
+ }
576
+
577
+ private ensureLifecycleListeners(): void {
578
+ if (this.visibilityListener || this.onlineListener) return;
579
+ const doc = (
580
+ globalThis as {
581
+ document?: {
582
+ addEventListener?: (type: string, listener: () => void) => void;
583
+ hidden?: boolean;
584
+ };
585
+ }
586
+ ).document;
587
+ if (doc && typeof doc.addEventListener === 'function') {
588
+ const listener = (): void => {
589
+ if (doc.hidden !== true) this.resume();
590
+ };
591
+ this.visibilityListener = listener;
592
+ doc.addEventListener('visibilitychange', listener);
593
+ }
594
+ if (typeof globalThis.addEventListener === 'function') {
595
+ const listener = (): void => this.resume();
596
+ this.onlineListener = listener;
597
+ globalThis.addEventListener('online', listener);
598
+ }
599
+ }
600
+
601
+ private removeLifecycleListeners(): void {
602
+ const doc = (
603
+ globalThis as {
604
+ document?: {
605
+ removeEventListener?: (type: string, listener: () => void) => void;
606
+ };
607
+ }
608
+ ).document;
609
+ if (
610
+ this.visibilityListener &&
611
+ doc &&
612
+ typeof doc.removeEventListener === 'function'
613
+ ) {
614
+ doc.removeEventListener('visibilitychange', this.visibilityListener);
615
+ }
616
+ this.visibilityListener = null;
617
+ if (
618
+ this.onlineListener &&
619
+ typeof globalThis.removeEventListener === 'function'
620
+ ) {
621
+ globalThis.removeEventListener('online', this.onlineListener);
622
+ }
623
+ this.onlineListener = null;
624
+ }
625
+
626
+ private startHeartbeat(): void {
627
+ this.stopHeartbeat();
628
+ this.heartbeatTimer = setInterval(() => {
629
+ this.sendIfOpen({ type: 'ping' });
630
+ }, HEARTBEAT_INTERVAL_MS);
631
+ }
632
+
633
+ private stopHeartbeat(): void {
634
+ if (this.heartbeatTimer) {
635
+ clearInterval(this.heartbeatTimer);
636
+ this.heartbeatTimer = null;
637
+ }
638
+ }
639
+
640
+ private sendIfOpen(frame: unknown): void {
641
+ if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
642
+ this.sendFrame(frame);
643
+ }
644
+
645
+ private sendFrame(frame: unknown): void {
646
+ if (!this.ws) return;
647
+ try {
648
+ this.ws.send(JSON.stringify(frame));
649
+ } catch {
650
+ // A send failure means the connection is half-dead; `close` fires
651
+ // shortly and drives reconnect.
652
+ }
653
+ }
654
+ }
655
+
656
+ function isObject(value: unknown): value is Record<string, unknown> {
657
+ return typeof value === 'object' && value !== null;
658
+ }
659
+
660
+ // The frame's topic, if any. `send()` uses it to decide whether the frame is
661
+ // subject to the subscribe-pending hold (see `pendingTopicSends`). Returns
662
+ // `undefined` for transport frames (`subscribe` / `unsubscribe` / `ping` /
663
+ // `pong`), which the hold must NOT apply to (the subscribe is what it waits
664
+ // for).
665
+ function topicOfFrame(frame: unknown): string | undefined {
666
+ if (!isObject(frame)) return undefined;
667
+ const type = frame['type'];
668
+ if (
669
+ type === 'subscribe' ||
670
+ type === 'unsubscribe' ||
671
+ type === 'ping' ||
672
+ type === 'pong'
673
+ ) {
674
+ return undefined;
675
+ }
676
+ const topic = frame['topic'];
677
+ return typeof topic === 'string' ? topic : undefined;
678
+ }