@brass-build/client 0.1.0 → 0.2.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 (193) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +317 -231
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +3 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +217 -75
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-engine.d.ts +4 -0
  13. package/dist/document-engine.d.ts.map +1 -0
  14. package/dist/document-engine.js +9 -0
  15. package/dist/document-engine.js.map +1 -0
  16. package/dist/document-streams.d.ts +8 -1
  17. package/dist/document-streams.d.ts.map +1 -1
  18. package/dist/document-streams.js +79 -8
  19. package/dist/document-streams.js.map +1 -1
  20. package/dist/documents-namespace.d.ts.map +1 -1
  21. package/dist/documents-namespace.js +19 -3
  22. package/dist/documents-namespace.js.map +1 -1
  23. package/dist/documents.d.ts +43 -14
  24. package/dist/documents.d.ts.map +1 -1
  25. package/dist/documents.js +871 -223
  26. package/dist/documents.js.map +1 -1
  27. package/dist/embed-protocol.d.ts +3 -0
  28. package/dist/embed-protocol.d.ts.map +1 -0
  29. package/dist/embed-protocol.js +26 -0
  30. package/dist/embed-protocol.js.map +1 -0
  31. package/dist/emitter.d.ts.map +1 -1
  32. package/dist/emitter.js +8 -1
  33. package/dist/emitter.js.map +1 -1
  34. package/dist/export-guest.d.ts.map +1 -1
  35. package/dist/export-guest.js +14 -6
  36. package/dist/export-guest.js.map +1 -1
  37. package/dist/fetch-deadline.d.ts +22 -0
  38. package/dist/fetch-deadline.d.ts.map +1 -0
  39. package/dist/fetch-deadline.js +96 -0
  40. package/dist/fetch-deadline.js.map +1 -0
  41. package/dist/fetch-retry.d.ts +1 -0
  42. package/dist/fetch-retry.d.ts.map +1 -1
  43. package/dist/fetch-retry.js +12 -5
  44. package/dist/fetch-retry.js.map +1 -1
  45. package/dist/handle-types.d.ts +150 -0
  46. package/dist/handle-types.d.ts.map +1 -0
  47. package/dist/handle-types.js +8 -0
  48. package/dist/handle-types.js.map +1 -0
  49. package/dist/handles.d.ts +48 -48
  50. package/dist/handles.d.ts.map +1 -1
  51. package/dist/handles.js +3122 -501
  52. package/dist/handles.js.map +1 -1
  53. package/dist/hosted-runtime.d.ts.map +1 -1
  54. package/dist/hosted-runtime.js +8 -12
  55. package/dist/hosted-runtime.js.map +1 -1
  56. package/dist/hosted-ui.d.ts +0 -1
  57. package/dist/hosted-ui.d.ts.map +1 -1
  58. package/dist/hosted-ui.js +29 -24
  59. package/dist/hosted-ui.js.map +1 -1
  60. package/dist/importer.d.ts.map +1 -1
  61. package/dist/importer.js +253 -8
  62. package/dist/importer.js.map +1 -1
  63. package/dist/index.d.ts +10 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +11 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal.d.ts +1 -1
  68. package/dist/internal.d.ts.map +1 -1
  69. package/dist/internal.js.map +1 -1
  70. package/dist/join-coverage.d.ts +12 -0
  71. package/dist/join-coverage.d.ts.map +1 -0
  72. package/dist/join-coverage.js +37 -0
  73. package/dist/join-coverage.js.map +1 -0
  74. package/dist/jwt.d.ts.map +1 -1
  75. package/dist/jwt.js +5 -1
  76. package/dist/jwt.js.map +1 -1
  77. package/dist/launch-context.js +2 -2
  78. package/dist/launch-context.js.map +1 -1
  79. package/dist/mutate-batching.d.ts +25 -2
  80. package/dist/mutate-batching.d.ts.map +1 -1
  81. package/dist/mutate-batching.js +57 -10
  82. package/dist/mutate-batching.js.map +1 -1
  83. package/dist/open-document.d.ts +15 -1
  84. package/dist/open-document.d.ts.map +1 -1
  85. package/dist/open-document.js +1851 -321
  86. package/dist/open-document.js.map +1 -1
  87. package/dist/presence.d.ts +28 -0
  88. package/dist/presence.d.ts.map +1 -1
  89. package/dist/presence.js +149 -7
  90. package/dist/presence.js.map +1 -1
  91. package/dist/record-paging.d.ts +336 -71
  92. package/dist/record-paging.d.ts.map +1 -1
  93. package/dist/record-paging.js +591 -127
  94. package/dist/record-paging.js.map +1 -1
  95. package/dist/selection-persistence.d.ts.map +1 -1
  96. package/dist/selection-persistence.js +11 -1
  97. package/dist/selection-persistence.js.map +1 -1
  98. package/dist/serializer.d.ts +30 -1
  99. package/dist/serializer.d.ts.map +1 -1
  100. package/dist/session-persistence.d.ts.map +1 -1
  101. package/dist/session-persistence.js +9 -8
  102. package/dist/session-persistence.js.map +1 -1
  103. package/dist/session-toolbar.d.ts +0 -1
  104. package/dist/session-toolbar.d.ts.map +1 -1
  105. package/dist/session-toolbar.js +226 -75
  106. package/dist/session-toolbar.js.map +1 -1
  107. package/dist/session.d.ts.map +1 -1
  108. package/dist/session.js +87 -18
  109. package/dist/session.js.map +1 -1
  110. package/dist/tail-ops.d.ts +14 -4
  111. package/dist/tail-ops.d.ts.map +1 -1
  112. package/dist/tail-ops.js +75 -15
  113. package/dist/tail-ops.js.map +1 -1
  114. package/dist/topic-lifecycle.d.ts +4 -0
  115. package/dist/topic-lifecycle.d.ts.map +1 -1
  116. package/dist/topic-lifecycle.js +25 -1
  117. package/dist/topic-lifecycle.js.map +1 -1
  118. package/dist/types.d.ts +47 -1
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/undo.d.ts +2 -3
  121. package/dist/undo.d.ts.map +1 -1
  122. package/dist/undo.js +11 -0
  123. package/dist/undo.js.map +1 -1
  124. package/dist/unload-guard.d.ts +5 -0
  125. package/dist/unload-guard.d.ts.map +1 -0
  126. package/dist/unload-guard.js +88 -0
  127. package/dist/unload-guard.js.map +1 -0
  128. package/dist/version-bar.d.ts +4 -3
  129. package/dist/version-bar.d.ts.map +1 -1
  130. package/dist/version-bar.js +140 -19
  131. package/dist/version-bar.js.map +1 -1
  132. package/dist/version.d.ts +2 -2
  133. package/dist/version.js +1 -1
  134. package/dist/vite.d.ts +18 -2
  135. package/dist/vite.d.ts.map +1 -1
  136. package/dist/vite.js +133 -8
  137. package/dist/vite.js.map +1 -1
  138. package/dist/ws-client.d.ts +23 -3
  139. package/dist/ws-client.d.ts.map +1 -1
  140. package/dist/ws-client.js +339 -89
  141. package/dist/ws-client.js.map +1 -1
  142. package/dist/ws-frame-limit.d.ts.map +1 -1
  143. package/dist/ws-frame-limit.js +19 -12
  144. package/dist/ws-frame-limit.js.map +1 -1
  145. package/docs/app-manifest.md +7 -8
  146. package/docs/custom-chrome.md +17 -20
  147. package/docs/hosted-ui-advanced.md +14 -17
  148. package/docs/imported-documents.md +53 -197
  149. package/docs/presence.md +45 -21
  150. package/docs/schemas.md +12 -17
  151. package/docs/streams.md +55 -57
  152. package/package.json +4 -3
  153. package/src/auth-flow.ts +90 -23
  154. package/src/client.ts +225 -78
  155. package/src/document-engine.ts +15 -0
  156. package/src/document-signals.ts +7 -7
  157. package/src/document-streams.ts +95 -9
  158. package/src/documents-namespace.ts +18 -3
  159. package/src/documents.ts +983 -277
  160. package/src/embed-protocol.ts +25 -0
  161. package/src/emitter.ts +8 -1
  162. package/src/export-guest.ts +15 -9
  163. package/src/fetch-deadline.ts +119 -0
  164. package/src/fetch-retry.ts +14 -5
  165. package/src/handle-types.ts +303 -0
  166. package/src/handles.ts +3406 -683
  167. package/src/hosted-runtime.ts +8 -12
  168. package/src/hosted-ui.ts +28 -32
  169. package/src/importer.ts +261 -8
  170. package/src/index.ts +29 -18
  171. package/src/internal.ts +1 -0
  172. package/src/join-coverage.ts +42 -0
  173. package/src/jwt.ts +8 -1
  174. package/src/launch-context.ts +2 -2
  175. package/src/mutate-batching.ts +60 -11
  176. package/src/open-document.ts +2355 -515
  177. package/src/presence.ts +210 -11
  178. package/src/record-paging.ts +687 -137
  179. package/src/selection-persistence.ts +10 -1
  180. package/src/serializer.ts +33 -1
  181. package/src/session-persistence.ts +9 -8
  182. package/src/session-toolbar.ts +249 -80
  183. package/src/session.ts +91 -17
  184. package/src/tail-ops.ts +76 -10
  185. package/src/topic-lifecycle.ts +31 -1
  186. package/src/types.ts +56 -4
  187. package/src/undo.ts +13 -5
  188. package/src/unload-guard.ts +91 -0
  189. package/src/version-bar.ts +163 -25
  190. package/src/version.ts +1 -1
  191. package/src/vite.ts +175 -10
  192. package/src/ws-client.ts +392 -92
  193. package/src/ws-frame-limit.ts +19 -12
