@brass-build/client 0.4.1 → 0.5.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 (323) hide show
  1. package/AGENTS.md +124 -45
  2. package/CHANGELOG.md +80 -4
  3. package/README.md +666 -179
  4. package/dist/api-fetch.d.ts +8 -0
  5. package/dist/api-fetch.d.ts.map +1 -1
  6. package/dist/api-fetch.js +35 -8
  7. package/dist/api-fetch.js.map +1 -1
  8. package/dist/batched-get.d.ts +11 -0
  9. package/dist/batched-get.d.ts.map +1 -0
  10. package/dist/batched-get.js +92 -0
  11. package/dist/batched-get.js.map +1 -0
  12. package/dist/call-service.d.ts +4 -0
  13. package/dist/call-service.d.ts.map +1 -1
  14. package/dist/call-service.js +66 -3
  15. package/dist/call-service.js.map +1 -1
  16. package/dist/client.d.ts +48 -2
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +918 -115
  19. package/dist/client.js.map +1 -1
  20. package/dist/document-checkpoints.d.ts +25 -2
  21. package/dist/document-checkpoints.d.ts.map +1 -1
  22. package/dist/document-checkpoints.js +258 -61
  23. package/dist/document-checkpoints.js.map +1 -1
  24. package/dist/document-commands.d.ts.map +1 -1
  25. package/dist/document-commands.js +5 -3
  26. package/dist/document-commands.js.map +1 -1
  27. package/dist/document-contents.d.ts +10 -0
  28. package/dist/document-contents.d.ts.map +1 -1
  29. package/dist/document-contents.js +23 -1
  30. package/dist/document-contents.js.map +1 -1
  31. package/dist/document-set-subscription.d.ts +56 -0
  32. package/dist/document-set-subscription.d.ts.map +1 -0
  33. package/dist/document-set-subscription.js +240 -0
  34. package/dist/document-set-subscription.js.map +1 -0
  35. package/dist/document-signals.d.ts +6 -0
  36. package/dist/document-signals.d.ts.map +1 -1
  37. package/dist/document-signals.js +31 -1
  38. package/dist/document-signals.js.map +1 -1
  39. package/dist/document-streams.d.ts +36 -2
  40. package/dist/document-streams.d.ts.map +1 -1
  41. package/dist/document-streams.js +69 -18
  42. package/dist/document-streams.js.map +1 -1
  43. package/dist/document-thumbnail-capture.d.ts +12 -2
  44. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  45. package/dist/document-thumbnail-capture.js +103 -27
  46. package/dist/document-thumbnail-capture.js.map +1 -1
  47. package/dist/documents-namespace.d.ts +92 -10
  48. package/dist/documents-namespace.d.ts.map +1 -1
  49. package/dist/documents-namespace.js +285 -39
  50. package/dist/documents-namespace.js.map +1 -1
  51. package/dist/documents.d.ts +173 -8
  52. package/dist/documents.d.ts.map +1 -1
  53. package/dist/documents.js +1778 -310
  54. package/dist/documents.js.map +1 -1
  55. package/dist/errors.d.ts +3 -3
  56. package/dist/errors.d.ts.map +1 -1
  57. package/dist/errors.js.map +1 -1
  58. package/dist/fetch-retry.d.ts +1 -0
  59. package/dist/fetch-retry.d.ts.map +1 -1
  60. package/dist/fetch-retry.js +2 -0
  61. package/dist/fetch-retry.js.map +1 -1
  62. package/dist/file-choice.d.ts +2 -0
  63. package/dist/file-choice.d.ts.map +1 -0
  64. package/dist/file-choice.js +27 -0
  65. package/dist/file-choice.js.map +1 -0
  66. package/dist/guest-link.d.ts +18 -1
  67. package/dist/guest-link.d.ts.map +1 -1
  68. package/dist/guest-link.js +329 -1
  69. package/dist/guest-link.js.map +1 -1
  70. package/dist/handle-types.d.ts +67 -5
  71. package/dist/handle-types.d.ts.map +1 -1
  72. package/dist/handles.d.ts +15 -19
  73. package/dist/handles.d.ts.map +1 -1
  74. package/dist/handles.js +231 -76
  75. package/dist/handles.js.map +1 -1
  76. package/dist/hosted-ui.d.ts +61 -0
  77. package/dist/hosted-ui.d.ts.map +1 -1
  78. package/dist/hosted-ui.js +94 -42
  79. package/dist/hosted-ui.js.map +1 -1
  80. package/dist/import-contract.d.ts +74 -0
  81. package/dist/import-contract.d.ts.map +1 -0
  82. package/dist/import-contract.js +163 -0
  83. package/dist/import-contract.js.map +1 -0
  84. package/dist/import-guest.d.ts +9 -0
  85. package/dist/import-guest.d.ts.map +1 -0
  86. package/dist/import-guest.js +155 -0
  87. package/dist/import-guest.js.map +1 -0
  88. package/dist/import-report.d.ts +17 -0
  89. package/dist/import-report.d.ts.map +1 -0
  90. package/dist/import-report.js +7 -0
  91. package/dist/import-report.js.map +1 -0
  92. package/dist/importer-wait.d.ts +2 -0
  93. package/dist/importer-wait.d.ts.map +1 -0
  94. package/dist/importer-wait.js +5 -0
  95. package/dist/importer-wait.js.map +1 -0
  96. package/dist/importer.d.ts +18 -4
  97. package/dist/importer.d.ts.map +1 -1
  98. package/dist/importer.js +322 -105
  99. package/dist/importer.js.map +1 -1
  100. package/dist/in-development.d.ts +2 -0
  101. package/dist/in-development.d.ts.map +1 -0
  102. package/dist/in-development.js +10 -0
  103. package/dist/in-development.js.map +1 -0
  104. package/dist/index.d.ts +16 -8
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +24 -8
  107. package/dist/index.js.map +1 -1
  108. package/dist/init-keys.d.ts +1 -1
  109. package/dist/init-keys.d.ts.map +1 -1
  110. package/dist/init-keys.js +6 -1
  111. package/dist/init-keys.js.map +1 -1
  112. package/dist/input-documents.d.ts +67 -0
  113. package/dist/input-documents.d.ts.map +1 -0
  114. package/dist/input-documents.js +693 -0
  115. package/dist/input-documents.js.map +1 -0
  116. package/dist/inputs-status.d.ts +80 -0
  117. package/dist/inputs-status.d.ts.map +1 -0
  118. package/dist/inputs-status.js +460 -0
  119. package/dist/inputs-status.js.map +1 -0
  120. package/dist/inputs.d.ts +2 -0
  121. package/dist/inputs.d.ts.map +1 -0
  122. package/dist/inputs.js +793 -0
  123. package/dist/inputs.js.map +1 -0
  124. package/dist/internal.d.ts +6 -1
  125. package/dist/internal.d.ts.map +1 -1
  126. package/dist/internal.js +4 -1
  127. package/dist/internal.js.map +1 -1
  128. package/dist/join-content.d.ts +33 -0
  129. package/dist/join-content.d.ts.map +1 -0
  130. package/dist/join-content.js +114 -0
  131. package/dist/join-content.js.map +1 -0
  132. package/dist/join-stamp.d.ts +3 -0
  133. package/dist/join-stamp.d.ts.map +1 -0
  134. package/dist/join-stamp.js +12 -0
  135. package/dist/join-stamp.js.map +1 -0
  136. package/dist/manifest-drift.d.ts +9 -0
  137. package/dist/manifest-drift.d.ts.map +1 -0
  138. package/dist/manifest-drift.js +46 -0
  139. package/dist/manifest-drift.js.map +1 -0
  140. package/dist/open-document.d.ts +109 -2
  141. package/dist/open-document.d.ts.map +1 -1
  142. package/dist/open-document.js +1822 -811
  143. package/dist/open-document.js.map +1 -1
  144. package/dist/page-restore.d.ts +12 -0
  145. package/dist/page-restore.d.ts.map +1 -0
  146. package/dist/page-restore.js +23 -0
  147. package/dist/page-restore.js.map +1 -0
  148. package/dist/placement.d.ts +13 -0
  149. package/dist/placement.d.ts.map +1 -0
  150. package/dist/placement.js +34 -0
  151. package/dist/placement.js.map +1 -0
  152. package/dist/presence.d.ts +1 -0
  153. package/dist/presence.d.ts.map +1 -1
  154. package/dist/presence.js +16 -4
  155. package/dist/presence.js.map +1 -1
  156. package/dist/read-only-document.d.ts +87 -0
  157. package/dist/read-only-document.d.ts.map +1 -0
  158. package/dist/read-only-document.js +4 -0
  159. package/dist/read-only-document.js.map +1 -0
  160. package/dist/record-paging.d.ts +63 -6
  161. package/dist/record-paging.d.ts.map +1 -1
  162. package/dist/record-paging.js +122 -29
  163. package/dist/record-paging.js.map +1 -1
  164. package/dist/references.d.ts +40 -0
  165. package/dist/references.d.ts.map +1 -0
  166. package/dist/references.js +135 -0
  167. package/dist/references.js.map +1 -0
  168. package/dist/request-lane.d.ts +5 -0
  169. package/dist/request-lane.d.ts.map +1 -0
  170. package/dist/request-lane.js +48 -0
  171. package/dist/request-lane.js.map +1 -0
  172. package/dist/sealed-arrow.d.ts +170 -0
  173. package/dist/sealed-arrow.d.ts.map +1 -0
  174. package/dist/sealed-arrow.js +769 -0
  175. package/dist/sealed-arrow.js.map +1 -0
  176. package/dist/sealed-cache.d.ts +21 -0
  177. package/dist/sealed-cache.d.ts.map +1 -0
  178. package/dist/sealed-cache.js +247 -0
  179. package/dist/sealed-cache.js.map +1 -0
  180. package/dist/sealed-encode.d.ts +87 -0
  181. package/dist/sealed-encode.d.ts.map +1 -0
  182. package/dist/sealed-encode.js +876 -0
  183. package/dist/sealed-encode.js.map +1 -0
  184. package/dist/sealed-handles.d.ts +45 -0
  185. package/dist/sealed-handles.d.ts.map +1 -0
  186. package/dist/sealed-handles.js +408 -0
  187. package/dist/sealed-handles.js.map +1 -0
  188. package/dist/sealed-store.d.ts +242 -0
  189. package/dist/sealed-store.d.ts.map +1 -0
  190. package/dist/sealed-store.js +1155 -0
  191. package/dist/sealed-store.js.map +1 -0
  192. package/dist/serializer.d.ts +6 -15
  193. package/dist/serializer.d.ts.map +1 -1
  194. package/dist/session-toolbar.d.ts +20 -5
  195. package/dist/session-toolbar.d.ts.map +1 -1
  196. package/dist/session-toolbar.js +419 -67
  197. package/dist/session-toolbar.js.map +1 -1
  198. package/dist/session.d.ts +38 -1
  199. package/dist/session.d.ts.map +1 -1
  200. package/dist/session.js +562 -79
  201. package/dist/session.js.map +1 -1
  202. package/dist/sha256.d.ts +3 -0
  203. package/dist/sha256.d.ts.map +1 -0
  204. package/dist/sha256.js +74 -0
  205. package/dist/sha256.js.map +1 -0
  206. package/dist/stable-handles.d.ts +19 -0
  207. package/dist/stable-handles.d.ts.map +1 -0
  208. package/dist/stable-handles.js +193 -0
  209. package/dist/stable-handles.js.map +1 -0
  210. package/dist/staged-seal.d.ts +2 -0
  211. package/dist/staged-seal.d.ts.map +1 -0
  212. package/dist/staged-seal.js +76 -0
  213. package/dist/staged-seal.js.map +1 -0
  214. package/dist/subscriber-delivery.d.ts +8 -0
  215. package/dist/subscriber-delivery.d.ts.map +1 -0
  216. package/dist/subscriber-delivery.js +31 -0
  217. package/dist/subscriber-delivery.js.map +1 -0
  218. package/dist/tail-op-import.d.ts +10 -0
  219. package/dist/tail-op-import.d.ts.map +1 -0
  220. package/dist/tail-op-import.js +33 -0
  221. package/dist/tail-op-import.js.map +1 -0
  222. package/dist/topic-lifecycle.d.ts +6 -0
  223. package/dist/topic-lifecycle.d.ts.map +1 -1
  224. package/dist/topic-lifecycle.js +32 -2
  225. package/dist/topic-lifecycle.js.map +1 -1
  226. package/dist/trial-replace-dialog.d.ts +10 -0
  227. package/dist/trial-replace-dialog.d.ts.map +1 -0
  228. package/dist/trial-replace-dialog.js +153 -0
  229. package/dist/trial-replace-dialog.js.map +1 -0
  230. package/dist/types.d.ts +8 -1
  231. package/dist/types.d.ts.map +1 -1
  232. package/dist/undo.d.ts +60 -5
  233. package/dist/undo.d.ts.map +1 -1
  234. package/dist/undo.js +344 -75
  235. package/dist/undo.js.map +1 -1
  236. package/dist/version-bar.d.ts +1 -0
  237. package/dist/version-bar.d.ts.map +1 -1
  238. package/dist/version-bar.js +2 -1
  239. package/dist/version-bar.js.map +1 -1
  240. package/dist/version.d.ts +2 -2
  241. package/dist/version.js +1 -1
  242. package/dist/ws-client.d.ts +21 -0
  243. package/dist/ws-client.d.ts.map +1 -1
  244. package/dist/ws-client.js +192 -8
  245. package/dist/ws-client.js.map +1 -1
  246. package/dist/ws-frame-limit.d.ts +1 -0
  247. package/dist/ws-frame-limit.d.ts.map +1 -1
  248. package/dist/ws-frame-limit.js.map +1 -1
  249. package/docs/app-manifest.md +121 -17
  250. package/docs/custom-chrome.md +24 -16
  251. package/docs/hosted-ui-advanced.md +38 -6
  252. package/docs/imported-documents.md +104 -51
  253. package/docs/presence.md +16 -4
  254. package/docs/schemas.md +8 -5
  255. package/docs/services.md +6 -6
  256. package/docs/streams.md +139 -150
  257. package/docs/text-fields.md +26 -19
  258. package/package.json +5 -2
  259. package/src/api-fetch.ts +53 -3
  260. package/src/auth-flow.ts +1 -1
  261. package/src/batched-get.ts +117 -0
  262. package/src/call-service.ts +78 -3
  263. package/src/client.ts +1022 -112
  264. package/src/document-checkpoints.ts +307 -65
  265. package/src/document-commands.ts +5 -5
  266. package/src/document-contents.ts +27 -1
  267. package/src/document-set-subscription.ts +274 -0
  268. package/src/document-signals.ts +47 -5
  269. package/src/document-streams.ts +85 -16
  270. package/src/document-thumbnail-capture.ts +118 -32
  271. package/src/documents-namespace.ts +539 -84
  272. package/src/documents.ts +2017 -348
  273. package/src/errors.ts +17 -6
  274. package/src/fetch-retry.ts +5 -0
  275. package/src/file-choice.ts +26 -0
  276. package/src/guest-link.ts +362 -1
  277. package/src/handle-types.ts +73 -47
  278. package/src/handles.ts +253 -83
  279. package/src/hosted-ui.ts +211 -50
  280. package/src/import-contract.ts +227 -0
  281. package/src/import-guest.ts +193 -0
  282. package/src/import-report.ts +24 -0
  283. package/src/importer-wait.ts +4 -0
  284. package/src/importer.ts +386 -131
  285. package/src/in-development.ts +10 -0
  286. package/src/index.ts +76 -16
  287. package/src/init-keys.ts +6 -1
  288. package/src/input-documents.ts +819 -0
  289. package/src/inputs-status.ts +598 -0
  290. package/src/inputs.ts +1090 -0
  291. package/src/internal.ts +7 -0
  292. package/src/join-content.ts +135 -0
  293. package/src/join-stamp.ts +12 -0
  294. package/src/manifest-drift.ts +54 -0
  295. package/src/open-document.ts +2346 -930
  296. package/src/page-restore.ts +38 -0
  297. package/src/placement.ts +54 -0
  298. package/src/presence.ts +21 -5
  299. package/src/read-only-document.ts +133 -0
  300. package/src/record-paging.ts +147 -25
  301. package/src/references.ts +207 -0
  302. package/src/request-lane.ts +59 -0
  303. package/src/sealed-arrow.ts +843 -0
  304. package/src/sealed-cache.ts +267 -0
  305. package/src/sealed-encode.ts +1015 -0
  306. package/src/sealed-handles.ts +486 -0
  307. package/src/sealed-store.ts +1330 -0
  308. package/src/serializer.ts +8 -20
  309. package/src/session-toolbar.ts +463 -72
  310. package/src/session.ts +740 -99
  311. package/src/sha256.ts +65 -0
  312. package/src/stable-handles.ts +235 -0
  313. package/src/staged-seal.ts +90 -0
  314. package/src/subscriber-delivery.ts +37 -0
  315. package/src/tail-op-import.ts +30 -0
  316. package/src/topic-lifecycle.ts +38 -2
  317. package/src/trial-replace-dialog.ts +163 -0
  318. package/src/types.ts +6 -6
  319. package/src/undo.ts +386 -73
  320. package/src/version-bar.ts +5 -1
  321. package/src/version.ts +1 -1
  322. package/src/ws-client.ts +208 -16
  323. package/src/ws-frame-limit.ts +1 -0
