@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/dist/ws-client.js CHANGED
@@ -4,35 +4,71 @@
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
  import { IDLE_TOPIC, holdsFrames, step, } from './topic-lifecycle.js';
26
25
  import { SDK_CLIENT_ID } from './version.js';
27
26
  const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
28
- const RECONNECT_GIVE_UP_MS = 5 * 60 * 1000;
27
+ // Failed attempts before the loop reports and slows, counted rather than
28
+ // timed because the loop's pacing is not ours: a hidden page's timers are
29
+ // throttled to roughly one firing a minute while wall-clock runs on at full
30
+ // speed. A budget measured in time therefore either bails on a healthy
31
+ // connection the moment a reader tabs away, or excludes hidden time and
32
+ // leaves a hidden page unbounded. A count measures what the loop actually
33
+ // did, so neither the page's visibility nor the machine's clock enters into
34
+ // it.
35
+ //
36
+ // Against the delays above this spans 301s, so a foregrounded page reaches it
37
+ // in about five minutes, which is what a genuinely broken connection
38
+ // (offline, signed out, endpoint down) looks like. A throttled one takes as
39
+ // long as its own timers take, roughly a quarter hour. Retuning the delays
40
+ // re-prices this, which `ws-client.test.ts` pins.
41
+ const RECONNECT_MAX_ATTEMPTS = 15;
42
+ // What the loop backs off to once it has passed that threshold. It keeps
43
+ // retrying at this pace for as long as topics are wanted, so a client is
44
+ // never left with nothing running: a misjudged connection recovers on its
45
+ // own within one interval instead of waiting for a reader to come back.
46
+ // Against an endpoint that is genuinely down this is the whole standing
47
+ // cost, which is why it is minutes rather than the 30s ceiling above.
48
+ const RECONNECT_IDLE_DELAY_MS = 5 * 60_000;
29
49
  const HEARTBEAT_INTERVAL_MS = 60_000;
50
+ // When a socket sends its FIRST ping. The steady cadence answers API
51
+ // Gateway's 10-minute idle timeout; this one exists so the reconnect loop
52
+ // can tell a working connection from a flapping one.
53
+ //
54
+ // It is a MINIMUM LIFETIME, not a latency tweak, and that is the part to
55
+ // keep: a `pong` is the only thing a quiet subscription can draw (a topic
56
+ // with no activity produces its ack and then nothing), so this delay is how
57
+ // long a socket must survive before it can clear the count. Send the first
58
+ // ping at open instead and a backend that accepts, answers and drops in
59
+ // 200ms credits itself on every socket, so the loop never ends and retries
60
+ // at the 1s floor for as long as the page is open.
61
+ //
62
+ // A background page clamps its timers to about a minute, so a hidden reader
63
+ // on a quiet topic falls back to the steady cadence. That page is bounded
64
+ // rather than wrong: it parks, and returning to it clears the count. A
65
+ // hidden page doing WORK is unaffected, because its ops draw `op_ack`.
66
+ const HEARTBEAT_FIRST_MS = 10_000;
30
67
  // Cap on the frames held for one topic's unacked subscribe, matching the
31
- // `outboundQueue` bound. Both hold the same kind of mid-reconnect frames
32
- // (a disconnect rolls every wanted topic back to `subscribing`, so ops sent
33
- // during the reconnect window are HELD here rather than in `outboundQueue`),
34
- // so both need the same memory ceiling or a doc edited through a long outage
35
- // grows one queue without bound. Generous for a single user's in-flight ops.
68
+ // `outboundQueue` bound. A disconnect rolls every wanted topic back to
69
+ // `subscribing`, so both queues hold the same kind of mid-reconnect frame
70
+ // and both need the same memory ceiling, or a topic written to through a
71
+ // long outage grows one of them without bound.
36
72
  const MAX_QUEUED_FRAMES = 1024;
