@brass-build/client 0.1.0 → 0.3.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 (213) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +325 -235
  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 +4 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +245 -76
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-checkpoints.d.ts +1 -0
  13. package/dist/document-checkpoints.d.ts.map +1 -1
  14. package/dist/document-checkpoints.js +27 -0
  15. package/dist/document-checkpoints.js.map +1 -1
  16. package/dist/document-engine.d.ts +4 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +9 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-signals.d.ts +31 -1
  21. package/dist/document-signals.d.ts.map +1 -1
  22. package/dist/document-signals.js +59 -0
  23. package/dist/document-signals.js.map +1 -1
  24. package/dist/document-streams.d.ts +8 -1
  25. package/dist/document-streams.d.ts.map +1 -1
  26. package/dist/document-streams.js +79 -8
  27. package/dist/document-streams.js.map +1 -1
  28. package/dist/document-thumbnail-capture.d.ts +20 -0
  29. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  30. package/dist/document-thumbnail-capture.js +247 -9
  31. package/dist/document-thumbnail-capture.js.map +1 -1
  32. package/dist/documents-namespace.d.ts.map +1 -1
  33. package/dist/documents-namespace.js +22 -3
  34. package/dist/documents-namespace.js.map +1 -1
  35. package/dist/documents.d.ts +49 -16
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +981 -237
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed-protocol.d.ts +3 -0
  40. package/dist/embed-protocol.d.ts.map +1 -0
  41. package/dist/embed-protocol.js +26 -0
  42. package/dist/embed-protocol.js.map +1 -0
  43. package/dist/emitter.d.ts.map +1 -1
  44. package/dist/emitter.js +8 -1
  45. package/dist/emitter.js.map +1 -1
  46. package/dist/export-guest.d.ts.map +1 -1
  47. package/dist/export-guest.js +14 -6
  48. package/dist/export-guest.js.map +1 -1
  49. package/dist/fetch-deadline.d.ts +22 -0
  50. package/dist/fetch-deadline.d.ts.map +1 -0
  51. package/dist/fetch-deadline.js +96 -0
  52. package/dist/fetch-deadline.js.map +1 -0
  53. package/dist/fetch-retry.d.ts +1 -0
  54. package/dist/fetch-retry.d.ts.map +1 -1
  55. package/dist/fetch-retry.js +12 -5
  56. package/dist/fetch-retry.js.map +1 -1
  57. package/dist/handle-types.d.ts +150 -0
  58. package/dist/handle-types.d.ts.map +1 -0
  59. package/dist/handle-types.js +8 -0
  60. package/dist/handle-types.js.map +1 -0
  61. package/dist/handles.d.ts +48 -48
  62. package/dist/handles.d.ts.map +1 -1
  63. package/dist/handles.js +3122 -501
  64. package/dist/handles.js.map +1 -1
  65. package/dist/hosted-runtime.d.ts.map +1 -1
  66. package/dist/hosted-runtime.js +8 -12
  67. package/dist/hosted-runtime.js.map +1 -1
  68. package/dist/hosted-ui.d.ts +0 -1
  69. package/dist/hosted-ui.d.ts.map +1 -1
  70. package/dist/hosted-ui.js +29 -24
  71. package/dist/hosted-ui.js.map +1 -1
  72. package/dist/importer.d.ts.map +1 -1
  73. package/dist/importer.js +204 -8
  74. package/dist/importer.js.map +1 -1
  75. package/dist/index.d.ts +10 -10
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +11 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal.d.ts +2 -1
  80. package/dist/internal.d.ts.map +1 -1
  81. package/dist/internal.js +1 -0
  82. package/dist/internal.js.map +1 -1
  83. package/dist/join-coverage.d.ts +12 -0
  84. package/dist/join-coverage.d.ts.map +1 -0
  85. package/dist/join-coverage.js +37 -0
  86. package/dist/join-coverage.js.map +1 -0
  87. package/dist/jwt.d.ts.map +1 -1
  88. package/dist/jwt.js +5 -1
  89. package/dist/jwt.js.map +1 -1
  90. package/dist/launch-context.js +2 -2
  91. package/dist/launch-context.js.map +1 -1
  92. package/dist/mutate-batching.d.ts +25 -2
  93. package/dist/mutate-batching.d.ts.map +1 -1
  94. package/dist/mutate-batching.js +57 -10
  95. package/dist/mutate-batching.js.map +1 -1
  96. package/dist/open-document.d.ts +30 -1
  97. package/dist/open-document.d.ts.map +1 -1
  98. package/dist/open-document.js +1905 -322
  99. package/dist/open-document.js.map +1 -1
  100. package/dist/presence.d.ts +28 -0
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +149 -7
  103. package/dist/presence.js.map +1 -1
  104. package/dist/record-paging.d.ts +335 -71
  105. package/dist/record-paging.d.ts.map +1 -1
  106. package/dist/record-paging.js +590 -127
  107. package/dist/record-paging.js.map +1 -1
  108. package/dist/selection-persistence.d.ts.map +1 -1
  109. package/dist/selection-persistence.js +11 -1
  110. package/dist/selection-persistence.js.map +1 -1
  111. package/dist/serializer.d.ts +30 -1
  112. package/dist/serializer.d.ts.map +1 -1
  113. package/dist/session-persistence.d.ts.map +1 -1
  114. package/dist/session-persistence.js +9 -8
  115. package/dist/session-persistence.js.map +1 -1
  116. package/dist/session-toolbar.d.ts +0 -1
  117. package/dist/session-toolbar.d.ts.map +1 -1
  118. package/dist/session-toolbar.js +356 -83
  119. package/dist/session-toolbar.js.map +1 -1
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +87 -18
  122. package/dist/session.js.map +1 -1
  123. package/dist/tail-ops.d.ts +14 -4
  124. package/dist/tail-ops.d.ts.map +1 -1
  125. package/dist/tail-ops.js +75 -15
  126. package/dist/tail-ops.js.map +1 -1
  127. package/dist/topic-lifecycle.d.ts +4 -0
  128. package/dist/topic-lifecycle.d.ts.map +1 -1
  129. package/dist/topic-lifecycle.js +25 -1
  130. package/dist/topic-lifecycle.js.map +1 -1
  131. package/dist/types.d.ts +47 -1
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/undo.d.ts +2 -3
  134. package/dist/undo.d.ts.map +1 -1
  135. package/dist/undo.js +11 -0
  136. package/dist/undo.js.map +1 -1
  137. package/dist/unload-guard.d.ts +5 -0
  138. package/dist/unload-guard.d.ts.map +1 -0
  139. package/dist/unload-guard.js +88 -0
  140. package/dist/unload-guard.js.map +1 -0
  141. package/dist/version-bar.d.ts +4 -3
  142. package/dist/version-bar.d.ts.map +1 -1
  143. package/dist/version-bar.js +162 -20
  144. package/dist/version-bar.js.map +1 -1
  145. package/dist/version.d.ts +2 -2
  146. package/dist/version.js +1 -1
  147. package/dist/vite.d.ts +18 -2
  148. package/dist/vite.d.ts.map +1 -1
  149. package/dist/vite.js +133 -8
  150. package/dist/vite.js.map +1 -1
  151. package/dist/when-saved.d.ts +2 -0
  152. package/dist/when-saved.d.ts.map +1 -0
  153. package/dist/when-saved.js +70 -0
  154. package/dist/when-saved.js.map +1 -0
  155. package/dist/ws-client.d.ts +27 -3
  156. package/dist/ws-client.d.ts.map +1 -1
  157. package/dist/ws-client.js +408 -89
  158. package/dist/ws-client.js.map +1 -1
  159. package/dist/ws-frame-limit.d.ts.map +1 -1
  160. package/dist/ws-frame-limit.js +23 -17
  161. package/dist/ws-frame-limit.js.map +1 -1
  162. package/docs/app-manifest.md +7 -8
  163. package/docs/custom-chrome.md +21 -22
  164. package/docs/hosted-ui-advanced.md +14 -17
  165. package/docs/imported-documents.md +53 -197
  166. package/docs/presence.md +45 -21
  167. package/docs/schemas.md +12 -17
  168. package/docs/streams.md +55 -57
  169. package/package.json +4 -3
  170. package/src/auth-flow.ts +90 -23
  171. package/src/client.ts +262 -80
  172. package/src/document-checkpoints.ts +30 -0
  173. package/src/document-engine.ts +15 -0
  174. package/src/document-signals.ts +120 -8
  175. package/src/document-streams.ts +95 -9
  176. package/src/document-thumbnail-capture.ts +253 -9
  177. package/src/documents-namespace.ts +38 -13
  178. package/src/documents.ts +1103 -290
  179. package/src/embed-protocol.ts +25 -0
  180. package/src/emitter.ts +8 -1
  181. package/src/export-guest.ts +15 -9
  182. package/src/fetch-deadline.ts +119 -0
  183. package/src/fetch-retry.ts +14 -5
  184. package/src/handle-types.ts +303 -0
  185. package/src/handles.ts +3407 -683
  186. package/src/hosted-runtime.ts +8 -12
  187. package/src/hosted-ui.ts +28 -32
  188. package/src/importer.ts +216 -8
  189. package/src/index.ts +29 -18
  190. package/src/internal.ts +7 -0
  191. package/src/join-coverage.ts +42 -0
  192. package/src/jwt.ts +8 -1
  193. package/src/launch-context.ts +2 -2
  194. package/src/mutate-batching.ts +60 -11
  195. package/src/open-document.ts +2441 -502
  196. package/src/presence.ts +210 -11
  197. package/src/record-paging.ts +686 -137
  198. package/src/selection-persistence.ts +10 -1
  199. package/src/serializer.ts +33 -1
  200. package/src/session-persistence.ts +9 -8
  201. package/src/session-toolbar.ts +387 -89
  202. package/src/session.ts +91 -17
  203. package/src/tail-ops.ts +76 -10
  204. package/src/topic-lifecycle.ts +31 -1
  205. package/src/types.ts +56 -4
  206. package/src/undo.ts +13 -5
  207. package/src/unload-guard.ts +91 -0
  208. package/src/version-bar.ts +183 -26
  209. package/src/version.ts +1 -1
  210. package/src/vite.ts +175 -10
  211. package/src/when-saved.ts +66 -0
  212. package/src/ws-client.ts +461 -92
  213. package/src/ws-frame-limit.ts +23 -17