package/src/ws-client.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  type TopicEvent,
35
35
  type TopicState,
36
36
  } from './topic-lifecycle.js';
37
+ import { PRESENCE_TOPIC_PREFIX } from './presence.js';
37
38
  import { SDK_CLIENT_ID } from './version.js';
38
39
 
39
40
  interface WsClientOptions {
@@ -41,12 +42,8 @@ interface WsClientOptions {
41
42
  appId: string;
42
43
  getAccessToken: () => Promise<string | null>;
43
44
  // Resolves the current app assertion (the auth proxy mints it alongside
44
- // the access token). Sent as the `app_assertion` connect param so the
45
- // server can enforce Layer 2 on the socket the same way it does on HTTP;
46
- // absent / null just means the connection is not app-attested (the
47
- // server treats it as first-party, un-gated). Optional so a client
48
- // constructed without it (or on the first-party dashboard path) connects
49
- // unchanged.
45
+ // the access token). Sent as the `app_assertion` connect param, which the
46
+ // server requires wherever it requires the HTTP header.
50
47
  getAppAssertion?: () => Promise<string | null>;
51
48
  // The anonymous link-guest token (`?brass_link=`), or null on the
52
49
  // ordinary signed-in path. When `getAccessToken` returns null but this
@@ -116,6 +113,16 @@ const HEARTBEAT_FIRST_MS = 10_000;
116
113
  // credits: the question is whether the path still carries bytes. Two missed
117
114
  // replies plus slack, so one dropped `pong` is not enough.
118
115
  const SOCKET_SILENCE_LIMIT_MS = 150_000;
116
+ const SUBSCRIBE_RETRY_BASE_MS = 250;
117
+ const SUBSCRIBE_RETRY_MAX_MS = 15_000;
118
+ // How long a sent subscribe may go unacked before it is sent again. The
119
+ // `subscribed` ack can be lost while the frames after it arrive, and frames
120
+ // for an unacked topic are held, so without a second subscribe everything the
121
+ // topic's owner sends waits for the next socket. Well above an ordinary ack's
122
+ // round trip, since each resend repeats the server's open work; a duplicate
123
+ // subscribe is idempotent there.
124
+ const SUBSCRIBE_ACK_TIMEOUT_MS = 5_000;
125
+ const SUBSCRIBE_ACK_TIMEOUT_MAX_MS = 30_000;
119
126
  // Spread added to that limit, because the condition is SHARED: the server
120
127
  // answers a `ping` after an awaited presence refresh and reap sweep, so its
121
128
  // replies slow under exactly the load that makes sockets fragile, and every
@@ -142,7 +149,7 @@ const MAX_QUEUED_FRAMES = 1024;
142
149
  // live (`established`) subscription behind it, so any LATER ack means the
143
150
  // socket dropped in between and frames may have been missed.
144
151
  interface ListenerMeta {
145
- onSubscribed?: (info: { resubscribed: boolean }) => void;
152
+ onSubscribed?: (info: { resubscribed: boolean; currentSeq?: number }) => void;
146
153
  onEnded?: (info: { reason: string }) => void;
147
154
  expectGap: boolean;
148
155
  }
@@ -156,7 +163,12 @@ export interface SubscribeOptions {
156
163
  // drop, so frames fanned out in the gap were lost and the subscriber
157
164
  // should reconcile by refetching (org-topic events carry no catch-up;
158
165
  // only the `doc:` topics replay via `doc_init`).
159
- onSubscribed?: (info: { resubscribed: boolean }) => void;
166
+ onSubscribed?: (info: { resubscribed: boolean; currentSeq?: number }) => void;
167
+ // Asks the server only to report that a `doc:` topic's document moved: no
168
+ // `doc_init` follows the ack, and the ack names the branch head as
169
+ // `currentSeq`. Honoured while every callback on the topic asks for it; a
170
+ // callback that does not re-subscribes the topic in full.
171
+ notice?: boolean;
160
172
  // Fired once when the server ends the subscription under a caller that
161
173
  // still wants it: it refused the subscribe (a revoked grant), or evicted
162
174
  // it again after the one silent re-subscribe a connection grants. The
@@ -254,6 +266,19 @@ export class BrassWsClient {
254
266
  // one socket cannot move its own deadline every time the check looks at it.
255
267
  private silenceLimitMs = SOCKET_SILENCE_LIMIT_MS;
256
268
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
269
+ // A subscribe the server could not finish is sent again on a doubling,
270
+ // jittered delay, so a burst that collided does not collide again in step.
271
+ // Cleared once the topic leaves its unacked phase or the socket changes.
272
+ private readonly subscribeRetries = new Map<
273
+ string,
274
+ { attempt: number; timer: ReturnType<typeof setTimeout> | null }
275
+ >();
276
+ // One per sent subscribe still awaiting its ack (`SUBSCRIBE_ACK_TIMEOUT_MS`).
277
+ // Cleared with `subscribeRetries`.
278
+ private readonly subscribeAckWatches = new Map<
279
+ string,
280
+ { attempt: number; timer: ReturnType<typeof setTimeout> | null }
281
+ >();
257
282
  // Sockets this client has opened. Every topic and every open document
258
283
  // rides this one socket, so the count is a fact about the client rather
259
284
  // than about any one of them. Reported in a client's own sync-health
@@ -265,9 +290,20 @@ export class BrassWsClient {
265
290
  // once.
266
291
  private outboundOverflowReported = false;
267
292
  private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
293
+ // When the last heartbeat went out, and when each topic was last acked: a
294
+ // pong describes the rows the server held when it answered that ping, so it
295
+ // says nothing about a topic acked after the ping was sent.
296
+ private lastPingSentAt = 0;
297
+ private readonly ackedAt = new Map<string, number>();
268
298
  private closedByUser = false;
269
299
  // Page-lifecycle listeners, installed lazily on first connect and removed on
270
300
  // user close. Guarded for non-DOM environments (Node, SSR).
301
+ // Topics every callback subscribed to as a notice (see `notice`).
302
+ private readonly noticeTopics = new Set<string>();
303
+ // Topics upgraded from a notice while the notice subscribe was unacked. The
304
+ // full subscribe waits for that ack: sent alongside, the notice one can
305
+ // land second and leave the connection a notice subscriber.
306
+ private readonly upgradeOnAck = new Set<string>();
271
307
  private visibilityListener: (() => void) | null = null;
272
308
  private onlineListener: (() => void) | null = null;
273
309
 
@@ -287,6 +323,18 @@ export class BrassWsClient {
287
323
  set = new Map();
288
324
  this.listeners.set(topic, set);
289
325
  }
326
+ if (firstForTopic && options.notice === true) {
327
+ this.noticeTopics.add(topic);
328
+ } else if (options.notice !== true && this.noticeTopics.delete(topic)) {
329
+ // The topic was subscribed as a notice, which sends no `doc_init`, and
330
+ // this callback needs the full subscription.
331
+ const prior = this.topics.get(topic);
332
+ if (prior?.phase === 'established') {
333
+ this.sendIfOpen({ type: 'subscribe', topic });
334
+ } else if (prior !== undefined && holdsFrames(prior)) {
335
+ this.upgradeOnAck.add(topic);
336
+ }
337
+ }
290
338
  set.set(callback, {
291
339
  ...(options.onSubscribed ? { onSubscribed: options.onSubscribed } : {}),
292
340
  ...(options.onEnded ? { onEnded: options.onEnded } : {}),
@@ -321,6 +369,7 @@ export class BrassWsClient {
321
369
  if (current.size > 0) return;
322
370
  this.listeners.delete(topic);
323
371
  this.ownFrameRetryTopics.delete(topic);
372
+ this.noticeTopics.delete(topic);
324
373
  this.dispatch(topic, { type: 'user_unsubscribe' });
325
374
  // Drop the connection once no topic is wanted, so no idle socket lingers.
326
375
  if (this.listeners.size === 0) {
@@ -336,8 +385,25 @@ export class BrassWsClient {
336
385
  private dispatch(topic: string, event: TopicEvent): void {
337
386
  const prior = this.topics.get(topic) ?? IDLE_TOPIC;
338
387
  const { state, effects } = step(prior, event);
388
+ if (
389
+ !holdsFrames(state) ||
390
+ event.type === 'socket_open' ||
391
+ event.type === 'socket_closed'
392
+ ) {
393
+ this.clearSubscribeRetry(topic);
394
+ }
395
+ // A socket transition re-sends every wanted topic in full, and a topic
396
+ // that left its live phases has no subscription left to upgrade.
397
+ if (
398
+ event.type === 'socket_open' ||
399
+ event.type === 'socket_closed' ||
400
+ (!holdsFrames(state) && state.phase !== 'established')
401
+ ) {
402
+ this.upgradeOnAck.delete(topic);
403
+ }
339
404
  if (state.phase === 'idle') {
340
405
  this.topics.delete(topic);
406
+ this.ackedAt.delete(topic);
341
407
  } else if (event.type === 'user_subscribe') {
342
408
  // Re-insert so the map's order (the reconnect replay order) tracks
343
409
  // subscription order even for a topic re-subscribed after teardown.
@@ -378,7 +444,16 @@ export class BrassWsClient {
378
444
  case 'send_subscribe':
379
445
  // Dropped when the socket is not open; `socket_open` re-derives a
380
446
  // `send_subscribe` for every wanted topic, so nothing is lost.
381
- this.sendIfOpen({ type: 'subscribe', topic });
447
+ if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
448
+ this.sendFrame(
449
+ this.noticeTopics.has(topic)
450
+ ? { type: 'subscribe', topic, notice: true }
451
+ : { type: 'subscribe', topic },
452
+ );
453
+ this.watchSubscribeAck(topic);
454
+ return;
455
+ case 'resend_subscribe_later':
456
+ this.scheduleSubscribeRetry(topic);
382
457
  return;
383
458
  case 'send_unsubscribe':
384
459
  this.sendIfOpen({ type: 'unsubscribe', topic });
@@ -412,6 +487,54 @@ export class BrassWsClient {
412
487
  }
413
488
  }
414
489
 
490
+ private scheduleSubscribeRetry(topic: string): void {
491
+ const retry = this.subscribeRetries.get(topic) ?? { attempt: 0, timer: null };
492
+ if (retry.timer !== null) return;
493
+ const base = Math.min(
494
+ SUBSCRIBE_RETRY_MAX_MS,
495
+ SUBSCRIBE_RETRY_BASE_MS * 2 ** retry.attempt,
496
+ );
497
+ retry.attempt += 1;
498
+ retry.timer = setTimeout(() => {
499
+ retry.timer = null;
500
+ const state = this.topics.get(topic);
501
+ if (!state || !holdsFrames(state)) return;
502
+ this.runEffect(topic, { kind: 'send_subscribe' });
503
+ }, base / 2 + Math.random() * (base / 2));
504
+ this.subscribeRetries.set(topic, retry);
505
+ }
506
+
507
+ private clearSubscribeRetry(topic: string): void {
508
+ const watch = this.subscribeAckWatches.get(topic);
509
+ if (watch) {
510
+ if (watch.timer !== null) clearTimeout(watch.timer);
511
+ this.subscribeAckWatches.delete(topic);
512
+ }
513
+ const retry = this.subscribeRetries.get(topic);
514
+ if (!retry) return;
515
+ if (retry.timer !== null) clearTimeout(retry.timer);
516
+ this.subscribeRetries.delete(topic);
517
+ }
518
+
519
+ // Send the subscribe again if its ack has not arrived in time, on a
520
+ // doubling delay so a server that is merely slow is not asked repeatedly.
521
+ private watchSubscribeAck(topic: string): void {
522
+ const watch = this.subscribeAckWatches.get(topic) ?? { attempt: 0, timer: null };
523
+ if (watch.timer !== null) clearTimeout(watch.timer);
524
+ const delay = Math.min(
525
+ SUBSCRIBE_ACK_TIMEOUT_MAX_MS,
526
+ SUBSCRIBE_ACK_TIMEOUT_MS * 2 ** watch.attempt,
527
+ );
528
+ watch.attempt += 1;
529
+ watch.timer = setTimeout(() => {
530
+ watch.timer = null;
531
+ const state = this.topics.get(topic);
532
+ if (!state || !holdsFrames(state)) return;
533
+ this.runEffect(topic, { kind: 'send_subscribe' });
534
+ }, delay);
535
+ this.subscribeAckWatches.set(topic, watch);
536
+ }
537
+
415
538
  // Send an arbitrary frame. Queued when the socket is not open and drained on
416
539
  // the next `open` (covering a mutate during the subscribe handshake gap and
417
540
  // ops that must survive a mid-session reconnect). Failures (overflow, closed
@@ -524,6 +647,35 @@ export class BrassWsClient {
524
647
  }
525
648
  }
526
649
 
650
+ /**
651
+ * Replace the socket because the credential it was opened with no longer
652
+ * applies (a guest page moved onto another document's link). Wanted topics
653
+ * re-subscribe on the new socket and queued frames wait for it.
654
+ */
655
+ reconnect(): void {
656
+ if (this.closedByUser) return;
657
+ if (this.ws === null && !this.connecting) return;
658
+ this.connectEpoch++;
659
+ if (this.reconnectTimer) {
660
+ clearTimeout(this.reconnectTimer);
661
+ this.reconnectTimer = null;
662
+ }
663
+ this.stopHeartbeat();
664
+ const old = this.ws;
665
+ this.ws = null;
666
+ this.lastInboundAt = 0;
667
+ this.connecting = false;
668
+ for (const topic of this.topics.keys()) {
669
+ this.dispatch(topic, { type: 'socket_closed' });
670
+ }
671
+ try {
672
+ old?.close();
673
+ } catch {
674
+ // Closing an already-closing socket is fine.
675
+ }
676
+ if (this.listeners.size > 0) void this.ensureConnected();
677
+ }
678
+
527
679
  private async ensureConnected(): Promise<void> {
528
680
  // The reconnect bound is deliberately not consulted here. A caller's own
529
681
  // connect always attempts, so a client parked on the bound still reaches
@@ -536,11 +688,12 @@ export class BrassWsClient {
536
688
  this.closedByUser = false;
537
689
  const epoch = ++this.connectEpoch;
538
690
  try {
539
- const token = await this.opts.getAccessToken();
691
+ // A page on a link connects with the link, whoever is signed in: the
692
+ // socket carries one credential, and the link is the one that admits
693
+ // its document.
694
+ const guestToken = this.opts.getGuestLinkToken?.() ?? null;
695
+ const token = guestToken ? null : await this.opts.getAccessToken();
540
696
  if (epoch !== this.connectEpoch) return;
541
- // Anonymous link guest: no access token, but a link token authorises a
542
- // read-only connection.
543
- const guestToken = token ? null : this.opts.getGuestLinkToken?.() ?? null;
544
697
  if (!token && !guestToken) {
545
698
  this.connecting = false;
546
699
  this.opts.fireError(
@@ -726,15 +879,22 @@ export class BrassWsClient {
726
879
  }
727
880
 
728
881
  if (type === 'subscribed' && typeof frame.topic === 'string') {
882
+ this.ackedAt.set(frame.topic, Date.now());
729
883
  // `flush_held` drains the frames held back for the unacked subscribe.
730
884
  this.dispatch(frame.topic, { type: 'subscribed_ack' });
885
+ if (this.upgradeOnAck.delete(frame.topic)) {
886
+ this.sendIfOpen({ type: 'subscribe', topic: frame.topic });
887
+ }
731
888
  const acked = this.listeners.get(frame.topic);
889
+ const currentSeq = (frame as { current_seq?: unknown }).current_seq;
732
890
  if (acked) {
733
891
  for (const meta of acked.values()) {
734
892
  const resubscribed = meta.expectGap;
735
893
  meta.expectGap = true;
736
894
  try {
737
- meta.onSubscribed?.({ resubscribed });
895
+ meta.onSubscribed?.(
896
+ typeof currentSeq === 'number' ? { resubscribed, currentSeq } : { resubscribed },
897
+ );
738
898
  } catch {
739
899
  // A bad listener must not break the others or kill the read loop.
740
900
  }
@@ -747,8 +907,14 @@ export class BrassWsClient {
747
907
  return;
748
908
  }
749
909
  if (type === 'pong') {
750
- // Heartbeat reply; drop silently (the round-trip already reset API
751
- // Gateway's idle-timeout).
910
+ // Heartbeat reply (the round-trip already reset API Gateway's
911
+ // idle-timeout). It names the presence topics the server still holds a
912
+ // row on for this socket. A row reaped while this page's timers were
913
+ // suspended leaves the page out of every roster, and a page that
914
+ // publishes nothing draws no `not_subscribed` to rejoin, so a missing
915
+ // topic is subscribed again here.
916
+ const held = (frame as { presence_topics?: unknown }).presence_topics;
917
+ if (Array.isArray(held)) this.rejoinDroppedPresence(new Set(held));
752
918
  return;
753
919
  }
754
920
  if (type === 'error') {
@@ -780,9 +946,20 @@ export class BrassWsClient {
780
946
  // Topic-scoped server frames: fan out to every callback for the topic.
781
947
  if (typeof frame.topic === 'string') {
782
948
  this.fanOut(frame.topic, frame);
949
+ // `doc_gone` evicts every subscriber on the topic server-side. A
950
+ // subscriber reopening the same topic (a moved document reopens under
951
+ // the same id) must send its own subscribe, which it only does as the
952
+ // topic's first listener.
953
+ if (type === 'doc_gone') this.endTopic(frame.topic);
783
954
  }
784
955
  }
785
956
 
957
+ private endTopic(topic: string): void {
958
+ this.ownFrameRetryTopics.delete(topic);
959
+ this.dispatch(topic, { type: 'server_ended' });
960
+ if (this.listeners.size === 0) this.close();
961
+ }
962
+
786
963
  private fanOut(topic: string, frame: unknown): void {
787
964
  const set = this.listeners.get(topic);
788
965
  if (!set) return;
@@ -894,6 +1071,11 @@ export class BrassWsClient {
894
1071
  return this.isHidden();
895
1072
  }
896
1073
 
1074
+ /** Whether any socket has opened since this client was made. */
1075
+ get everOpened(): boolean {
1076
+ return this.socketsOpened > 0;
1077
+ }
1078
+
897
1079
  /** Whether a socket is open right now. */
898
1080
  get connected(): boolean {
899
1081
  return this.ws !== null && this.ws.readyState === this.ws.OPEN;
@@ -965,6 +1147,7 @@ export class BrassWsClient {
965
1147
  this.dropSilentSocket();
966
1148
  return;
967
1149
  }
1150
+ this.lastPingSentAt = Date.now();
968
1151
  this.sendIfOpen({ type: 'ping' });
969
1152
  arm(HEARTBEAT_INTERVAL_MS);
970
1153
  }, delay);
@@ -972,6 +1155,15 @@ export class BrassWsClient {
972
1155
  arm(HEARTBEAT_FIRST_MS);
973
1156
  }
974
1157
 
1158
+ private rejoinDroppedPresence(held: Set<unknown>): void {
1159
+ for (const [topic, state] of this.topics) {
1160
+ if (!topic.startsWith(PRESENCE_TOPIC_PREFIX) || state.phase !== 'established') continue;
1161
+ if (held.has(topic)) continue;
1162
+ if ((this.ackedAt.get(topic) ?? Infinity) >= this.lastPingSentAt) continue;
1163
+ this.dispatch(topic, { type: 'server_dropped' });
1164
+ }
1165
+ }
1166
+
975
1167
  private socketHasGoneSilent(): boolean {
976
1168
  if (!this.ws || this.lastInboundAt === 0) return false;
977
1169
  return Date.now() - this.lastInboundAt >= this.silenceLimitMs;
@@ -31,6 +31,7 @@ export interface OpFrame {
31
31
  topic: string;
32
32
  op_blob: string;
33
33
  client_op_id: string;
34
+ base_import_seq?: number;
34
35
  }
35
36
 
36
37
  /**