package/src/ws-client.ts CHANGED
@@ -4,24 +4,23 @@
4
4
  // frame when the last detaches.
5
5
  //
6
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.
7
+ // of topics. After `RECONNECT_MAX_ATTEMPTS` consecutive failures it fires
8
+ // `onError` once and drops to a slow retry, so a wrong verdict costs a
9
+ // reader a delay rather than their connection. One counter both bounds and
10
+ // paces the loop, and it counts attempts rather than time (see the
11
+ // constant), so a hidden tab goes on reconnecting under the same bound as a
12
+ // visible one. What clears it is a socket the server answered, or a reader
13
+ // coming back via `visibilitychange` / `online`.
16
14
  //
17
15
  // Heartbeat: send `{ type: 'ping' }` every 60 seconds while any topic is
18
16
  // subscribed. One interval serves two purposes: it resets the server's
19
17
  // 10-minute idle timeout (only client-originated frames do), and it is the
20
18
  // presence liveness signal (the server refreshes `last_seen` on each ping;
21
19
  // 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.
20
+ // `pong`. A topic-less `{ type: 'error', reason: 'bad_message' }` is the
21
+ // server's catch-all rejection, so it is swallowed rather than surfaced (a
22
+ // deploy without the `ping` handler answers a heartbeat with it) but never
23
+ // counted as the connection working. Both are dropped.
25
24
 