package/src/ws-client.ts CHANGED
@@ -4,24 +4,27 @@
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`. The same interval notices a socket that has stopped carrying
21
+ // anything at all (`SOCKET_SILENCE_LIMIT_MS`), since on an idle connection
22
+ // it is the only thing running; such a socket is retired here so the
23
+ // reconnect loop can replace it, because a dead path does not reliably close
24
+ // itself. A topic-less `{ type: 'error', reason: 'bad_message' }` is the
25
+ // server's catch-all rejection, so it is swallowed rather than surfaced (a
26
+ // deploy without the `ping` handler answers a heartbeat with it) but never
27
+ // counted as the connection working. Both are dropped.
25
28
 
26
29
  import {
27
30
  IDLE_TOPIC,
@@ -62,18 +65,126 @@ interface WsClientOptions {
62
65
 
63
66
  export type WsTopicCallback = (frame: unknown) => void;
64
67
 
68
+
65
69
  const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
66
- const RECONNECT_GIVE_UP_MS = 5 * 60 * 1000;
70
+ // Failed attempts before the loop reports and slows, counted rather than
71
+ // timed because the loop's pacing is not ours: a hidden page's timers are
72
+ // throttled to roughly one firing a minute while wall-clock runs on at full
73
+ // speed. A budget measured in time therefore either bails on a healthy
74
+ // connection the moment a reader tabs away, or excludes hidden time and
75
+ // leaves a hidden page unbounded. A count measures what the loop actually
76
+ // did, so neither the page's visibility nor the machine's clock enters into
77
+ // it.
78
+ //
79
+ // Against the delays above this spans 301s, so a foregrounded page reaches it
80
+ // in about five minutes, which is what a genuinely broken connection
81
+ // (offline, signed out, endpoint down) looks like. A throttled one takes as
82
+ // long as its own timers take, roughly a quarter hour. Retuning the delays
83
+ // re-prices this, which `ws-client.test.ts` pins.
84
+ const RECONNECT_MAX_ATTEMPTS = 15;
85
+ // What the loop backs off to once it has passed that threshold. It keeps
86
+ // retrying at this pace for as long as topics are wanted, so a client is
87
+ // never left with nothing running: a misjudged connection recovers on its
88
+ // own within one interval instead of waiting for a reader to come back.
89
+ // Against an endpoint that is genuinely down this is the whole standing
90
+ // cost, which is why it is minutes rather than the 30s ceiling above.
91
+ const RECONNECT_IDLE_DELAY_MS = 5 * 60_000;
67
92
  const HEARTBEAT_INTERVAL_MS = 60_000;
93
+ // When a socket sends its FIRST ping. The steady cadence answers API
94
+ // Gateway's 10-minute idle timeout; this one exists so the reconnect loop
95
+ // can tell a working connection from a flapping one.
96
+ //
97
+ // It is a MINIMUM LIFETIME, not a latency tweak, and that is the part to
98
+ // keep: a `pong` is the only thing a quiet subscription can draw (a topic
99
+ // with no activity produces its ack and then nothing), so this delay is how
100
+ // long a socket must survive before it can clear the count. Send the first
101
+ // ping at open instead and a backend that accepts, answers and drops in
102
+ // 200ms credits itself on every socket, so the loop never ends and retries
103
+ // at the 1s floor for as long as the page is open.
104
+ //
105
+ // A background page clamps its timers to about a minute, so a hidden reader
106
+ // on a quiet topic falls back to the steady cadence. That page is bounded
107
+ // rather than wrong: it parks, and returning to it clears the count. A
108
+ // hidden page doing WORK is unaffected, because its ops draw `op_ack`.
109
+ const HEARTBEAT_FIRST_MS = 10_000;
110
+ // How long a socket may carry nothing at all before this client treats it as
111
+ // gone. A dead path does not always close: when a phone changes network
112
+ // neither end sends a FIN, so a socket that will never carry another byte
113
+ // still reads as OPEN, and nothing downstream notices, because the reconnect
114
+ // loop is driven by `close` and an op's resend goes out on the same socket.
115
+ // ANY inbound frame answers this, not just the `pong` the reconnect count
116
+ // credits: the question is whether the path still carries bytes. Two missed
117
+ // replies plus slack, so one dropped `pong` is not enough.
118
+ const SOCKET_SILENCE_LIMIT_MS = 150_000;
119
+ // Spread added to that limit, because the condition is SHARED: the server
120
+ // answers a `ping` after an awaited presence refresh and reap sweep, so its
121
+ // replies slow under exactly the load that makes sockets fragile, and every
122
+ // client goes silent at once. An unjittered threshold turns one degradation
123
+ // into a synchronized wave of reconnects landing on a service already
124
+ // shedding.
125
+ //
126
+ // Drawn PER SOCKET, not once per client. Clients that retire together
127
+ // reconnect together, so their next silence window is tighter than the last;
128
+ // a fixed per-client offset keeps its phase inside that convergence, where a
129
+ // fresh draw on every open scatters the schedule the wave just aligned. Two
130
+ // heartbeat intervals wide, since the check rides the 60s tick and a spread
131
+ // under one tick is quantized away.
132
+ const SOCKET_SILENCE_JITTER_MS = 120_000;
68
133
  // 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.
134
+ // `outboundQueue` bound. A disconnect rolls every wanted topic back to
135
+ // `subscribing`, so both queues hold the same kind of mid-reconnect frame
136
+ // and both need the same memory ceiling, or a topic written to through a
137
+ // long outage grows one of them without bound.
74
138
  const MAX_QUEUED_FRAMES = 1024;
75
139
 
76
- type Listeners = Set<WsTopicCallback>;
140
+ // Per-callback subscription metadata. `expectGap` is what makes the
141
+ // `onSubscribed` resubscribe flag honest: it arms once the listener has a
142
+ // live (`established`) subscription behind it, so any LATER ack means the
143
+ // socket dropped in between and frames may have been missed.
144
+ interface ListenerMeta {
145
+ onSubscribed?: (info: { resubscribed: boolean }) => void;
146
+ onEnded?: (info: { reason: string }) => void;
147
+ expectGap: boolean;
148
+ }
149
+
150
+ type Listeners = Map<WsTopicCallback, ListenerMeta>;
151
+
152
+ export interface SubscribeOptions {
153
+ // Fired on every `subscribed` ack for the topic while this callback is
154
+ // attached. `resubscribed: false` is the initial subscribe;
155
+ // `resubscribed: true` means the subscription was re-established after a
156
+ // drop, so frames fanned out in the gap were lost and the subscriber
157
+ // should reconcile by refetching (org-topic events carry no catch-up;
158
+ // only the `doc:` topics replay via `doc_init`).
159
+ onSubscribed?: (info: { resubscribed: boolean }) => void;
160
+ // Fired once when the server ends the subscription under a caller that
161
+ // still wants it: it refused the subscribe (a revoked grant), or evicted
162
+ // it again after the one silent re-subscribe a connection grants. The
163
+ // client detaches this callback as it fires, so the topic is over for
164
+ // this subscriber whatever the socket does next.
165
+ //
166
+ // A subscriber holding work for the topic reads this as the end of the
167
+ // road for that work: a document's unacked ops can no longer be acked, so
168
+ // it fails them here rather than re-sending them into a subscription the
169
+ // server has refused, which pins `savingCount` above zero and tells the
170
+ // app nothing.
171
+ onEnded?: (info: { reason: string }) => void;
172
+ // This subscriber re-asserts its own identified frames (the ones carrying
173
+ // a `client_op_id`) whenever the subscription is established, so the
174
+ // client DROPS such a frame it cannot put on the wire right now instead
175
+ // of buffering it.
176
+ //
177
+ // Buffering it as well is what writes it twice: the buffer flushes on the
178
+ // `subscribed` ack and the subscriber re-asserts on the same signal, and
179
+ // the server allocates a fresh seq per send rather than deduplicating on
180
+ // the id. Worse across an outage, where the topic sits unacked for its
181
+ // whole length and each of the subscriber's retries appends another copy
182
+ // to the buffer, all of which flush at once on the ack.
183
+ //
184
+ // Only for a subscriber that really does re-assert: a frame dropped here
185
+ // reaches the server only if its owner sends it again.
186
+ ownsFrameRetry?: boolean;
187
+ }
77
188
 
78
189
  export class BrassWsClient {
79
190
  private ws: WebSocket | null = null;
@@ -94,28 +205,66 @@ export class BrassWsClient {
94
205
  private topics = new Map<string, TopicState>();
95
206
  private listeners = new Map<string, Listeners>();
96
207
  // 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.
208
+ // (after subscribe replay) so a frame nobody re-asserts survives a
209
+ // reconnect window.
99
210
  private outboundQueue: unknown[] = [];
100
211
  // Per-topic queues of frames held while `holdsFrames` says the topic's
101
212
  // subscribe is unacked: a topic-scoped frame (`presence`, `op`) sent
102
- // before the `subscribed` reply races the SUB# row write and the
213
+ // before the `subscribed` reply races the subscription record write and the
103
214
  // server's conditional update fails as a spurious `forbidden`. The
104
215
  // 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.
216
+ // Bounded per topic (`MAX_QUEUED_FRAMES`, drop-oldest) so a topic written
217
+ // to through a long outage can't grow it without limit.
107
218
  private pendingTopicSends = new Map<string, unknown[]>();
219
+ // Topics whose subscriber re-asserts its own identified frames (see
220
+ // `SubscribeOptions.ownsFrameRetry`). Held per topic rather than per
221
+ // listener because the decision is about the frame's fate on the wire,
222
+ // which every listener on the topic shares.
223
+ private ownFrameRetryTopics = new Set<string>();
108
224
  // Warn-once latch for held-queue overflow drops, reset when a topic's held
109
225
  // frames flush on the `subscribed` ack, so each unacked window reports once.
110
226
  private heldOverflowReported = false;
111
- private reconnectAttempt = 0;
112
- private reconnectStartedAt: number | null = null;
227
+ // Consecutive failures, which paces the loop AND decides whether this
228
+ // connection is working at all. One number answers both, because the two
229
+ // questions have the same evidence behind them: a socket that opened is
230
+ // not progress, so pacing on opening pins the delay at the 1s floor
231
+ // against a backend that accepts and hangs up, at ~86,000 sockets a day
232
+ // per tab for zero connected time. Only a frame the server sent in answer
233
+ // to this client clears it.
234
+ private failedAttempts = 0;
235
+ // Latches the failing report, so one incident is reported once however
236
+ // long it lasts.
237
+ private reportedFailing = false;
238
+ // Credited frames on the CURRENT socket, reset on open. Two of them is
239
+ // what re-arms the latch, and the pair is the point: a single answer is
240
+ // survivable by a socket that dies straight after, where a second means
241
+ // this socket lived past `HEARTBEAT_FIRST_MS` into the steady cadence and
242
+ // is genuinely serving the reader. Without a recovery-observed clear the
243
+ // latch never lifts on its own, since `resume` returns early while a
244
+ // socket is open and a Node client has no `visibilitychange` to fire it,
245
+ // so a second outage after a recovery would go unreported for the life of
246
+ // the process.
247
+ private creditsThisSocket = 0;
248
+ // When this socket last carried anything inbound, set on open so a socket
249
+ // that never speaks is measured from the moment it opened. Zero while no
250
+ // socket is up, which stops a silence check firing against a connection
251
+ // that does not exist.
252
+ private lastInboundAt = 0;
253
+ // This socket's silence threshold, redrawn on every open. Held as state so
254
+ // one socket cannot move its own deadline every time the check looks at it.
255
+ private silenceLimitMs = SOCKET_SILENCE_LIMIT_MS;
113
256
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
257
+ // Sockets this client has opened. Every topic and every open document
258
+ // rides this one socket, so the count is a fact about the client rather
259
+ // than about any one of them. Reported in a client's own sync-health
260
+ // report, where a high count beside unacked work is what distinguishes a
261
+ // connection that keeps dropping mid-write from one that is merely slow.
262
+ private socketsOpened = 0;
114
263
  // Warn-once latch for outbound-queue overflow drops; reset when the queue
115
264
  // drains on a successful (re)connect, so each disconnect reports at most
116
265
  // once.
117
266
  private outboundOverflowReported = false;
118
- private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
267
+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
119
268
  private closedByUser = false;
120
269
  // Page-lifecycle listeners, installed lazily on first connect and removed on
121
270
  // user close. Guarded for non-DOM environments (Node, SSR).
@@ -127,14 +276,25 @@ export class BrassWsClient {
127
276
  // Returns an unsubscribe that detaches THIS callback only; the WS-level
128
277
  // subscription stays open while other callbacks remain, and the unsubscribe
129
278
  // frame is sent only when the last detaches.
130
- subscribe(topic: string, callback: WsTopicCallback): () => void {
279
+ subscribe(
280
+ topic: string,
281
+ callback: WsTopicCallback,
282
+ options: SubscribeOptions = {},
283
+ ): () => void {
131
284
  let set = this.listeners.get(topic);
132
285
  const firstForTopic = !set;
133
286
  if (!set) {
134
- set = new Set();
287
+ set = new Map();
135
288
  this.listeners.set(topic, set);
136
289
  }
137
- set.add(callback);
290
+ set.set(callback, {
291
+ ...(options.onSubscribed ? { onSubscribed: options.onSubscribed } : {}),
292
+ ...(options.onEnded ? { onEnded: options.onEnded } : {}),
293
+ // Attaching to an already-established subscription: the next ack this
294
+ // listener sees is a reconnect, and the gap since attach is real.
295
+ expectGap: this.topics.get(topic)?.phase === 'established',
296
+ });
297
+ if (options.ownsFrameRetry) this.ownFrameRetryTopics.add(topic);
138
298
 
139
299
  if (firstForTopic) {
140
300
  this.dispatch(topic, { type: 'user_subscribe' });
@@ -144,7 +304,14 @@ export class BrassWsClient {
144
304
  // reconnect in flight (a prior give-up or a drop while hidden): a fresh
145
305
  // subscribe is the retry signal. (A pending reconnect timer is left
146
306
  // alone so its backoff isn't reset.)
147
- this.resume();
307
+ //
308
+ // A closed client goes through the front door instead, because
309
+ // `resume()` refuses one and this branch would otherwise be the single
310
+ // retry signal with no way out: subscribing a topic the wanted-state
311
+ // still holds would leave the client dead for good, where subscribing
312
+ // any other topic recovers it.
313
+ if (this.closedByUser) void this.ensureConnected();
314
+ else this.resume();
148
315
  }
149
316
 
150
317
  return () => {
@@ -153,6 +320,7 @@ export class BrassWsClient {
153
320
  current.delete(callback);
154
321
  if (current.size > 0) return;
155
322
  this.listeners.delete(topic);
323
+ this.ownFrameRetryTopics.delete(topic);
156
324
  this.dispatch(topic, { type: 'user_unsubscribe' });
157
325
  // Drop the connection once no topic is wanted, so no idle socket lingers.
158
326
  if (this.listeners.size === 0) {
@@ -178,9 +346,31 @@ export class BrassWsClient {
178
346
  } else {
179
347
  this.topics.set(topic, state);
180
348
  }
349
+ // Teardown detaches the topic's callbacks, so the subscribers owed an
350
+ // end notice are captured before the effects drop them and told once the
351
+ // topic is released: a subscriber that closes itself from `onEnded` then
352
+ // finds nothing left to unsubscribe. `fire_error` carries the same reason
353
+ // to the session-wide channel; this notice is what reaches the one
354
+ // subscriber that has to give up the work it holds for the topic.
355
+ const ending =
356
+ event.type === 'error' && effects.some((e) => e.kind === 'drop_listeners')
357
+ ? {
358
+ reason: event.reason,
359
+ metas: [...(this.listeners.get(topic)?.values() ?? [])],
360
+ }
361
+ : null;
181
362
  for (const effect of effects) {
182
363
  this.runEffect(topic, effect);
183
364
  }
365
+ if (ending) {
366
+ for (const meta of ending.metas) {
367
+ try {
368
+ meta.onEnded?.({ reason: ending.reason });
369
+ } catch {
370
+ // A bad listener must not break the others or kill the read loop.
371
+ }
372
+ }
373
+ }
184
374
  }
185
375
 
186
376
  private runEffect(topic: string, effect: TopicEffect): void {
@@ -209,6 +399,11 @@ export class BrassWsClient {
209
399
  case 'drop_listeners':
210
400
  this.listeners.delete(topic);
211
401
  return;
402
+ case 'deliver_error':
403
+ // Rebuilt rather than forwarded: a topic error frame is exactly
404
+ // these three fields.
405
+ this.fanOut(topic, { type: 'error', topic, reason: effect.reason });
406
+ return;
212
407
  case 'fire_error':
213
408
  this.opts.fireError(
214
409
  new Error(`Brass WebSocket subscribe(${topic}): ${effect.reason}`),
@@ -222,23 +417,30 @@ export class BrassWsClient {
222
417
  // ops that must survive a mid-session reconnect). Failures (overflow, closed
223
418
  // transport) are silently dropped.
224
419
  send(frame: unknown): void {
225
- // Topic-scoped frames sent before the `subscribed` ack race the SUB# row
420
+ // Topic-scoped frames sent before the `subscribed` ack race the subscription record
226
421
  // write and surface as spurious `forbidden`, so hold them until the ack
227
422
  // (see `pendingTopicSends`). The machine's `holdsFrames` covers every
228
423
  // unacked wanted state (fresh subscribe, reconnect replay, retry).
229
424
  const topic = topicOfFrame(frame);
230
425
  const topicState = topic !== undefined ? this.topics.get(topic) : undefined;
426
+ // Buffering this frame would duplicate it, because its owner sends it
427
+ // again on the next `subscribed` ack (see `ownsFrameRetry`).
428
+ const ownerResends =
429
+ topic !== undefined &&
430
+ this.ownFrameRetryTopics.has(topic) &&
431
+ isIdentifiedFrame(frame);
231
432
 
232
433
  if (topic !== undefined && topicState && holdsFrames(topicState)) {
434
+ if (ownerResends) return;
233
435
  let queue = this.pendingTopicSends.get(topic);
234
436
  if (!queue) {
235
437
  queue = [];
236
438
  this.pendingTopicSends.set(topic, queue);
237
439
  }
238
440
  // 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()`.