37
73
  export class BrassWsClient {
38
74
  opts;
@@ -54,23 +90,53 @@ export class BrassWsClient {
54
90
  topics = new Map();
55
91
  listeners = new Map();
56
92
  // Outbound frames queued while the socket is not OPEN. Drained on `open`
57
- // (after subscribe replay) so an optimistic `mutate()` survives a reconnect
58
- // window.
93
+ // (after subscribe replay) so a frame nobody re-asserts survives a
94
+ // reconnect window.
59
95
  outboundQueue = [];
60
96
  // Per-topic queues of frames held while `holdsFrames` says the topic's
61
97
  // subscribe is unacked: a topic-scoped frame (`presence`, `op`) sent
62
- // before the `subscribed` reply races the SUB# row write and the
98
+ // before the `subscribed` reply races the subscription record write and the
63
99
  // server's conditional update fails as a spurious `forbidden`. The
64
100
  // machine's `flush_held` / `drop_held` effects drain or discard them.
65
- // Bounded per topic (`MAX_QUEUED_FRAMES`, drop-oldest) so a doc edited
66
- // through a long outage can't grow it without limit.
101
+ // Bounded per topic (`MAX_QUEUED_FRAMES`, drop-oldest) so a topic written
102
+ // to through a long outage can't grow it without limit.
67
103
  pendingTopicSends = new Map();
104
+ // Topics whose subscriber re-asserts its own identified frames (see
105
+ // `SubscribeOptions.ownsFrameRetry`). Held per topic rather than per
106
+ // listener because the decision is about the frame's fate on the wire,
107
+ // which every listener on the topic shares.
108
+ ownFrameRetryTopics = new Set();
68
109
  // Warn-once latch for held-queue overflow drops, reset when a topic's held
69
110
  // frames flush on the `subscribed` ack, so each unacked window reports once.
70
111
  heldOverflowReported = false;
71
- reconnectAttempt = 0;
72
- reconnectStartedAt = null;
112
+ // Consecutive failures, which paces the loop AND decides whether this
113
+ // connection is working at all. One number answers both, because the two
114
+ // questions have the same evidence behind them: a socket that opened is
115
+ // not progress, so pacing on opening pins the delay at the 1s floor
116
+ // against a backend that accepts and hangs up, at ~86,000 sockets a day
117
+ // per tab for zero connected time. Only a frame the server sent in answer
118
+ // to this client clears it.
119
+ failedAttempts = 0;
120
+ // Latches the failing report, so one incident is reported once however
121
+ // long it lasts.
122
+ reportedFailing = false;
123
+ // Credited frames on the CURRENT socket, reset on open. Two of them is
124
+ // what re-arms the latch, and the pair is the point: a single answer is
125
+ // survivable by a socket that dies straight after, where a second means
126
+ // this socket lived past `HEARTBEAT_FIRST_MS` into the steady cadence and
127
+ // is genuinely serving the reader. Without a recovery-observed clear the
128
+ // latch never lifts on its own, since `resume` returns early while a
129
+ // socket is open and a Node client has no `visibilitychange` to fire it,
130
+ // so a second outage after a recovery would go unreported for the life of
131
+ // the process.
132
+ creditsThisSocket = 0;
73
133
  reconnectTimer = null;
134
+ // Sockets this client has opened. Every topic and every open document
135
+ // rides this one socket, so the count is a fact about the client rather
136
+ // than about any one of them. Reported in a client's own sync-health
137
+ // report, where a high count beside unacked work is what distinguishes a
138
+ // connection that keeps dropping mid-write from one that is merely slow.
139
+ socketsOpened = 0;
74
140
  // Warn-once latch for outbound-queue overflow drops; reset when the queue
75
141
  // drains on a successful (re)connect, so each disconnect reports at most
76
142
  // once.
@@ -87,14 +153,22 @@ export class BrassWsClient {
87
153
  // Returns an unsubscribe that detaches THIS callback only; the WS-level
88
154
  // subscription stays open while other callbacks remain, and the unsubscribe
89
155
  // frame is sent only when the last detaches.
90
- subscribe(topic, callback) {
156
+ subscribe(topic, callback, options = {}) {
91
157
  let set = this.listeners.get(topic);
92
158
  const firstForTopic = !set;
93
159
  if (!set) {
94
- set = new Set();
160
+ set = new Map();
95
161
  this.listeners.set(topic, set);
96
162
  }
97
- set.add(callback);
163
+ set.set(callback, {
164
+ ...(options.onSubscribed ? { onSubscribed: options.onSubscribed } : {}),
165
+ ...(options.onEnded ? { onEnded: options.onEnded } : {}),
166
+ // Attaching to an already-established subscription: the next ack this
167
+ // listener sees is a reconnect, and the gap since attach is real.
168
+ expectGap: this.topics.get(topic)?.phase === 'established',
169
+ });
170
+ if (options.ownsFrameRetry)
171
+ this.ownFrameRetryTopics.add(topic);
98
172
  if (firstForTopic) {
99
173
  this.dispatch(topic, { type: 'user_subscribe' });
100
174
  void this.ensureConnected();
@@ -104,7 +178,16 @@ export class BrassWsClient {
104
178
  // reconnect in flight (a prior give-up or a drop while hidden): a fresh
105
179
  // subscribe is the retry signal. (A pending reconnect timer is left
106
180
  // alone so its backoff isn't reset.)
107
- this.resume();
181
+ //
182
+ // A closed client goes through the front door instead, because
183
+ // `resume()` refuses one and this branch would otherwise be the single
184
+ // retry signal with no way out: subscribing a topic the wanted-state
185
+ // still holds would leave the client dead for good, where subscribing
186
+ // any other topic recovers it.
187
+ if (this.closedByUser)
188
+ void this.ensureConnected();
189
+ else
190
+ this.resume();
108
191
  }
109
192
  return () => {
110
193
  const current = this.listeners.get(topic);
@@ -114,6 +197,7 @@ export class BrassWsClient {
114
197
  if (current.size > 0)
115
198
  return;
116
199
  this.listeners.delete(topic);
200
+ this.ownFrameRetryTopics.delete(topic);
117
201
  this.dispatch(topic, { type: 'user_unsubscribe' });
118
202
  // Drop the connection once no topic is wanted, so no idle socket lingers.
119
203
  if (this.listeners.size === 0) {
@@ -140,9 +224,31 @@ export class BrassWsClient {
140
224
  else {
141
225
  this.topics.set(topic, state);
142
226
  }
227
+ // Teardown detaches the topic's callbacks, so the subscribers owed an
228
+ // end notice are captured before the effects drop them and told once the
229
+ // topic is released: a subscriber that closes itself from `onEnded` then
230
+ // finds nothing left to unsubscribe. `fire_error` carries the same reason
231
+ // to the session-wide channel; this notice is what reaches the one
232
+ // subscriber that has to give up the work it holds for the topic.
233
+ const ending = event.type === 'error' && effects.some((e) => e.kind === 'drop_listeners')
234
+ ? {
235
+ reason: event.reason,
236
+ metas: [...(this.listeners.get(topic)?.values() ?? [])],
237
+ }
238
+ : null;
143
239
  for (const effect of effects) {
144
240
  this.runEffect(topic, effect);
145
241
  }
242
+ if (ending) {
243
+ for (const meta of ending.metas) {
244
+ try {
245
+ meta.onEnded?.({ reason: ending.reason });
246
+ }
247
+ catch {
248
+ // A bad listener must not break the others or kill the read loop.
249
+ }
250
+ }
251
+ }
146
252
  }
147
253
  runEffect(topic, effect) {
148
254
  switch (effect.kind) {
@@ -171,6 +277,11 @@ export class BrassWsClient {
171
277
  case 'drop_listeners':
172
278
  this.listeners.delete(topic);
173
279
  return;
280
+ case 'deliver_error':
281
+ // Rebuilt rather than forwarded: a topic error frame is exactly
282
+ // these three fields.
283
+ this.fanOut(topic, { type: 'error', topic, reason: effect.reason });
284
+ return;
174
285
  case 'fire_error':
175
286
  this.opts.fireError(new Error(`Brass WebSocket subscribe(${topic}): ${effect.reason}`));
176
287
  return;
@@ -181,22 +292,29 @@ export class BrassWsClient {
181
292
  // ops that must survive a mid-session reconnect). Failures (overflow, closed
182
293
  // transport) are silently dropped.
183
294
  send(frame) {
184
- // Topic-scoped frames sent before the `subscribed` ack race the SUB# row
295
+ // Topic-scoped frames sent before the `subscribed` ack race the subscription record
185
296
  // write and surface as spurious `forbidden`, so hold them until the ack
186
297
  // (see `pendingTopicSends`). The machine's `holdsFrames` covers every
187
298
  // unacked wanted state (fresh subscribe, reconnect replay, retry).
188
299
  const topic = topicOfFrame(frame);
189
300
  const topicState = topic !== undefined ? this.topics.get(topic) : undefined;
301
+ // Buffering this frame would duplicate it, because its owner sends it
302
+ // again on the next `subscribed` ack (see `ownsFrameRetry`).
303
+ const ownerResends = topic !== undefined &&
304
+ this.ownFrameRetryTopics.has(topic) &&
305
+ isIdentifiedFrame(frame);
190
306
  if (topic !== undefined && topicState && holdsFrames(topicState)) {
307
+ if (ownerResends)
308
+ return;
191
309
  let queue = this.pendingTopicSends.get(topic);
192
310
  if (!queue) {
193
311
  queue = [];
194
312
  this.pendingTopicSends.set(topic, queue);
195
313
  }
196
314
  // Bounded like `outboundQueue`: drop the oldest held frame on
197
- // overflow. Safe to drop here because an op is re-asserted by the
198
- // per-doc resend tracker (keyed independently on `client_op_id`), and
199
- // a dropped presence frame is superseded by the next `set()`.
315
+ // overflow. What reaches this queue is a frame nobody re-asserts (the
316
+ // ones that are re-asserted took the `ownerResends` exit above), so
317
+ // the drop is reported.
200
318
  if (queue.length >= MAX_QUEUED_FRAMES) {
201
319
  queue.shift();
202
320
  if (!this.heldOverflowReported) {
@@ -214,6 +332,8 @@ export class BrassWsClient {
214
332
  this.sendFrame(frame);
215
333
  return;
216
334
  }
335
+ if (ownerResends)
336
+ return;
217
337
  // Bounded so a misbehaving caller can't leak memory; 1024 is generous for
218
338
  // a single user's mid-session ops during a reconnect (each typically <1KB).
219
339
  if (this.outboundQueue.length >= 1024) {
@@ -242,6 +362,10 @@ export class BrassWsClient {
242
362
  // continuation that is about to bail.
243
363
  this.connectEpoch++;
244
364
  this.connecting = false;
365
+ // `failedAttempts` deliberately survives. An app reaches `close()`
366
+ // routinely rather than only on teardown, so clearing it here lets a
367
+ // client one step from the bound start over on every cycle and the loop
368
+ // never ends. A frame from the server is what clears it.
245
369
  this.removeLifecycleListeners();
246
370
  if (this.reconnectTimer) {
247
371
  clearTimeout(this.reconnectTimer);
@@ -266,6 +390,10 @@ export class BrassWsClient {
266
390
  }
267
391
  }
268
392
  async ensureConnected() {
393
+ // The reconnect bound is deliberately not consulted here. A caller's own
394
+ // connect always attempts, so a client parked on the bound still reaches
395
+ // a network that has recovered, and the frame that comes back clears the
396
+ // count. What the bound stops is the unattended retry loop.
269
397
  if (this.ws && this.ws.readyState === this.ws.OPEN)
270
398
  return;
271
399
  if (this.connecting)
@@ -328,6 +456,10 @@ export class BrassWsClient {
328
456
  // The `close` handler runs after `error` and drives reconnect;
329
457
  // swallowing keeps a transient network error from bubbling.
330
458
  });
459
+ // The superseded socket's `close` is dropped by the identity guard, so
460
+ // `onClose` never runs for it and its heartbeat would outlive it.
461
+ if (prev)
462
+ this.stopHeartbeat();
331
463
  if (prev) {
332
464
  try {
333
465
  prev.close();
@@ -343,7 +475,25 @@ export class BrassWsClient {
343
475
  if (epoch !== this.connectEpoch)
344
476
  return;
345
477
  this.connecting = false;
346
- this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
478
+ // Reported until the loop has said the connection is failing, then
479
+ // left to that one report. The throw here repeats on every attempt of
480
+ // an outage, so relaying each one turns a single incident into an
481
+ // error per attempt for as long as it lasts.
482
+ if (!this.reportedFailing) {
483
+ this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
484
+ }
485
+ // The token and the assertion reject on a momentary outage (a shed
486
+ // `/refresh`), which is the same transient the reconnect loop backs
487
+ // off on when a live socket drops. The throw happens before any
488
+ // socket exists, so no `close` event drives that loop and the retry
489
+ // is scheduled from here, on the same backoff and the same give-up
490
+ // budget. A signed-out session resolves null instead and takes the
491
+ // branch above, which stays terminal.
492
+ if (this.closedByUser)
493
+ return;
494
+ if (this.listeners.size === 0)
495
+ return;
496
+ this.scheduleReconnect();
347
497
  }
348
498
  }
349
499
  buildUrl(token, assertion) {
@@ -366,8 +516,8 @@ export class BrassWsClient {
366
516
  }
367
517
  onOpen() {
368
518
  this.connecting = false;
369
- this.reconnectAttempt = 0;
370
- this.reconnectStartedAt = null;
519
+ this.socketsOpened += 1;
520
+ this.creditsThisSocket = 0;
371
521
  // `socket_open` per topic: re-subscribes every wanted topic (in
372
522
  // subscription order) with a fresh retry grant, and prunes `tornDown`
373
523
  // leftovers. Runs before the outbound drain so the server sees the
@@ -379,6 +529,15 @@ export class BrassWsClient {
379
529
  this.flushOutbound();
380
530
  this.startHeartbeat();
381
531
  }
532
+ // Re-`send` each drained frame rather than writing it straight to the
533
+ // socket: the topic it belongs to has just been re-subscribed and its ack
534
+ // is still outstanding, so a frame written now races the server's
535
+ // subscription record write and comes back `not_subscribed`, which tears
536
+ // the topic down
537
+ // and leaves the document deaf on a live connection. `send` applies the
538
+ // hold (or the drop) the topic's current state calls for. The queue is
539
+ // swapped out first, so a frame `send` re-queues lands in the new array
540
+ // instead of being re-drained here.
382
541
  flushOutbound() {
383
542
  if (!this.ws || this.ws.readyState !== this.ws.OPEN)
384
543
  return;
@@ -386,7 +545,7 @@ export class BrassWsClient {
386
545
  this.outboundQueue = [];
387
546
  this.outboundOverflowReported = false;
388
547
  for (const frame of drained) {
389
- this.sendFrame(frame);
548
+ this.send(frame);
390
549
  }
391
550
  }
392
551
  onMessage(ev) {
@@ -403,9 +562,46 @@ export class BrassWsClient {
403
562
  const type = frame.type;
404
563
  if (typeof type !== 'string')
405
564
  return;
565
+ // The connection is working, so the reconnect loop starts over.
566
+ //
567
+ // What these have in common is that each ANSWERS something this client
568
+ // sent, which is why they read as the connection working where the rest
569
+ // of the protocol does not: the subscribe handshake is `subscribed` and
570
+ // then `doc_init` or `presence_snapshot`, so a backend that accepts,
571
+ // acks and hangs up would credit itself on every cycle, and an `error`
572
+ // frame is the server reporting it could NOT do the work.
573
+ //
574
+ // Credited as the evidence arrives rather than when the socket ends,
575
+ // because `close()` drops its own socket's `close` event (it nulls
576
+ // `this.ws` first, so the identity guard rejects it) and an app reaches
577
+ // `close()` routinely, so a session that ended that way would credit
578
+ // nothing however well it worked.
579
+ //
580
+ // Getting this wrong is survivable by design: the count decides when the
581
+ // loop slows and reports, not whether it runs at all, so a client this
582
+ // rule misjudges is late to a notification rather than disconnected.
583
+ if (type === 'pong' || type === 'op_ack') {
584
+ this.failedAttempts = 0;
585
+ this.creditsThisSocket += 1;
586
+ if (this.creditsThisSocket >= 2)
587
+ this.reportedFailing = false;
588
+ }
406
589
  if (type === 'subscribed' && typeof frame.topic === 'string') {
407
590
  // `flush_held` drains the frames held back for the unacked subscribe.
408
591
  this.dispatch(frame.topic, { type: 'subscribed_ack' });
592
+ const acked = this.listeners.get(frame.topic);
593
+ if (acked) {
594
+ for (const meta of acked.values()) {
595
+ const resubscribed = meta.expectGap;
596
+ meta.expectGap = true;
597
+ try {
598
+ meta.onSubscribed?.({ resubscribed });
599
+ }
600
+ catch {
601
+ // A bad listener must not break the others or kill the read loop.
602
+ }
603
+ }
604
+ }
409
605
  return;
410
606
  }
411
607
  if (type === 'unsubscribed') {
@@ -420,18 +616,22 @@ export class BrassWsClient {
420
616
  if (type === 'error') {
421
617
  const topic = typeof frame.topic === 'string' ? frame.topic : undefined;
422
618
  const reason = typeof frame.reason === 'string' ? frame.reason : 'error';
423
- // A deploy without the `ping` handler replies to our ping with a
424
- // topic-less `bad_message` instead of `pong`; treat it as a heartbeat
425
- // ack (see the header).
619
+ // The server's catch-all rejection: a deploy without the `ping`
620
+ // handler answers a heartbeat with it, and one without the
621
+ // `client_report` handler answers every sync-health report with it.
622
+ // Swallowed so a version skew is not an error the app sees, and
623
+ // deliberately not credited above, since a client whose every frame is
624
+ // refused would otherwise read its own rejections as a healthy socket.
426
625
  if (reason === 'bad_message' && !topic)
427
626
  return;
428
627
  if (topic) {
429
- // The machine decides: silent retry, swallow, or teardown +
430
- // `fire_error`. A topic with no live state (already unsubscribed
431
- // or torn down our side) swallows: the canonical case is a
432
- // presence-update racing a `doc_gone` eviction, whose late
433
- // `forbidden` reply would otherwise surface as a misleading
434
- // "subscribe(...): forbidden" with no subscribe in flight to fail.
628
+ // The machine decides: silent retry, delivery to the topic's
629
+ // callbacks, swallow, or teardown + `fire_error`. A topic with no
630
+ // live state (already unsubscribed or torn down our side) swallows:
631
+ // the canonical case is a presence-update racing a `doc_gone`
632
+ // eviction, whose late `forbidden` reply would otherwise surface as
633
+ // a misleading "subscribe(...): forbidden" with no subscribe in
634
+ // flight to fail.
435
635
  this.dispatch(topic, { type: 'error', reason });
436
636
  return;
437
637
  }
@@ -440,16 +640,19 @@ export class BrassWsClient {
440
640
  }
441
641
  // Topic-scoped server frames: fan out to every callback for the topic.
442
642
  if (typeof frame.topic === 'string') {
443
- const set = this.listeners.get(frame.topic);
444
- if (!set)
445
- return;
446
- for (const cb of set) {
447
- try {
448
- cb(frame);
449
- }
450
- catch {
451
- // A bad listener must not break the others or kill the read loop.
452
- }
643
+ this.fanOut(frame.topic, frame);
644
+ }
645
+ }
646
+ fanOut(topic, frame) {
647
+ const set = this.listeners.get(topic);
648
+ if (!set)
649
+ return;
650
+ for (const cb of set.keys()) {
651
+ try {
652
+ cb(frame);
653
+ }
654
+ catch {
655
+ // A bad listener must not break the others or kill the read loop.
453
656
  }
454
657
  }
455
658
  }
@@ -468,38 +671,62 @@ export class BrassWsClient {
468
671
  return;
469
672
  this.scheduleReconnect();
470
673
  }
471
- scheduleReconnect() {
472
- // Don't run the loop while hidden: throttled timers but advancing
473
- // wall-clock would burn the give-up budget without real attempts and bail
474
- // on a healthy connection. `resume()` restarts with a fresh budget on
475
- // foreground / network recovery.
476
- if (this.isHidden())
674
+ // Tell the app the connection is failing, once per incident. `onError`
675
+ // has no dedupe of its own and reaches every listener, so an app that
676
+ // surfaces it would otherwise show the same outage repeatedly: the count
677
+ // is cleared by any credited frame, and a flapping link supplies one every
678
+ // so often, which starts a fresh episode each time.
679
+ reportFailing() {
680
+ if (this.reportedFailing)
477
681
  return;
478
- if (this.reconnectStartedAt === null) {
479
- this.reconnectStartedAt = Date.now();
480
- }
481
- if (Date.now() - this.reconnectStartedAt >= RECONNECT_GIVE_UP_MS) {
482
- this.opts.fireError(new Error('Brass WebSocket subscribe: reconnect gave up after 5 minutes'));
483
- // Keep the wanted-state: a resume / fresh `subscribe` recovers by
484
- // replaying it. The budget resets so resume starts clean; clearing
485
- // listeners here would make the bail permanent (the background-tab bug
486
- // this guards against).
487
- this.reconnectAttempt = 0;
488
- this.reconnectStartedAt = null;
489
- return;
490
- }
491
- const idx = Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1);
492
- const delay = RECONNECT_DELAYS_MS[idx];
493
- this.reconnectAttempt += 1;
682
+ this.reportedFailing = true;
683
+ this.opts.fireError(new Error(`Brass WebSocket subscribe: reconnect failing after ${RECONNECT_MAX_ATTEMPTS} attempts`));
684
+ }
685
+ scheduleReconnect() {
686
+ this.failedAttempts += 1;
687
+ if (this.failedAttempts >= RECONNECT_MAX_ATTEMPTS)
688
+ this.reportFailing();
689
+ // Past the threshold the loop SLOWS; it does not stop. Stopping is what
690
+ // makes a wrong verdict expensive: deciding a working connection is dead
691
+ // leaves a reader disconnected with nothing retrying, and every rule for
692
+ // telling a working connection from a failing one has some client it
693
+ // misjudges (a quiet topic draws no traffic, a short socket answers
694
+ // nothing, a flapping backend answers everything). Slowing costs one
695
+ // attempt every few minutes against an endpoint that is genuinely down,
696
+ // and it takes the whole class of "gave up on a healthy network" out of
697
+ // the design rather than out of the current predicate.
698
+ //
699
+ // The slow phase costs a reader who is being served by short sockets:
700
+ // a subscriber that never writes draws no `op_ack`, and a socket that
701
+ // dies before `HEARTBEAT_FIRST_MS` draws no `pong`, so frames pushed to
702
+ // it never speed the loop back up. Measured against a server pushing one
703
+ // event every 30s, that reader receives 4% of them at a 9s socket
704
+ // lifetime where pacing on opening receives 82%. It is the accepted
705
+ // cost: the signal that buys those frames back is one a hangup backend
706
+ // satisfies without carrying anything, which is the 86,000-sockets-a-day
707
+ // trade, and this reader still gets a full `doc_init` resync on every
708
+ // reconnect plus the failing report.
709
+ const delay = this.failedAttempts > RECONNECT_MAX_ATTEMPTS
710
+ ? RECONNECT_IDLE_DELAY_MS
711
+ : RECONNECT_DELAYS_MS[Math.min(this.failedAttempts - 1, RECONNECT_DELAYS_MS.length - 1)];
712
+ // Clear before arming. An out-of-band connect (a `send` during a backoff
713
+ // window) can bring this through a second time with a timer already
714
+ // armed, and the overwritten handle is one `close()` cannot reach: it
715
+ // fires afterwards, and `ensureConnected` re-enters at the front door,
716
+ // clearing `closedByUser` and standing a socket and heartbeat back up.
717
+ if (this.reconnectTimer)
718
+ clearTimeout(this.reconnectTimer);
494
719
  this.reconnectTimer = setTimeout(() => {
495
720
  this.reconnectTimer = null;
721
+ // The user may have closed us while this was armed.
722
+ if (this.closedByUser)
723
+ return;
496
724
  void this.ensureConnected();
497
725
  }, delay);
498
726
  }
499
- // Foreground return / network recovery: reset the backoff + give-up budget
500
- // and reconnect promptly with a fresh token instead of waiting out a long
501
- // delay or staying dead after a bail. No-op when nothing is wanted, the user
502
- // closed us, or a socket is already open.
727
+ // Foreground return / network recovery: a reader is back, so reconnect
728
+ // promptly with a fresh token rather than waiting out a long delay or
729
+ // staying parked after a bail.
503
730
  resume() {
504
731
  if (this.closedByUser)
505
732
  return;
@@ -511,10 +738,22 @@ export class BrassWsClient {
511
738
  clearTimeout(this.reconnectTimer);
512
739
  this.reconnectTimer = null;
513
740
  }
514
- this.reconnectAttempt = 0;
515
- this.reconnectStartedAt = null;
741
+ this.failedAttempts = 0;
742
+ this.reportedFailing = false;
516
743
  void this.ensureConnected();
517
744
  }
745
+ /** Reconnects this client has made: every socket after the first. */
746
+ get reconnectCount() {
747
+ return Math.max(0, this.socketsOpened - 1);
748
+ }
749
+ /** Whether the page is hidden right now. */
750
+ get pageHidden() {
751
+ return this.isHidden();
752
+ }
753
+ /** Whether a socket is open right now. */
754
+ get connected() {
755
+ return this.ws !== null && this.ws.readyState === this.ws.OPEN;
756
+ }
518
757
  isHidden() {
519
758
  const doc = globalThis.document;
520
759
  return doc?.hidden === true;
@@ -525,7 +764,7 @@ export class BrassWsClient {
525
764
  const doc = globalThis.document;
526
765
  if (doc && typeof doc.addEventListener === 'function') {
527
766
  const listener = () => {
528
- if (doc.hidden !== true)
767
+ if (!this.isHidden())
529
768
  this.resume();
530
769
  };
531
770
  this.visibilityListener = listener;
@@ -553,13 +792,19 @@ export class BrassWsClient {
553
792
  }
554
793
  startHeartbeat() {
555
794
  this.stopHeartbeat();
556
- this.heartbeatTimer = setInterval(() => {
557
- this.sendIfOpen({ type: 'ping' });
558
- }, HEARTBEAT_INTERVAL_MS);
795
+ // One handle, re-armed after each ping, so the first delay can differ
796
+ // from the cadence without a second timer to keep in step.
797
+ const arm = (delay) => {
798
+ this.heartbeatTimer = setTimeout(() => {
799
+ this.sendIfOpen({ type: 'ping' });
800
+ arm(HEARTBEAT_INTERVAL_MS);
801
+ }, delay);
802
+ };
803
+ arm(HEARTBEAT_FIRST_MS);
559
804
  }
560
805
  stopHeartbeat() {
561
806
  if (this.heartbeatTimer) {
562
- clearInterval(this.heartbeatTimer);
807
+ clearTimeout(this.heartbeatTimer);
563
808
  this.heartbeatTimer = null;
564
809
  }
565
810
  }
@@ -583,6 +828,11 @@ export class BrassWsClient {
583
828
  function isObject(value) {
584
829
  return typeof value === 'object' && value !== null;
585
830
  }
831
+ // True for a frame the server answers by name, which is what lets a
832
+ // subscriber that holds it re-assert exactly that frame later.
833
+ function isIdentifiedFrame(frame) {
834
+ return isObject(frame) && typeof frame['client_op_id'] === 'string';
835
+ }
586
836
  // The frame's topic, if any. `send()` uses it to decide whether the frame is
587
837
  // subject to the subscribe-pending hold (see `pendingTopicSends`). Returns
588
838
  // `undefined` for transport frames (`subscribe` / `unsubscribe` / `ping` /