26
25
  import {
27
26
  IDLE_TOPIC,
@@ -62,18 +61,103 @@ interface WsClientOptions {
62
61
 
63
62
  export type WsTopicCallback = (frame: unknown) => void;
64
63
 
64
+
65
65
  const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
66
- const RECONNECT_GIVE_UP_MS = 5 * 60 * 1000;
66
+ // Failed attempts before the loop reports and slows, counted rather than
67
+ // timed because the loop's pacing is not ours: a hidden page's timers are
68
+ // throttled to roughly one firing a minute while wall-clock runs on at full
69
+ // speed. A budget measured in time therefore either bails on a healthy
70
+ // connection the moment a reader tabs away, or excludes hidden time and
71
+ // leaves a hidden page unbounded. A count measures what the loop actually
72
+ // did, so neither the page's visibility nor the machine's clock enters into
73
+ // it.
74
+ //
75
+ // Against the delays above this spans 301s, so a foregrounded page reaches it
76
+ // in about five minutes, which is what a genuinely broken connection
77
+ // (offline, signed out, endpoint down) looks like. A throttled one takes as
78
+ // long as its own timers take, roughly a quarter hour. Retuning the delays
79
+ // re-prices this, which `ws-client.test.ts` pins.
80
+ const RECONNECT_MAX_ATTEMPTS = 15;
81
+ // What the loop backs off to once it has passed that threshold. It keeps
82
+ // retrying at this pace for as long as topics are wanted, so a client is
83
+ // never left with nothing running: a misjudged connection recovers on its
84
+ // own within one interval instead of waiting for a reader to come back.
85
+ // Against an endpoint that is genuinely down this is the whole standing
86
+ // cost, which is why it is minutes rather than the 30s ceiling above.
87
+ const RECONNECT_IDLE_DELAY_MS = 5 * 60_000;
67
88
  const HEARTBEAT_INTERVAL_MS = 60_000;
89
+ // When a socket sends its FIRST ping. The steady cadence answers API
90
+ // Gateway's 10-minute idle timeout; this one exists so the reconnect loop
91
+ // can tell a working connection from a flapping one.
92
+ //
93
+ // It is a MINIMUM LIFETIME, not a latency tweak, and that is the part to
94
+ // keep: a `pong` is the only thing a quiet subscription can draw (a topic
95
+ // with no activity produces its ack and then nothing), so this delay is how
96
+ // long a socket must survive before it can clear the count. Send the first
97
+ // ping at open instead and a backend that accepts, answers and drops in
98
+ // 200ms credits itself on every socket, so the loop never ends and retries
99
+ // at the 1s floor for as long as the page is open.
100
+ //
101
+ // A background page clamps its timers to about a minute, so a hidden reader
102
+ // on a quiet topic falls back to the steady cadence. That page is bounded
103
+ // rather than wrong: it parks, and returning to it clears the count. A
104
+ // hidden page doing WORK is unaffected, because its ops draw `op_ack`.
105
+ const HEARTBEAT_FIRST_MS = 10_000;
68
106
  // 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.
107
+ // `outboundQueue` bound. A disconnect rolls every wanted topic back to
108
+ // `subscribing`, so both queues hold the same kind of mid-reconnect frame
109
+ // and both need the same memory ceiling, or a topic written to through a
110
+ // long outage grows one of them without bound.
74
111
  const MAX_QUEUED_FRAMES = 1024;
75
112
 
76
- type Listeners = Set<WsTopicCallback>;
113
+ // Per-callback subscription metadata. `expectGap` is what makes the
114
+ // `onSubscribed` resubscribe flag honest: it arms once the listener has a
115
+ // live (`established`) subscription behind it, so any LATER ack means the
116
+ // socket dropped in between and frames may have been missed.
117
+ interface ListenerMeta {
118
+ onSubscribed?: (info: { resubscribed: boolean }) => void;
119
+ onEnded?: (info: { reason: string }) => void;
120
+ expectGap: boolean;
121
+ }
122
+
123
+ type Listeners = Map<WsTopicCallback, ListenerMeta>;
124
+
125
+ export interface SubscribeOptions {
126
+ // Fired on every `subscribed` ack for the topic while this callback is
127
+ // attached. `resubscribed: false` is the initial subscribe;
128
+ // `resubscribed: true` means the subscription was re-established after a
129
+ // drop, so frames fanned out in the gap were lost and the subscriber
130
+ // should reconcile by refetching (org-topic events carry no catch-up;
131
+ // only the `doc:` topics replay via `doc_init`).
132
+ onSubscribed?: (info: { resubscribed: boolean }) => void;
133
+ // Fired once when the server ends the subscription under a caller that
134
+ // still wants it: it refused the subscribe (a revoked grant), or evicted
135
+ // it again after the one silent re-subscribe a connection grants. The
136
+ // client detaches this callback as it fires, so the topic is over for
137
+ // this subscriber whatever the socket does next.
138
+ //
139
+ // A subscriber holding work for the topic reads this as the end of the
140
+ // road for that work: a document's unacked ops can no longer be acked, so
141
+ // it fails them here rather than re-sending them into a subscription the
142
+ // server has refused, which pins `savingCount` above zero and tells the
143
+ // app nothing.
144
+ onEnded?: (info: { reason: string }) => void;
145
+ // This subscriber re-asserts its own identified frames (the ones carrying
146
+ // a `client_op_id`) whenever the subscription is established, so the
147
+ // client DROPS such a frame it cannot put on the wire right now instead
148
+ // of buffering it.
149
+ //
150
+ // Buffering it as well is what writes it twice: the buffer flushes on the
151
+ // `subscribed` ack and the subscriber re-asserts on the same signal, and
152
+ // the server allocates a fresh seq per send rather than deduplicating on
153
+ // the id. Worse across an outage, where the topic sits unacked for its
154
+ // whole length and each of the subscriber's retries appends another copy
155
+ // to the buffer, all of which flush at once on the ack.
156
+ //
157
+ // Only for a subscriber that really does re-assert: a frame dropped here
158
+ // reaches the server only if its owner sends it again.
159
+ ownsFrameRetry?: boolean;
160
+ }
77
161
 
78
162
  export class BrassWsClient {
79
163
  private ws: WebSocket | null = null;
@@ -94,28 +178,58 @@ export class BrassWsClient {
94
178
  private topics = new Map<string, TopicState>();
95
179
  private listeners = new Map<string, Listeners>();
96
180
  // 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.
181
+ // (after subscribe replay) so a frame nobody re-asserts survives a
182
+ // reconnect window.
99
183
  private outboundQueue: unknown[] = [];
100
184
  // Per-topic queues of frames held while `holdsFrames` says the topic's
101
185
  // subscribe is unacked: a topic-scoped frame (`presence`, `op`) sent
102
- // before the `subscribed` reply races the SUB# row write and the
186
+ // before the `subscribed` reply races the subscription record write and the
103
187
  // server's conditional update fails as a spurious `forbidden`. The
104
188
  // 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.
189
+ // Bounded per topic (`MAX_QUEUED_FRAMES`, drop-oldest) so a topic written
190
+ // to through a long outage can't grow it without limit.
107
191
  private pendingTopicSends = new Map<string, unknown[]>();
192
+ // Topics whose subscriber re-asserts its own identified frames (see
193
+ // `SubscribeOptions.ownsFrameRetry`). Held per topic rather than per
194
+ // listener because the decision is about the frame's fate on the wire,
195
+ // which every listener on the topic shares.
196
+ private ownFrameRetryTopics = new Set<string>();
108
197
  // Warn-once latch for held-queue overflow drops, reset when a topic's held
109
198
  // frames flush on the `subscribed` ack, so each unacked window reports once.
110
199
  private heldOverflowReported = false;
111
- private reconnectAttempt = 0;
112
- private reconnectStartedAt: number | null = null;
200
+ // Consecutive failures, which paces the loop AND decides whether this
201
+ // connection is working at all. One number answers both, because the two
202
+ // questions have the same evidence behind them: a socket that opened is
203
+ // not progress, so pacing on opening pins the delay at the 1s floor
204
+ // against a backend that accepts and hangs up, at ~86,000 sockets a day
205
+ // per tab for zero connected time. Only a frame the server sent in answer
206
+ // to this client clears it.
207
+ private failedAttempts = 0;
208
+ // Latches the failing report, so one incident is reported once however
209
+ // long it lasts.
210
+ private reportedFailing = false;
211
+ // Credited frames on the CURRENT socket, reset on open. Two of them is
212
+ // what re-arms the latch, and the pair is the point: a single answer is
213
+ // survivable by a socket that dies straight after, where a second means
214
+ // this socket lived past `HEARTBEAT_FIRST_MS` into the steady cadence and
215
+ // is genuinely serving the reader. Without a recovery-observed clear the
216
+ // latch never lifts on its own, since `resume` returns early while a
217
+ // socket is open and a Node client has no `visibilitychange` to fire it,
218
+ // so a second outage after a recovery would go unreported for the life of
219
+ // the process.
220
+ private creditsThisSocket = 0;
113
221
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
222
+ // Sockets this client has opened. Every topic and every open document
223
+ // rides this one socket, so the count is a fact about the client rather
224
+ // than about any one of them. Reported in a client's own sync-health
225
+ // report, where a high count beside unacked work is what distinguishes a
226
+ // connection that keeps dropping mid-write from one that is merely slow.
227
+ private socketsOpened = 0;
114
228
  // Warn-once latch for outbound-queue overflow drops; reset when the queue
115
229
  // drains on a successful (re)connect, so each disconnect reports at most
116
230
  // once.
117
231
  private outboundOverflowReported = false;
118
- private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
232
+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
119
233
  private closedByUser = false;
120
234
  // Page-lifecycle listeners, installed lazily on first connect and removed on
121
235
  // user close. Guarded for non-DOM environments (Node, SSR).
@@ -127,14 +241,25 @@ export class BrassWsClient {
127
241
  // Returns an unsubscribe that detaches THIS callback only; the WS-level
128
242
  // subscription stays open while other callbacks remain, and the unsubscribe
129
243
  // frame is sent only when the last detaches.
130
- subscribe(topic: string, callback: WsTopicCallback): () => void {
244
+ subscribe(
245
+ topic: string,
246
+ callback: WsTopicCallback,
247
+ options: SubscribeOptions = {},
248
+ ): () => void {
131
249
  let set = this.listeners.get(topic);
132
250
  const firstForTopic = !set;
133
251
  if (!set) {
134
- set = new Set();
252
+ set = new Map();
135
253
  this.listeners.set(topic, set);
136
254
  }
137
- set.add(callback);
255
+ set.set(callback, {
256
+ ...(options.onSubscribed ? { onSubscribed: options.onSubscribed } : {}),
257
+ ...(options.onEnded ? { onEnded: options.onEnded } : {}),
258
+ // Attaching to an already-established subscription: the next ack this
259
+ // listener sees is a reconnect, and the gap since attach is real.
260
+ expectGap: this.topics.get(topic)?.phase === 'established',
261
+ });
262
+ if (options.ownsFrameRetry) this.ownFrameRetryTopics.add(topic);
138
263
 
139
264
  if (firstForTopic) {
140
265
  this.dispatch(topic, { type: 'user_subscribe' });
@@ -144,7 +269,14 @@ export class BrassWsClient {
144
269
  // reconnect in flight (a prior give-up or a drop while hidden): a fresh
145
270
  // subscribe is the retry signal. (A pending reconnect timer is left
146
271
  // alone so its backoff isn't reset.)
147
- this.resume();
272
+ //
273
+ // A closed client goes through the front door instead, because
274
+ // `resume()` refuses one and this branch would otherwise be the single
275
+ // retry signal with no way out: subscribing a topic the wanted-state
276
+ // still holds would leave the client dead for good, where subscribing
277
+ // any other topic recovers it.
278
+ if (this.closedByUser) void this.ensureConnected();
279
+ else this.resume();
148
280
  }
149
281
 
150
282
  return () => {
@@ -153,6 +285,7 @@ export class BrassWsClient {
153
285
  current.delete(callback);
154
286
  if (current.size > 0) return;
155
287
  this.listeners.delete(topic);
288
+ this.ownFrameRetryTopics.delete(topic);
156
289
  this.dispatch(topic, { type: 'user_unsubscribe' });
157
290
  // Drop the connection once no topic is wanted, so no idle socket lingers.
158
291
  if (this.listeners.size === 0) {
@@ -178,9 +311,31 @@ export class BrassWsClient {
178
311
  } else {
179
312
  this.topics.set(topic, state);
180
313
  }
314
+ // Teardown detaches the topic's callbacks, so the subscribers owed an
315
+ // end notice are captured before the effects drop them and told once the
316
+ // topic is released: a subscriber that closes itself from `onEnded` then
317
+ // finds nothing left to unsubscribe. `fire_error` carries the same reason
318
+ // to the session-wide channel; this notice is what reaches the one
319
+ // subscriber that has to give up the work it holds for the topic.
320
+ const ending =
321
+ event.type === 'error' && effects.some((e) => e.kind === 'drop_listeners')
322
+ ? {
323
+ reason: event.reason,
324
+ metas: [...(this.listeners.get(topic)?.values() ?? [])],
325
+ }
326
+ : null;
181
327
  for (const effect of effects) {
182
328
  this.runEffect(topic, effect);
183
329
  }
330
+ if (ending) {
331
+ for (const meta of ending.metas) {
332
+ try {
333
+ meta.onEnded?.({ reason: ending.reason });
334
+ } catch {
335
+ // A bad listener must not break the others or kill the read loop.
336
+ }
337
+ }
338
+ }
184
339
  }
185
340
 
186
341
  private runEffect(topic: string, effect: TopicEffect): void {
@@ -209,6 +364,11 @@ export class BrassWsClient {
209
364
  case 'drop_listeners':
210
365
  this.listeners.delete(topic);
211
366
  return;
367
+ case 'deliver_error':
368
+ // Rebuilt rather than forwarded: a topic error frame is exactly
369
+ // these three fields.
370
+ this.fanOut(topic, { type: 'error', topic, reason: effect.reason });
371
+ return;
212
372
  case 'fire_error':
213
373
  this.opts.fireError(
214
374
  new Error(`Brass WebSocket subscribe(${topic}): ${effect.reason}`),
@@ -222,23 +382,30 @@ export class BrassWsClient {
222
382
  // ops that must survive a mid-session reconnect). Failures (overflow, closed
223
383
  // transport) are silently dropped.
224
384
  send(frame: unknown): void {
225
- // Topic-scoped frames sent before the `subscribed` ack race the SUB# row
385
+ // Topic-scoped frames sent before the `subscribed` ack race the subscription record
226
386
  // write and surface as spurious `forbidden`, so hold them until the ack
227
387
  // (see `pendingTopicSends`). The machine's `holdsFrames` covers every
228
388
  // unacked wanted state (fresh subscribe, reconnect replay, retry).
229
389
  const topic = topicOfFrame(frame);
230
390
  const topicState = topic !== undefined ? this.topics.get(topic) : undefined;
391
+ // Buffering this frame would duplicate it, because its owner sends it
392
+ // again on the next `subscribed` ack (see `ownsFrameRetry`).
393
+ const ownerResends =
394
+ topic !== undefined &&
395
+ this.ownFrameRetryTopics.has(topic) &&
396
+ isIdentifiedFrame(frame);
231
397
 
232
398
  if (topic !== undefined && topicState && holdsFrames(topicState)) {
399
+ if (ownerResends) return;
233
400
  let queue = this.pendingTopicSends.get(topic);
234
401
  if (!queue) {
235
402
  queue = [];
236
403
  this.pendingTopicSends.set(topic, queue);
237
404
  }
238
405
  // 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()`.
406
+ // overflow. What reaches this queue is a frame nobody re-asserts (the
407
+ // ones that are re-asserted took the `ownerResends` exit above), so
408
+ // the drop is reported.
242
409
  if (queue.length >= MAX_QUEUED_FRAMES) {
243
410
  queue.shift();
244
411
  if (!this.heldOverflowReported) {
@@ -261,6 +428,7 @@ export class BrassWsClient {
261
428
  this.sendFrame(frame);
262
429
  return;
263
430
  }
431
+ if (ownerResends) return;
264
432
  // Bounded so a misbehaving caller can't leak memory; 1024 is generous for
265
433
  // a single user's mid-session ops during a reconnect (each typically <1KB).
266
434
  if (this.outboundQueue.length >= 1024) {
@@ -294,6 +462,10 @@ export class BrassWsClient {
294
462
  // continuation that is about to bail.
295
463
  this.connectEpoch++;
296
464
  this.connecting = false;
465
+ // `failedAttempts` deliberately survives. An app reaches `close()`
466
+ // routinely rather than only on teardown, so clearing it here lets a
467
+ // client one step from the bound start over on every cycle and the loop
468
+ // never ends. A frame from the server is what clears it.
297
469
  this.removeLifecycleListeners();
298
470
  if (this.reconnectTimer) {
299
471
  clearTimeout(this.reconnectTimer);
@@ -318,6 +490,10 @@ export class BrassWsClient {
318
490
  }
319
491
 
320
492
  private async ensureConnected(): Promise<void> {
493
+ // The reconnect bound is deliberately not consulted here. A caller's own
494
+ // connect always attempts, so a client parked on the bound still reaches
495
+ // a network that has recovered, and the frame that comes back clears the
496
+ // count. What the bound stops is the unattended retry loop.
321
497
  if (this.ws && this.ws.readyState === this.ws.OPEN) return;
322
498
  if (this.connecting) return;
323
499
  this.ensureLifecycleListeners();
@@ -378,6 +554,9 @@ export class BrassWsClient {
378
554
  // The `close` handler runs after `error` and drives reconnect;
379
555
  // swallowing keeps a transient network error from bubbling.
380
556
  });
557
+ // The superseded socket's `close` is dropped by the identity guard, so
558
+ // `onClose` never runs for it and its heartbeat would outlive it.
559
+ if (prev) this.stopHeartbeat();
381
560
  if (prev) {
382
561
  try {
383
562
  prev.close();
@@ -390,7 +569,23 @@ export class BrassWsClient {
390
569
  // `connecting` flag now belongs to the newer attempt, so leave it.
391
570
  if (epoch !== this.connectEpoch) return;
392
571
  this.connecting = false;
393
- this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
572
+ // Reported until the loop has said the connection is failing, then
573
+ // left to that one report. The throw here repeats on every attempt of
574
+ // an outage, so relaying each one turns a single incident into an
575
+ // error per attempt for as long as it lasts.
576
+ if (!this.reportedFailing) {
577
+ this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
578
+ }
579
+ // The token and the assertion reject on a momentary outage (a shed
580
+ // `/refresh`), which is the same transient the reconnect loop backs
581
+ // off on when a live socket drops. The throw happens before any
582
+ // socket exists, so no `close` event drives that loop and the retry
583
+ // is scheduled from here, on the same backoff and the same give-up
584
+ // budget. A signed-out session resolves null instead and takes the
585
+ // branch above, which stays terminal.
586
+ if (this.closedByUser) return;
587
+ if (this.listeners.size === 0) return;
588
+ this.scheduleReconnect();
394
589
  }
395
590
  }
396
591
 
@@ -420,8 +615,8 @@ export class BrassWsClient {
420
615
 
421
616
  private onOpen(): void {
422
617
  this.connecting = false;
423
- this.reconnectAttempt = 0;
424
- this.reconnectStartedAt = null;
618
+ this.socketsOpened += 1;
619
+ this.creditsThisSocket = 0;
425
620
  // `socket_open` per topic: re-subscribes every wanted topic (in
426
621
  // subscription order) with a fresh retry grant, and prunes `tornDown`
427
622
  // leftovers. Runs before the outbound drain so the server sees the
@@ -434,13 +629,22 @@ export class BrassWsClient {
434
629
  this.startHeartbeat();
435
630
  }
436
631
 
632
+ // Re-`send` each drained frame rather than writing it straight to the
633
+ // socket: the topic it belongs to has just been re-subscribed and its ack
634
+ // is still outstanding, so a frame written now races the server's
635
+ // subscription record write and comes back `not_subscribed`, which tears
636
+ // the topic down
637
+ // and leaves the document deaf on a live connection. `send` applies the
638
+ // hold (or the drop) the topic's current state calls for. The queue is
639
+ // swapped out first, so a frame `send` re-queues lands in the new array
640
+ // instead of being re-drained here.
437
641
  private flushOutbound(): void {
438
642
  if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
439
643
  const drained = this.outboundQueue;
440
644
  this.outboundQueue = [];
441
645
  this.outboundOverflowReported = false;
442
646
  for (const frame of drained) {
443
- this.sendFrame(frame);
647
+ this.send(frame);
444
648
  }
445
649
  }
446
650
 
@@ -456,9 +660,45 @@ export class BrassWsClient {
456
660
  const type = frame.type;
457
661
  if (typeof type !== 'string') return;
458
662
 
663
+ // The connection is working, so the reconnect loop starts over.
664
+ //
665
+ // What these have in common is that each ANSWERS something this client
666
+ // sent, which is why they read as the connection working where the rest
667
+ // of the protocol does not: the subscribe handshake is `subscribed` and
668
+ // then `doc_init` or `presence_snapshot`, so a backend that accepts,
669
+ // acks and hangs up would credit itself on every cycle, and an `error`
670
+ // frame is the server reporting it could NOT do the work.
671
+ //
672
+ // Credited as the evidence arrives rather than when the socket ends,
673
+ // because `close()` drops its own socket's `close` event (it nulls
674
+ // `this.ws` first, so the identity guard rejects it) and an app reaches
675
+ // `close()` routinely, so a session that ended that way would credit
676
+ // nothing however well it worked.
677
+ //
678
+ // Getting this wrong is survivable by design: the count decides when the
679
+ // loop slows and reports, not whether it runs at all, so a client this
680
+ // rule misjudges is late to a notification rather than disconnected.
681
+ if (type === 'pong' || type === 'op_ack') {
682
+ this.failedAttempts = 0;
683
+ this.creditsThisSocket += 1;
684
+ if (this.creditsThisSocket >= 2) this.reportedFailing = false;
685
+ }
686
+
459
687
  if (type === 'subscribed' && typeof frame.topic === 'string') {
460
688
  // `flush_held` drains the frames held back for the unacked subscribe.
461
689
  this.dispatch(frame.topic, { type: 'subscribed_ack' });
690
+ const acked = this.listeners.get(frame.topic);
691
+ if (acked) {
692
+ for (const meta of acked.values()) {
693
+ const resubscribed = meta.expectGap;
694
+ meta.expectGap = true;
695
+ try {
696
+ meta.onSubscribed?.({ resubscribed });
697
+ } catch {
698
+ // A bad listener must not break the others or kill the read loop.
699
+ }
700
+ }
701
+ }
462
702
  return;
463
703
  }
464
704
  if (type === 'unsubscribed') {
@@ -473,17 +713,21 @@ export class BrassWsClient {
473
713
  if (type === 'error') {
474
714
  const topic = typeof frame.topic === 'string' ? frame.topic : undefined;
475
715
  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).
716
+ // The server's catch-all rejection: a deploy without the `ping`
717
+ // handler answers a heartbeat with it, and one without the
718
+ // `client_report` handler answers every sync-health report with it.
719
+ // Swallowed so a version skew is not an error the app sees, and
720
+ // deliberately not credited above, since a client whose every frame is
721
+ // refused would otherwise read its own rejections as a healthy socket.
479
722
  if (reason === 'bad_message' && !topic) return;
480
723
  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.
724
+ // The machine decides: silent retry, delivery to the topic's
725
+ // callbacks, swallow, or teardown + `fire_error`. A topic with no
726
+ // live state (already unsubscribed or torn down our side) swallows:
727
+ // the canonical case is a presence-update racing a `doc_gone`
728
+ // eviction, whose late `forbidden` reply would otherwise surface as
729
+ // a misleading "subscribe(...): forbidden" with no subscribe in
730
+ // flight to fail.
487
731
  this.dispatch(topic, { type: 'error', reason });
488
732
  return;
489
733
  }
@@ -494,14 +738,18 @@ export class BrassWsClient {
494
738
  }
495
739
  // Topic-scoped server frames: fan out to every callback for the topic.
496
740
  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
- }
741
+ this.fanOut(frame.topic, frame);
742
+ }
743
+ }
744
+
745
+ private fanOut(topic: string, frame: unknown): void {
746
+ const set = this.listeners.get(topic);
747
+ if (!set) return;
748
+ for (const cb of set.keys()) {
749
+ try {
750
+ cb(frame);
751
+ } catch {
752
+ // A bad listener must not break the others or kill the read loop.
505
753
  }
506
754
  }
507
755
  }
@@ -520,42 +768,67 @@ export class BrassWsClient {
520
768
  this.scheduleReconnect();
521
769
  }
522
770
 
771
+ // Tell the app the connection is failing, once per incident. `onError`
772
+ // has no dedupe of its own and reaches every listener, so an app that
773
+ // surfaces it would otherwise show the same outage repeatedly: the count
774
+ // is cleared by any credited frame, and a flapping link supplies one every
775
+ // so often, which starts a fresh episode each time.
776
+ private reportFailing(): void {
777
+ if (this.reportedFailing) return;
778
+ this.reportedFailing = true;
779
+ this.opts.fireError(
780
+ new Error(
781
+ `Brass WebSocket subscribe: reconnect failing after ${RECONNECT_MAX_ATTEMPTS} attempts`,
782
+ ),
783
+ );
784
+ }
785
+
523
786
  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;
787
+ this.failedAttempts += 1;
788
+ if (this.failedAttempts >= RECONNECT_MAX_ATTEMPTS) this.reportFailing();
789
+ // Past the threshold the loop SLOWS; it does not stop. Stopping is what
790
+ // makes a wrong verdict expensive: deciding a working connection is dead
791
+ // leaves a reader disconnected with nothing retrying, and every rule for
792
+ // telling a working connection from a failing one has some client it
793
+ // misjudges (a quiet topic draws no traffic, a short socket answers
794
+ // nothing, a flapping backend answers everything). Slowing costs one
795
+ // attempt every few minutes against an endpoint that is genuinely down,
796
+ // and it takes the whole class of "gave up on a healthy network" out of
797
+ // the design rather than out of the current predicate.
798
+ //
799
+ // The slow phase costs a reader who is being served by short sockets:
800
+ // a subscriber that never writes draws no `op_ack`, and a socket that
801
+ // dies before `HEARTBEAT_FIRST_MS` draws no `pong`, so frames pushed to
802
+ // it never speed the loop back up. Measured against a server pushing one
803
+ // event every 30s, that reader receives 4% of them at a 9s socket
804
+ // lifetime where pacing on opening receives 82%. It is the accepted
805
+ // cost: the signal that buys those frames back is one a hangup backend
806
+ // satisfies without carrying anything, which is the 86,000-sockets-a-day
807
+ // trade, and this reader still gets a full `doc_init` resync on every
808
+ // reconnect plus the failing report.
809
+ const delay =
810
+ this.failedAttempts > RECONNECT_MAX_ATTEMPTS
811
+ ? RECONNECT_IDLE_DELAY_MS
812
+ : RECONNECT_DELAYS_MS[
813
+ Math.min(this.failedAttempts - 1, RECONNECT_DELAYS_MS.length - 1)
814
+ ]!;
815
+ // Clear before arming. An out-of-band connect (a `send` during a backoff
816
+ // window) can bring this through a second time with a timer already
817
+ // armed, and the overwritten handle is one `close()` cannot reach: it
818
+ // fires afterwards, and `ensureConnected` re-enters at the front door,
819
+ // clearing `closedByUser` and standing a socket and heartbeat back up.
820
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
549
821
  this.reconnectTimer = setTimeout(() => {
550
822
  this.reconnectTimer = null;
823
+ // The user may have closed us while this was armed.
824
+ if (this.closedByUser) return;
551
825
  void this.ensureConnected();
552
826
  }, delay);
553
827
  }
554
828
 
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.
829
+ // Foreground return / network recovery: a reader is back, so reconnect
830
+ // promptly with a fresh token rather than waiting out a long delay or
831
+ // staying parked after a bail.
559
832
  private resume(): void {
560
833
  if (this.closedByUser) return;
561
834
  if (this.listeners.size === 0) return;
@@ -564,11 +837,26 @@ export class BrassWsClient {
564
837
  clearTimeout(this.reconnectTimer);
565
838
  this.reconnectTimer = null;
566
839
  }
567
- this.reconnectAttempt = 0;
568
- this.reconnectStartedAt = null;
840
+ this.failedAttempts = 0;
841
+ this.reportedFailing = false;
569
842
  void this.ensureConnected();
570
843
  }
571
844
 
845
+ /** Reconnects this client has made: every socket after the first. */
846
+ get reconnectCount(): number {
847
+ return Math.max(0, this.socketsOpened - 1);
848
+ }
849
+
850
+ /** Whether the page is hidden right now. */
851
+ get pageHidden(): boolean {
852
+ return this.isHidden();
853
+ }
854
+
855
+ /** Whether a socket is open right now. */
856
+ get connected(): boolean {
857
+ return this.ws !== null && this.ws.readyState === this.ws.OPEN;
858
+ }
859
+
572
860
  private isHidden(): boolean {
573
861
  const doc = (globalThis as { document?: { hidden?: boolean } }).document;
574
862
  return doc?.hidden === true;
@@ -586,7 +874,7 @@ export class BrassWsClient {
586
874
  ).document;
587
875
  if (doc && typeof doc.addEventListener === 'function') {
588
876
  const listener = (): void => {
589
- if (doc.hidden !== true) this.resume();
877
+ if (!this.isHidden()) this.resume();
590
878
  };
591
879
  this.visibilityListener = listener;
592
880
  doc.addEventListener('visibilitychange', listener);
@@ -625,14 +913,20 @@ export class BrassWsClient {
625
913
 
626
914
  private startHeartbeat(): void {
627
915
  this.stopHeartbeat();
628
- this.heartbeatTimer = setInterval(() => {
629
- this.sendIfOpen({ type: 'ping' });
630
- }, HEARTBEAT_INTERVAL_MS);
916
+ // One handle, re-armed after each ping, so the first delay can differ
917
+ // from the cadence without a second timer to keep in step.
918
+ const arm = (delay: number): void => {
919
+ this.heartbeatTimer = setTimeout(() => {
920
+ this.sendIfOpen({ type: 'ping' });
921
+ arm(HEARTBEAT_INTERVAL_MS);
922
+ }, delay);
923
+ };
924
+ arm(HEARTBEAT_FIRST_MS);
631
925
  }
632
926
 
633
927
  private stopHeartbeat(): void {
634
928
  if (this.heartbeatTimer) {
635
- clearInterval(this.heartbeatTimer);
929
+ clearTimeout(this.heartbeatTimer);
636
930
  this.heartbeatTimer = null;
637
931
  }
638
932
  }
@@ -657,6 +951,12 @@ function isObject(value: unknown): value is Record<string, unknown> {
657
951
  return typeof value === 'object' && value !== null;
658
952
  }
659
953
 
954
+ // True for a frame the server answers by name, which is what lets a
955
+ // subscriber that holds it re-assert exactly that frame later.
956
+ function isIdentifiedFrame(frame: unknown): boolean {
957
+ return isObject(frame) && typeof frame['client_op_id'] === 'string';
958
+ }
959
+
660
960
  // The frame's topic, if any. `send()` uses it to decide whether the frame is
661
961
  // subject to the subscribe-pending hold (see `pendingTopicSends`). Returns
662
962
  // `undefined` for transport frames (`subscribe` / `unsubscribe` / `ping` /