441
+ // overflow. What reaches this queue is a frame nobody re-asserts (the
442
+ // ones that are re-asserted took the `ownerResends` exit above), so
443
+ // the drop is reported.
242
444
  if (queue.length >= MAX_QUEUED_FRAMES) {
243
445
  queue.shift();
244
446
  if (!this.heldOverflowReported) {
@@ -261,6 +463,7 @@ export class BrassWsClient {
261
463
  this.sendFrame(frame);
262
464
  return;
263
465
  }
466
+ if (ownerResends) return;
264
467
  // Bounded so a misbehaving caller can't leak memory; 1024 is generous for
265
468
  // a single user's mid-session ops during a reconnect (each typically <1KB).
266
469
  if (this.outboundQueue.length >= 1024) {
@@ -294,6 +497,10 @@ export class BrassWsClient {
294
497
  // continuation that is about to bail.
295
498
  this.connectEpoch++;
296
499
  this.connecting = false;
500
+ // `failedAttempts` deliberately survives. An app reaches `close()`
501
+ // routinely rather than only on teardown, so clearing it here lets a
502
+ // client one step from the bound start over on every cycle and the loop
503
+ // never ends. A frame from the server is what clears it.
297
504
  this.removeLifecycleListeners();
298
505
  if (this.reconnectTimer) {
299
506
  clearTimeout(this.reconnectTimer);
@@ -318,6 +525,10 @@ export class BrassWsClient {
318
525
  }
319
526
 
320
527
  private async ensureConnected(): Promise<void> {
528
+ // The reconnect bound is deliberately not consulted here. A caller's own
529
+ // connect always attempts, so a client parked on the bound still reaches
530
+ // a network that has recovered, and the frame that comes back clears the
531
+ // count. What the bound stops is the unattended retry loop.
321
532
  if (this.ws && this.ws.readyState === this.ws.OPEN) return;
322
533
  if (this.connecting) return;
323
534
  this.ensureLifecycleListeners();
@@ -378,6 +589,9 @@ export class BrassWsClient {
378
589
  // The `close` handler runs after `error` and drives reconnect;
379
590
  // swallowing keeps a transient network error from bubbling.
380
591
  });
592
+ // The superseded socket's `close` is dropped by the identity guard, so
593
+ // `onClose` never runs for it and its heartbeat would outlive it.
594
+ if (prev) this.stopHeartbeat();
381
595
  if (prev) {
382
596
  try {
383
597
  prev.close();
@@ -390,7 +604,23 @@ export class BrassWsClient {
390
604
  // `connecting` flag now belongs to the newer attempt, so leave it.
391
605
  if (epoch !== this.connectEpoch) return;
392
606
  this.connecting = false;
393
- this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
607
+ // Reported until the loop has said the connection is failing, then
608
+ // left to that one report. The throw here repeats on every attempt of
609
+ // an outage, so relaying each one turns a single incident into an
610
+ // error per attempt for as long as it lasts.
611
+ if (!this.reportedFailing) {
612
+ this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
613
+ }
614
+ // The token and the assertion reject on a momentary outage (a shed
615
+ // `/refresh`), which is the same transient the reconnect loop backs
616
+ // off on when a live socket drops. The throw happens before any
617
+ // socket exists, so no `close` event drives that loop and the retry
618
+ // is scheduled from here, on the same backoff and the same give-up
619
+ // budget. A signed-out session resolves null instead and takes the
620
+ // branch above, which stays terminal.
621
+ if (this.closedByUser) return;
622
+ if (this.listeners.size === 0) return;
623
+ this.scheduleReconnect();
394
624
  }
395
625
  }
396
626
 
@@ -420,8 +650,11 @@ export class BrassWsClient {
420
650
 
421
651
  private onOpen(): void {
422
652
  this.connecting = false;
423
- this.reconnectAttempt = 0;
424
- this.reconnectStartedAt = null;
653
+ this.socketsOpened += 1;
654
+ this.creditsThisSocket = 0;
655
+ this.lastInboundAt = Date.now();
656
+ this.silenceLimitMs =
657
+ SOCKET_SILENCE_LIMIT_MS + Math.random() * SOCKET_SILENCE_JITTER_MS;
425
658
  // `socket_open` per topic: re-subscribes every wanted topic (in
426
659
  // subscription order) with a fresh retry grant, and prunes `tornDown`
427
660
  // leftovers. Runs before the outbound drain so the server sees the
@@ -434,17 +667,29 @@ export class BrassWsClient {
434
667
  this.startHeartbeat();
435
668
  }
436
669
 
670
+ // Re-`send` each drained frame rather than writing it straight to the
671
+ // socket: the topic it belongs to has just been re-subscribed and its ack
672
+ // is still outstanding, so a frame written now races the server's
673
+ // subscription record write and comes back `not_subscribed`, which tears
674
+ // the topic down
675
+ // and leaves the document deaf on a live connection. `send` applies the
676
+ // hold (or the drop) the topic's current state calls for. The queue is
677
+ // swapped out first, so a frame `send` re-queues lands in the new array
678
+ // instead of being re-drained here.
437
679
  private flushOutbound(): void {
438
680
  if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
439
681
  const drained = this.outboundQueue;
440
682
  this.outboundQueue = [];
441
683
  this.outboundOverflowReported = false;
442
684
  for (const frame of drained) {
443
- this.sendFrame(frame);
685
+ this.send(frame);
444
686
  }
445
687
  }
446
688
 
447
689
  private onMessage(ev: MessageEvent): void {
690
+ // Stamped before the parse: garbage the server sent is still the server
691
+ // reaching us, and the path carrying bytes is the whole question.
692
+ this.lastInboundAt = Date.now();
448
693
  let frame: unknown;
449
694
  try {
450
695
  frame = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));
@@ -456,9 +701,45 @@ export class BrassWsClient {
456
701
  const type = frame.type;
457
702
  if (typeof type !== 'string') return;
458
703
 
704
+ // The connection is working, so the reconnect loop starts over.
705
+ //
706
+ // What these have in common is that each ANSWERS something this client
707
+ // sent, which is why they read as the connection working where the rest
708
+ // of the protocol does not: the subscribe handshake is `subscribed` and
709
+ // then `doc_init` or `presence_snapshot`, so a backend that accepts,
710
+ // acks and hangs up would credit itself on every cycle, and an `error`
711
+ // frame is the server reporting it could NOT do the work.
712
+ //
713
+ // Credited as the evidence arrives rather than when the socket ends,
714
+ // because `close()` drops its own socket's `close` event (it nulls
715
+ // `this.ws` first, so the identity guard rejects it) and an app reaches
716
+ // `close()` routinely, so a session that ended that way would credit
717
+ // nothing however well it worked.
718
+ //
719
+ // Getting this wrong is survivable by design: the count decides when the
720
+ // loop slows and reports, not whether it runs at all, so a client this
721
+ // rule misjudges is late to a notification rather than disconnected.
722
+ if (type === 'pong' || type === 'op_ack') {
723
+ this.failedAttempts = 0;
724
+ this.creditsThisSocket += 1;
725
+ if (this.creditsThisSocket >= 2) this.reportedFailing = false;
726
+ }
727
+
459
728
  if (type === 'subscribed' && typeof frame.topic === 'string') {
460
729
  // `flush_held` drains the frames held back for the unacked subscribe.
461
730
  this.dispatch(frame.topic, { type: 'subscribed_ack' });
731
+ const acked = this.listeners.get(frame.topic);
732
+ if (acked) {
733
+ for (const meta of acked.values()) {
734
+ const resubscribed = meta.expectGap;
735
+ meta.expectGap = true;
736
+ try {
737
+ meta.onSubscribed?.({ resubscribed });
738
+ } catch {
739
+ // A bad listener must not break the others or kill the read loop.
740
+ }
741
+ }
742
+ }
462
743
  return;
463
744
  }
464
745
  if (type === 'unsubscribed') {
@@ -473,17 +754,21 @@ export class BrassWsClient {
473
754
  if (type === 'error') {
474
755
  const topic = typeof frame.topic === 'string' ? frame.topic : undefined;
475
756
  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).
757
+ // The server's catch-all rejection: a deploy without the `ping`
758
+ // handler answers a heartbeat with it, and one without the
759
+ // `client_report` handler answers every sync-health report with it.
760
+ // Swallowed so a version skew is not an error the app sees, and
761
+ // deliberately not credited above, since a client whose every frame is
762
+ // refused would otherwise read its own rejections as a healthy socket.
479
763
  if (reason === 'bad_message' && !topic) return;
480
764
  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.
765
+ // The machine decides: silent retry, delivery to the topic's
766
+ // callbacks, swallow, or teardown + `fire_error`. A topic with no
767
+ // live state (already unsubscribed or torn down our side) swallows:
768
+ // the canonical case is a presence-update racing a `doc_gone`
769
+ // eviction, whose late `forbidden` reply would otherwise surface as
770
+ // a misleading "subscribe(...): forbidden" with no subscribe in
771
+ // flight to fail.
487
772
  this.dispatch(topic, { type: 'error', reason });
488
773
  return;
489
774
  }
@@ -494,14 +779,18 @@ export class BrassWsClient {
494
779
  }
495
780
  // Topic-scoped server frames: fan out to every callback for the topic.
496
781
  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
- }
782
+ this.fanOut(frame.topic, frame);
783
+ }
784
+ }
785
+
786
+ private fanOut(topic: string, frame: unknown): void {
787
+ const set = this.listeners.get(topic);
788
+ if (!set) return;
789
+ for (const cb of set.keys()) {
790
+ try {
791
+ cb(frame);
792
+ } catch {
793
+ // A bad listener must not break the others or kill the read loop.
505
794
  }
506
795
  }
507
796
  }
@@ -509,6 +798,7 @@ export class BrassWsClient {
509
798
  private onClose(): void {
510
799
  this.stopHeartbeat();
511
800
  this.ws = null;
801
+ this.lastInboundAt = 0;
512
802
  // Held frames are tied to the connection's unacked subscribes;
513
803
  // `socket_closed` drops them and preserves wantedness for the replay.
514
804
  for (const topic of this.topics.keys()) {
@@ -520,42 +810,67 @@ export class BrassWsClient {
520
810
  this.scheduleReconnect();
521
811
  }
522
812
 
813
+ // Tell the app the connection is failing, once per incident. `onError`
814
+ // has no dedupe of its own and reaches every listener, so an app that
815
+ // surfaces it would otherwise show the same outage repeatedly: the count
816
+ // is cleared by any credited frame, and a flapping link supplies one every
817
+ // so often, which starts a fresh episode each time.
818
+ private reportFailing(): void {
819
+ if (this.reportedFailing) return;
820
+ this.reportedFailing = true;
821
+ this.opts.fireError(
822
+ new Error(
823
+ `Brass WebSocket subscribe: reconnect failing after ${RECONNECT_MAX_ATTEMPTS} attempts`,
824
+ ),
825
+ );
826
+ }
827
+
523
828
  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;
829
+ this.failedAttempts += 1;
830
+ if (this.failedAttempts >= RECONNECT_MAX_ATTEMPTS) this.reportFailing();
831
+ // Past the threshold the loop SLOWS; it does not stop. Stopping is what
832
+ // makes a wrong verdict expensive: deciding a working connection is dead
833
+ // leaves a reader disconnected with nothing retrying, and every rule for
834
+ // telling a working connection from a failing one has some client it
835
+ // misjudges (a quiet topic draws no traffic, a short socket answers
836
+ // nothing, a flapping backend answers everything). Slowing costs one
837
+ // attempt every few minutes against an endpoint that is genuinely down,
838
+ // and it takes the whole class of "gave up on a healthy network" out of
839
+ // the design rather than out of the current predicate.
840
+ //
841
+ // The slow phase costs a reader who is being served by short sockets:
842
+ // a subscriber that never writes draws no `op_ack`, and a socket that
843
+ // dies before `HEARTBEAT_FIRST_MS` draws no `pong`, so frames pushed to
844
+ // it never speed the loop back up. Measured against a server pushing one
845
+ // event every 30s, that reader receives 4% of them at a 9s socket
846
+ // lifetime where pacing on opening receives 82%. It is the accepted
847
+ // cost: the signal that buys those frames back is one a hangup backend
848
+ // satisfies without carrying anything, which is the 86,000-sockets-a-day
849
+ // trade, and this reader still gets a full `doc_init` resync on every
850
+ // reconnect plus the failing report.
851
+ const delay =
852
+ this.failedAttempts > RECONNECT_MAX_ATTEMPTS
853
+ ? RECONNECT_IDLE_DELAY_MS
854
+ : RECONNECT_DELAYS_MS[
855
+ Math.min(this.failedAttempts - 1, RECONNECT_DELAYS_MS.length - 1)
856
+ ]!;
857
+ // Clear before arming. An out-of-band connect (a `send` during a backoff
858
+ // window) can bring this through a second time with a timer already
859
+ // armed, and the overwritten handle is one `close()` cannot reach: it
860
+ // fires afterwards, and `ensureConnected` re-enters at the front door,
861
+ // clearing `closedByUser` and standing a socket and heartbeat back up.
862
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
549
863
  this.reconnectTimer = setTimeout(() => {
550
864
  this.reconnectTimer = null;
865
+ // The user may have closed us while this was armed.
866
+ if (this.closedByUser) return;
551
867
  void this.ensureConnected();
552
868
  }, delay);
553
869
  }
554
870
 
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.
871
+ // Foreground return / network recovery: a reader is back, so reconnect
872
+ // promptly with a fresh token rather than waiting out a long delay or
873
+ // staying parked after a bail.
559
874
  private resume(): void {
560
875
  if (this.closedByUser) return;
561
876
  if (this.listeners.size === 0) return;
@@ -564,11 +879,26 @@ export class BrassWsClient {
564
879
  clearTimeout(this.reconnectTimer);
565
880
  this.reconnectTimer = null;
566
881
  }
567
- this.reconnectAttempt = 0;
568
- this.reconnectStartedAt = null;
882
+ this.failedAttempts = 0;
883
+ this.reportedFailing = false;
569
884
  void this.ensureConnected();
570
885
  }
571
886
 
887
+ /** Reconnects this client has made: every socket after the first. */
888
+ get reconnectCount(): number {
889
+ return Math.max(0, this.socketsOpened - 1);
890
+ }
891
+
892
+ /** Whether the page is hidden right now. */
893
+ get pageHidden(): boolean {
894
+ return this.isHidden();
895
+ }
896
+
897
+ /** Whether a socket is open right now. */
898
+ get connected(): boolean {
899
+ return this.ws !== null && this.ws.readyState === this.ws.OPEN;
900
+ }
901
+
572
902
  private isHidden(): boolean {
573
903
  const doc = (globalThis as { document?: { hidden?: boolean } }).document;
574
904
  return doc?.hidden === true;
@@ -586,7 +916,7 @@ export class BrassWsClient {
586
916
  ).document;
587
917
  if (doc && typeof doc.addEventListener === 'function') {
588
918
  const listener = (): void => {
589
- if (doc.hidden !== true) this.resume();
919
+ if (!this.isHidden()) this.resume();
590
920
  };
591
921
  this.visibilityListener = listener;
592
922
  doc.addEventListener('visibilitychange', listener);
@@ -625,14 +955,47 @@ export class BrassWsClient {
625
955
 
626
956
  private startHeartbeat(): void {
627
957
  this.stopHeartbeat();
628
- this.heartbeatTimer = setInterval(() => {
629
- this.sendIfOpen({ type: 'ping' });
630
- }, HEARTBEAT_INTERVAL_MS);
958
+ // One handle, re-armed after each ping, so the first delay can differ
959
+ // from the cadence without a second timer to keep in step.
960
+ const arm = (delay: number): void => {
961
+ this.heartbeatTimer = setTimeout(() => {
962
+ // The one thing running on an idle connection, so it is also what
963
+ // notices the socket stopped carrying anything.
964
+ if (this.socketHasGoneSilent()) {
965
+ this.dropSilentSocket();
966
+ return;
967
+ }
968
+ this.sendIfOpen({ type: 'ping' });
969
+ arm(HEARTBEAT_INTERVAL_MS);
970
+ }, delay);
971
+ };
972
+ arm(HEARTBEAT_FIRST_MS);
973
+ }
974
+
975
+ private socketHasGoneSilent(): boolean {
976
+ if (!this.ws || this.lastInboundAt === 0) return false;
977
+ return Date.now() - this.lastInboundAt >= this.silenceLimitMs;
978
+ }
979
+
980
+ // Retire a socket the server has stopped answering on. `onClose` runs
981
+ // directly rather than being waited for: closing a dead path has nothing to
982
+ // complete the handshake with, so the socket can sit in CLOSING and never
983
+ // fire `close`, which is the state this exists to escape. Running the
984
+ // teardown first also lets the identity guard drop a late `close`, so the
985
+ // reconnect is scheduled once.
986
+ private dropSilentSocket(): void {
987
+ const dead = this.ws;
988
+ this.onClose();
989
+ try {
990
+ dead?.close();
991
+ } catch {
992
+ // Closing an already-dead socket is fine; the teardown already ran.
993
+ }
631
994
  }
632
995
 
633
996
  private stopHeartbeat(): void {
634
997
  if (this.heartbeatTimer) {
635
- clearInterval(this.heartbeatTimer);
998
+ clearTimeout(this.heartbeatTimer);
636
999
  this.heartbeatTimer = null;
637
1000
  }
638
1001
  }
@@ -657,6 +1020,12 @@ function isObject(value: unknown): value is Record<string, unknown> {
657
1020
  return typeof value === 'object' && value !== null;
658
1021
  }
659
1022
 
1023
+ // True for a frame the server answers by name, which is what lets a
1024
+ // subscriber that holds it re-assert exactly that frame later.
1025
+ function isIdentifiedFrame(frame: unknown): boolean {
1026
+ return isObject(frame) && typeof frame['client_op_id'] === 'string';
1027
+ }
1028
+
660
1029
  // The frame's topic, if any. `send()` uses it to decide whether the frame is
661
1030
  // subject to the subscribe-pending hold (see `pendingTopicSends`). Returns
662
1031
  // `undefined` for transport frames (`subscribe` / `unsubscribe` / `ping` /