@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/dist/ws-client.js CHANGED
@@ -4,35 +4,98 @@
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
  import { IDLE_TOPIC, holdsFrames, step, } from './topic-lifecycle.js';
26
29
  import { SDK_CLIENT_ID } from './version.js';
27
30
  const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
28
- const RECONNECT_GIVE_UP_MS = 5 * 60 * 1000;
31
+ // Failed attempts before the loop reports and slows, counted rather than
32
+ // timed because the loop's pacing is not ours: a hidden page's timers are
33
+ // throttled to roughly one firing a minute while wall-clock runs on at full
34
+ // speed. A budget measured in time therefore either bails on a healthy
35
+ // connection the moment a reader tabs away, or excludes hidden time and
36
+ // leaves a hidden page unbounded. A count measures what the loop actually
37
+ // did, so neither the page's visibility nor the machine's clock enters into
38
+ // it.
39
+ //
40
+ // Against the delays above this spans 301s, so a foregrounded page reaches it
41
+ // in about five minutes, which is what a genuinely broken connection
42
+ // (offline, signed out, endpoint down) looks like. A throttled one takes as
43
+ // long as its own timers take, roughly a quarter hour. Retuning the delays
44
+ // re-prices this, which `ws-client.test.ts` pins.
45
+ const RECONNECT_MAX_ATTEMPTS = 15;
46
+ // What the loop backs off to once it has passed that threshold. It keeps
47
+ // retrying at this pace for as long as topics are wanted, so a client is
48
+ // never left with nothing running: a misjudged connection recovers on its
49
+ // own within one interval instead of waiting for a reader to come back.
50
+ // Against an endpoint that is genuinely down this is the whole standing
51
+ // cost, which is why it is minutes rather than the 30s ceiling above.
52
+ const RECONNECT_IDLE_DELAY_MS = 5 * 60_000;
29
53
  const HEARTBEAT_INTERVAL_MS = 60_000;
54
+ // When a socket sends its FIRST ping. The steady cadence answers API
55
+ // Gateway's 10-minute idle timeout; this one exists so the reconnect loop
56
+ // can tell a working connection from a flapping one.
57
+ //
58
+ // It is a MINIMUM LIFETIME, not a latency tweak, and that is the part to
59
+ // keep: a `pong` is the only thing a quiet subscription can draw (a topic
60
+ // with no activity produces its ack and then nothing), so this delay is how
61
+ // long a socket must survive before it can clear the count. Send the first
62
+ // ping at open instead and a backend that accepts, answers and drops in
63
+ // 200ms credits itself on every socket, so the loop never ends and retries
64
+ // at the 1s floor for as long as the page is open.
65
+ //
66
+ // A background page clamps its timers to about a minute, so a hidden reader
67
+ // on a quiet topic falls back to the steady cadence. That page is bounded
68
+ // rather than wrong: it parks, and returning to it clears the count. A
69
+ // hidden page doing WORK is unaffected, because its ops draw `op_ack`.
70
+ const HEARTBEAT_FIRST_MS = 10_000;
71
+ // How long a socket may carry nothing at all before this client treats it as
72
+ // gone. A dead path does not always close: when a phone changes network
73
+ // neither end sends a FIN, so a socket that will never carry another byte
74
+ // still reads as OPEN, and nothing downstream notices, because the reconnect
75
+ // loop is driven by `close` and an op's resend goes out on the same socket.
76
+ // ANY inbound frame answers this, not just the `pong` the reconnect count
77
+ // credits: the question is whether the path still carries bytes. Two missed
78
+ // replies plus slack, so one dropped `pong` is not enough.
79
+ const SOCKET_SILENCE_LIMIT_MS = 150_000;
80
+ // Spread added to that limit, because the condition is SHARED: the server
81
+ // answers a `ping` after an awaited presence refresh and reap sweep, so its
82
+ // replies slow under exactly the load that makes sockets fragile, and every
83
+ // client goes silent at once. An unjittered threshold turns one degradation
84
+ // into a synchronized wave of reconnects landing on a service already
85
+ // shedding.
86
+ //
87
+ // Drawn PER SOCKET, not once per client. Clients that retire together
88
+ // reconnect together, so their next silence window is tighter than the last;
89
+ // a fixed per-client offset keeps its phase inside that convergence, where a
90
+ // fresh draw on every open scatters the schedule the wave just aligned. Two
91
+ // heartbeat intervals wide, since the check rides the 60s tick and a spread
92
+ // under one tick is quantized away.
93
+ const SOCKET_SILENCE_JITTER_MS = 120_000;
30
94
  // 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.
95
+ // `outboundQueue` bound. A disconnect rolls every wanted topic back to
96
+ // `subscribing`, so both queues hold the same kind of mid-reconnect frame
97
+ // and both need the same memory ceiling, or a topic written to through a
98
+ // long outage grows one of them without bound.
36
99
  const MAX_QUEUED_FRAMES = 1024;
37
100
  export class BrassWsClient {
38
101
  opts;
@@ -54,23 +117,61 @@ export class BrassWsClient {
54
117
  topics = new Map();
55
118
  listeners = new Map();
56
119
  // 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.
120
+ // (after subscribe replay) so a frame nobody re-asserts survives a
121
+ // reconnect window.
59
122
  outboundQueue = [];
60
123
  // Per-topic queues of frames held while `holdsFrames` says the topic's
61
124
  // subscribe is unacked: a topic-scoped frame (`presence`, `op`) sent
62
- // before the `subscribed` reply races the SUB# row write and the
125
+ // before the `subscribed` reply races the subscription record write and the
63
126
  // server's conditional update fails as a spurious `forbidden`. The
64
127
  // 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.
128
+ // Bounded per topic (`MAX_QUEUED_FRAMES`, drop-oldest) so a topic written
129
+ // to through a long outage can't grow it without limit.
67
130
  pendingTopicSends = new Map();
131
+ // Topics whose subscriber re-asserts its own identified frames (see
132
+ // `SubscribeOptions.ownsFrameRetry`). Held per topic rather than per
133
+ // listener because the decision is about the frame's fate on the wire,
134
+ // which every listener on the topic shares.
135
+ ownFrameRetryTopics = new Set();
68
136
  // Warn-once latch for held-queue overflow drops, reset when a topic's held
69
137
  // frames flush on the `subscribed` ack, so each unacked window reports once.
70
138
  heldOverflowReported = false;
71
- reconnectAttempt = 0;
72
- reconnectStartedAt = null;
139
+ // Consecutive failures, which paces the loop AND decides whether this
140
+ // connection is working at all. One number answers both, because the two
141
+ // questions have the same evidence behind them: a socket that opened is
142
+ // not progress, so pacing on opening pins the delay at the 1s floor
143
+ // against a backend that accepts and hangs up, at ~86,000 sockets a day
144
+ // per tab for zero connected time. Only a frame the server sent in answer
145
+ // to this client clears it.
146
+ failedAttempts = 0;
147
+ // Latches the failing report, so one incident is reported once however
148
+ // long it lasts.
149
+ reportedFailing = false;
150
+ // Credited frames on the CURRENT socket, reset on open. Two of them is
151
+ // what re-arms the latch, and the pair is the point: a single answer is
152
+ // survivable by a socket that dies straight after, where a second means
153
+ // this socket lived past `HEARTBEAT_FIRST_MS` into the steady cadence and
154
+ // is genuinely serving the reader. Without a recovery-observed clear the
155
+ // latch never lifts on its own, since `resume` returns early while a
156
+ // socket is open and a Node client has no `visibilitychange` to fire it,
157
+ // so a second outage after a recovery would go unreported for the life of
158
+ // the process.
159
+ creditsThisSocket = 0;
160
+ // When this socket last carried anything inbound, set on open so a socket
161
+ // that never speaks is measured from the moment it opened. Zero while no
162
+ // socket is up, which stops a silence check firing against a connection
163
+ // that does not exist.
164
+ lastInboundAt = 0;
165
+ // This socket's silence threshold, redrawn on every open. Held as state so
166
+ // one socket cannot move its own deadline every time the check looks at it.
167
+ silenceLimitMs = SOCKET_SILENCE_LIMIT_MS;
73
168
  reconnectTimer = null;
169
+ // Sockets this client has opened. Every topic and every open document
170
+ // rides this one socket, so the count is a fact about the client rather
171
+ // than about any one of them. Reported in a client's own sync-health
172
+ // report, where a high count beside unacked work is what distinguishes a
173
+ // connection that keeps dropping mid-write from one that is merely slow.
174
+ socketsOpened = 0;
74
175
  // Warn-once latch for outbound-queue overflow drops; reset when the queue
75
176
  // drains on a successful (re)connect, so each disconnect reports at most
76
177
  // once.
@@ -87,14 +188,22 @@ export class BrassWsClient {
87
188
  // Returns an unsubscribe that detaches THIS callback only; the WS-level
88
189
  // subscription stays open while other callbacks remain, and the unsubscribe
89
190
  // frame is sent only when the last detaches.
90
- subscribe(topic, callback) {
191
+ subscribe(topic, callback, options = {}) {
91
192
  let set = this.listeners.get(topic);
92
193
  const firstForTopic = !set;
93
194
  if (!set) {
94
- set = new Set();
195
+ set = new Map();
95
196
  this.listeners.set(topic, set);
96
197
  }
97
- set.add(callback);
198
+ set.set(callback, {
199
+ ...(options.onSubscribed ? { onSubscribed: options.onSubscribed } : {}),
200
+ ...(options.onEnded ? { onEnded: options.onEnded } : {}),
201
+ // Attaching to an already-established subscription: the next ack this
202
+ // listener sees is a reconnect, and the gap since attach is real.
203
+ expectGap: this.topics.get(topic)?.phase === 'established',
204
+ });
205
+ if (options.ownsFrameRetry)
206
+ this.ownFrameRetryTopics.add(topic);
98
207
  if (firstForTopic) {
99
208
  this.dispatch(topic, { type: 'user_subscribe' });
100
209
  void this.ensureConnected();
@@ -104,7 +213,16 @@ export class BrassWsClient {
104
213
  // reconnect in flight (a prior give-up or a drop while hidden): a fresh
105
214
  // subscribe is the retry signal. (A pending reconnect timer is left
106
215
  // alone so its backoff isn't reset.)
107
- this.resume();
216
+ //
217
+ // A closed client goes through the front door instead, because
218
+ // `resume()` refuses one and this branch would otherwise be the single
219
+ // retry signal with no way out: subscribing a topic the wanted-state
220
+ // still holds would leave the client dead for good, where subscribing
221
+ // any other topic recovers it.
222
+ if (this.closedByUser)
223
+ void this.ensureConnected();
224
+ else
225
+ this.resume();
108
226
  }
109
227
  return () => {
110
228
  const current = this.listeners.get(topic);
@@ -114,6 +232,7 @@ export class BrassWsClient {
114
232
  if (current.size > 0)
115
233
  return;
116
234
  this.listeners.delete(topic);
235
+ this.ownFrameRetryTopics.delete(topic);
117
236
  this.dispatch(topic, { type: 'user_unsubscribe' });
118
237
  // Drop the connection once no topic is wanted, so no idle socket lingers.
119
238
  if (this.listeners.size === 0) {
@@ -140,9 +259,31 @@ export class BrassWsClient {
140
259
  else {
141
260
  this.topics.set(topic, state);
142
261
  }
262
+ // Teardown detaches the topic's callbacks, so the subscribers owed an
263
+ // end notice are captured before the effects drop them and told once the
264
+ // topic is released: a subscriber that closes itself from `onEnded` then
265
+ // finds nothing left to unsubscribe. `fire_error` carries the same reason
266
+ // to the session-wide channel; this notice is what reaches the one
267
+ // subscriber that has to give up the work it holds for the topic.
268
+ const ending = event.type === 'error' && effects.some((e) => e.kind === 'drop_listeners')
269
+ ? {
270
+ reason: event.reason,
271
+ metas: [...(this.listeners.get(topic)?.values() ?? [])],
272
+ }
273
+ : null;
143
274
  for (const effect of effects) {
144
275
  this.runEffect(topic, effect);
145
276
  }
277
+ if (ending) {
278
+ for (const meta of ending.metas) {
279
+ try {
280
+ meta.onEnded?.({ reason: ending.reason });
281
+ }
282
+ catch {
283
+ // A bad listener must not break the others or kill the read loop.
284
+ }
285
+ }
286
+ }
146
287
  }
147
288
  runEffect(topic, effect) {
148
289
  switch (effect.kind) {
@@ -171,6 +312,11 @@ export class BrassWsClient {
171
312
  case 'drop_listeners':
172
313
  this.listeners.delete(topic);
173
314
  return;
315
+ case 'deliver_error':
316
+ // Rebuilt rather than forwarded: a topic error frame is exactly
317
+ // these three fields.
318
+ this.fanOut(topic, { type: 'error', topic, reason: effect.reason });
319
+ return;
174
320
  case 'fire_error':
175
321
  this.opts.fireError(new Error(`Brass WebSocket subscribe(${topic}): ${effect.reason}`));
176
322
  return;
@@ -181,22 +327,29 @@ export class BrassWsClient {
181
327
  // ops that must survive a mid-session reconnect). Failures (overflow, closed
182
328
  // transport) are silently dropped.
183
329
  send(frame) {
184
- // Topic-scoped frames sent before the `subscribed` ack race the SUB# row
330
+ // Topic-scoped frames sent before the `subscribed` ack race the subscription record
185
331
  // write and surface as spurious `forbidden`, so hold them until the ack
186
332
  // (see `pendingTopicSends`). The machine's `holdsFrames` covers every
187
333
  // unacked wanted state (fresh subscribe, reconnect replay, retry).
188
334
  const topic = topicOfFrame(frame);
189
335
  const topicState = topic !== undefined ? this.topics.get(topic) : undefined;
336
+ // Buffering this frame would duplicate it, because its owner sends it
337
+ // again on the next `subscribed` ack (see `ownsFrameRetry`).
338
+ const ownerResends = topic !== undefined &&
339
+ this.ownFrameRetryTopics.has(topic) &&
340
+ isIdentifiedFrame(frame);
190
341
  if (topic !== undefined && topicState && holdsFrames(topicState)) {
342
+ if (ownerResends)
343
+ return;
191
344
  let queue = this.pendingTopicSends.get(topic);
192
345
  if (!queue) {
193
346
  queue = [];
194
347
  this.pendingTopicSends.set(topic, queue);
195
348
  }
196
349
  // 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()`.
350
+ // overflow. What reaches this queue is a frame nobody re-asserts (the
351
+ // ones that are re-asserted took the `ownerResends` exit above), so
352
+ // the drop is reported.
200
353
  if (queue.length >= MAX_QUEUED_FRAMES) {
201
354
  queue.shift();
202
355
  if (!this.heldOverflowReported) {
@@ -214,6 +367,8 @@ export class BrassWsClient {
214
367
  this.sendFrame(frame);
215
368
  return;
216
369
  }
370
+ if (ownerResends)
371
+ return;
217
372
  // Bounded so a misbehaving caller can't leak memory; 1024 is generous for
218
373
  // a single user's mid-session ops during a reconnect (each typically <1KB).
219
374
  if (this.outboundQueue.length >= 1024) {
@@ -242,6 +397,10 @@ export class BrassWsClient {
242
397
  // continuation that is about to bail.
243
398
  this.connectEpoch++;
244
399
  this.connecting = false;
400
+ // `failedAttempts` deliberately survives. An app reaches `close()`
401
+ // routinely rather than only on teardown, so clearing it here lets a
402
+ // client one step from the bound start over on every cycle and the loop
403
+ // never ends. A frame from the server is what clears it.
245
404
  this.removeLifecycleListeners();
246
405
  if (this.reconnectTimer) {
247
406
  clearTimeout(this.reconnectTimer);
@@ -266,6 +425,10 @@ export class BrassWsClient {
266
425
  }
267
426
  }
268
427
  async ensureConnected() {
428
+ // The reconnect bound is deliberately not consulted here. A caller's own
429
+ // connect always attempts, so a client parked on the bound still reaches
430
+ // a network that has recovered, and the frame that comes back clears the
431
+ // count. What the bound stops is the unattended retry loop.
269
432
  if (this.ws && this.ws.readyState === this.ws.OPEN)
270
433
  return;
271
434
  if (this.connecting)
@@ -328,6 +491,10 @@ export class BrassWsClient {
328
491
  // The `close` handler runs after `error` and drives reconnect;
329
492
  // swallowing keeps a transient network error from bubbling.
330
493
  });
494
+ // The superseded socket's `close` is dropped by the identity guard, so
495
+ // `onClose` never runs for it and its heartbeat would outlive it.
496
+ if (prev)
497
+ this.stopHeartbeat();
331
498
  if (prev) {
332
499
  try {
333
500
  prev.close();
@@ -343,7 +510,25 @@ export class BrassWsClient {
343
510
  if (epoch !== this.connectEpoch)
344
511
  return;
345
512
  this.connecting = false;
346
- this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
513
+ // Reported until the loop has said the connection is failing, then
514
+ // left to that one report. The throw here repeats on every attempt of
515
+ // an outage, so relaying each one turns a single incident into an
516
+ // error per attempt for as long as it lasts.
517
+ if (!this.reportedFailing) {
518
+ this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
519
+ }
520
+ // The token and the assertion reject on a momentary outage (a shed
521
+ // `/refresh`), which is the same transient the reconnect loop backs
522
+ // off on when a live socket drops. The throw happens before any
523
+ // socket exists, so no `close` event drives that loop and the retry
524
+ // is scheduled from here, on the same backoff and the same give-up
525
+ // budget. A signed-out session resolves null instead and takes the
526
+ // branch above, which stays terminal.
527
+ if (this.closedByUser)
528
+ return;
529
+ if (this.listeners.size === 0)
530
+ return;
531
+ this.scheduleReconnect();
347
532
  }
348
533
  }
349
534
  buildUrl(token, assertion) {
@@ -366,8 +551,11 @@ export class BrassWsClient {
366
551
  }
367
552
  onOpen() {
368
553
  this.connecting = false;
369
- this.reconnectAttempt = 0;
370
- this.reconnectStartedAt = null;
554
+ this.socketsOpened += 1;
555
+ this.creditsThisSocket = 0;
556
+ this.lastInboundAt = Date.now();
557
+ this.silenceLimitMs =
558
+ SOCKET_SILENCE_LIMIT_MS + Math.random() * SOCKET_SILENCE_JITTER_MS;
371
559
  // `socket_open` per topic: re-subscribes every wanted topic (in
372
560
  // subscription order) with a fresh retry grant, and prunes `tornDown`
373
561
  // leftovers. Runs before the outbound drain so the server sees the
@@ -379,6 +567,15 @@ export class BrassWsClient {
379
567
  this.flushOutbound();
380
568
  this.startHeartbeat();
381
569
  }
570
+ // Re-`send` each drained frame rather than writing it straight to the
571
+ // socket: the topic it belongs to has just been re-subscribed and its ack
572
+ // is still outstanding, so a frame written now races the server's
573
+ // subscription record write and comes back `not_subscribed`, which tears
574
+ // the topic down
575
+ // and leaves the document deaf on a live connection. `send` applies the
576
+ // hold (or the drop) the topic's current state calls for. The queue is
577
+ // swapped out first, so a frame `send` re-queues lands in the new array
578
+ // instead of being re-drained here.
382
579
  flushOutbound() {
383
580
  if (!this.ws || this.ws.readyState !== this.ws.OPEN)
384
581
  return;
@@ -386,10 +583,13 @@ export class BrassWsClient {
386
583
  this.outboundQueue = [];
387
584
  this.outboundOverflowReported = false;
388
585
  for (const frame of drained) {
389
- this.sendFrame(frame);
586
+ this.send(frame);
390
587
  }
391
588
  }
392
589
  onMessage(ev) {
590
+ // Stamped before the parse: garbage the server sent is still the server
591
+ // reaching us, and the path carrying bytes is the whole question.
592
+ this.lastInboundAt = Date.now();
393
593
  let frame;
394
594
  try {
395
595
  frame = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));
@@ -403,9 +603,46 @@ export class BrassWsClient {
403
603
  const type = frame.type;
404
604
  if (typeof type !== 'string')
405
605
  return;
606
+ // The connection is working, so the reconnect loop starts over.
607
+ //
608
+ // What these have in common is that each ANSWERS something this client
609
+ // sent, which is why they read as the connection working where the rest
610
+ // of the protocol does not: the subscribe handshake is `subscribed` and
611
+ // then `doc_init` or `presence_snapshot`, so a backend that accepts,
612
+ // acks and hangs up would credit itself on every cycle, and an `error`
613
+ // frame is the server reporting it could NOT do the work.
614
+ //
615
+ // Credited as the evidence arrives rather than when the socket ends,
616
+ // because `close()` drops its own socket's `close` event (it nulls
617
+ // `this.ws` first, so the identity guard rejects it) and an app reaches
618
+ // `close()` routinely, so a session that ended that way would credit
619
+ // nothing however well it worked.
620
+ //
621
+ // Getting this wrong is survivable by design: the count decides when the
622
+ // loop slows and reports, not whether it runs at all, so a client this
623
+ // rule misjudges is late to a notification rather than disconnected.
624
+ if (type === 'pong' || type === 'op_ack') {
625
+ this.failedAttempts = 0;
626
+ this.creditsThisSocket += 1;
627
+ if (this.creditsThisSocket >= 2)
628
+ this.reportedFailing = false;
629
+ }
406
630
  if (type === 'subscribed' && typeof frame.topic === 'string') {
407
631
  // `flush_held` drains the frames held back for the unacked subscribe.
408
632
  this.dispatch(frame.topic, { type: 'subscribed_ack' });
633
+ const acked = this.listeners.get(frame.topic);
634
+ if (acked) {
635
+ for (const meta of acked.values()) {
636
+ const resubscribed = meta.expectGap;
637
+ meta.expectGap = true;
638
+ try {
639
+ meta.onSubscribed?.({ resubscribed });
640
+ }
641
+ catch {
642
+ // A bad listener must not break the others or kill the read loop.
643
+ }
644
+ }
645
+ }
409
646
  return;
410
647
  }
411
648
  if (type === 'unsubscribed') {
@@ -420,18 +657,22 @@ export class BrassWsClient {
420
657
  if (type === 'error') {
421
658
  const topic = typeof frame.topic === 'string' ? frame.topic : undefined;
422
659
  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).
660
+ // The server's catch-all rejection: a deploy without the `ping`
661
+ // handler answers a heartbeat with it, and one without the
662
+ // `client_report` handler answers every sync-health report with it.
663
+ // Swallowed so a version skew is not an error the app sees, and
664
+ // deliberately not credited above, since a client whose every frame is
665
+ // refused would otherwise read its own rejections as a healthy socket.
426
666
  if (reason === 'bad_message' && !topic)
427
667
  return;
428
668
  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.
669
+ // The machine decides: silent retry, delivery to the topic's
670
+ // callbacks, swallow, or teardown + `fire_error`. A topic with no
671
+ // live state (already unsubscribed or torn down our side) swallows:
672
+ // the canonical case is a presence-update racing a `doc_gone`
673
+ // eviction, whose late `forbidden` reply would otherwise surface as
674
+ // a misleading "subscribe(...): forbidden" with no subscribe in
675
+ // flight to fail.
435
676
  this.dispatch(topic, { type: 'error', reason });
436
677
  return;
437
678
  }
@@ -440,22 +681,26 @@ export class BrassWsClient {
440
681
  }
441
682
  // Topic-scoped server frames: fan out to every callback for the topic.
442
683
  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
- }
684
+ this.fanOut(frame.topic, frame);
685
+ }
686
+ }
687
+ fanOut(topic, frame) {
688
+ const set = this.listeners.get(topic);
689
+ if (!set)
690
+ return;
691
+ for (const cb of set.keys()) {
692
+ try {
693
+ cb(frame);
694
+ }
695
+ catch {
696
+ // A bad listener must not break the others or kill the read loop.
453
697
  }
454
698
  }
455
699
  }
456
700
  onClose() {
457
701
  this.stopHeartbeat();
458
702
  this.ws = null;
703
+ this.lastInboundAt = 0;
459
704
  // Held frames are tied to the connection's unacked subscribes;
460
705
  // `socket_closed` drops them and preserves wantedness for the replay.
461
706
  for (const topic of this.topics.keys()) {
@@ -468,38 +713,62 @@ export class BrassWsClient {
468
713
  return;
469
714
  this.scheduleReconnect();
470
715
  }
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())
716
+ // Tell the app the connection is failing, once per incident. `onError`
717
+ // has no dedupe of its own and reaches every listener, so an app that
718
+ // surfaces it would otherwise show the same outage repeatedly: the count
719
+ // is cleared by any credited frame, and a flapping link supplies one every
720
+ // so often, which starts a fresh episode each time.
721
+ reportFailing() {
722
+ if (this.reportedFailing)
477
723
  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;
724
+ this.reportedFailing = true;
725
+ this.opts.fireError(new Error(`Brass WebSocket subscribe: reconnect failing after ${RECONNECT_MAX_ATTEMPTS} attempts`));
726
+ }
727
+ scheduleReconnect() {
728
+ this.failedAttempts += 1;
729
+ if (this.failedAttempts >= RECONNECT_MAX_ATTEMPTS)
730
+ this.reportFailing();
731
+ // Past the threshold the loop SLOWS; it does not stop. Stopping is what
732
+ // makes a wrong verdict expensive: deciding a working connection is dead
733
+ // leaves a reader disconnected with nothing retrying, and every rule for
734
+ // telling a working connection from a failing one has some client it
735
+ // misjudges (a quiet topic draws no traffic, a short socket answers
736
+ // nothing, a flapping backend answers everything). Slowing costs one
737
+ // attempt every few minutes against an endpoint that is genuinely down,
738
+ // and it takes the whole class of "gave up on a healthy network" out of
739
+ // the design rather than out of the current predicate.
740
+ //
741
+ // The slow phase costs a reader who is being served by short sockets:
742
+ // a subscriber that never writes draws no `op_ack`, and a socket that
743
+ // dies before `HEARTBEAT_FIRST_MS` draws no `pong`, so frames pushed to
744
+ // it never speed the loop back up. Measured against a server pushing one
745
+ // event every 30s, that reader receives 4% of them at a 9s socket
746
+ // lifetime where pacing on opening receives 82%. It is the accepted
747
+ // cost: the signal that buys those frames back is one a hangup backend
748
+ // satisfies without carrying anything, which is the 86,000-sockets-a-day
749
+ // trade, and this reader still gets a full `doc_init` resync on every
750
+ // reconnect plus the failing report.
751
+ const delay = this.failedAttempts > RECONNECT_MAX_ATTEMPTS
752
+ ? RECONNECT_IDLE_DELAY_MS
753
+ : RECONNECT_DELAYS_MS[Math.min(this.failedAttempts - 1, RECONNECT_DELAYS_MS.length - 1)];
754
+ // Clear before arming. An out-of-band connect (a `send` during a backoff
755
+ // window) can bring this through a second time with a timer already
756
+ // armed, and the overwritten handle is one `close()` cannot reach: it
757
+ // fires afterwards, and `ensureConnected` re-enters at the front door,
758
+ // clearing `closedByUser` and standing a socket and heartbeat back up.
759
+ if (this.reconnectTimer)
760
+ clearTimeout(this.reconnectTimer);
494
761
  this.reconnectTimer = setTimeout(() => {
495
762
  this.reconnectTimer = null;
763
+ // The user may have closed us while this was armed.
764
+ if (this.closedByUser)
765
+ return;
496
766
  void this.ensureConnected();
497
767
  }, delay);
498
768
  }
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.
769
+ // Foreground return / network recovery: a reader is back, so reconnect
770
+ // promptly with a fresh token rather than waiting out a long delay or
771
+ // staying parked after a bail.
503
772
  resume() {
504
773
  if (this.closedByUser)
505
774
  return;
@@ -511,10 +780,22 @@ export class BrassWsClient {
511
780
  clearTimeout(this.reconnectTimer);
512
781
  this.reconnectTimer = null;
513
782
  }
514
- this.reconnectAttempt = 0;
515
- this.reconnectStartedAt = null;
783
+ this.failedAttempts = 0;
784
+ this.reportedFailing = false;
516
785
  void this.ensureConnected();
517
786
  }
787
+ /** Reconnects this client has made: every socket after the first. */
788
+ get reconnectCount() {
789
+ return Math.max(0, this.socketsOpened - 1);
790
+ }
791
+ /** Whether the page is hidden right now. */
792
+ get pageHidden() {
793
+ return this.isHidden();
794
+ }
795
+ /** Whether a socket is open right now. */
796
+ get connected() {
797
+ return this.ws !== null && this.ws.readyState === this.ws.OPEN;
798
+ }
518
799
  isHidden() {
519
800
  const doc = globalThis.document;
520
801
  return doc?.hidden === true;
@@ -525,7 +806,7 @@ export class BrassWsClient {
525
806
  const doc = globalThis.document;
526
807
  if (doc && typeof doc.addEventListener === 'function') {
527
808
  const listener = () => {
528
- if (doc.hidden !== true)
809
+ if (!this.isHidden())
529
810
  this.resume();
530
811
  };
531
812
  this.visibilityListener = listener;
@@ -553,13 +834,46 @@ export class BrassWsClient {
553
834
  }
554
835
  startHeartbeat() {
555
836
  this.stopHeartbeat();
556
- this.heartbeatTimer = setInterval(() => {
557
- this.sendIfOpen({ type: 'ping' });
558
- }, HEARTBEAT_INTERVAL_MS);
837
+ // One handle, re-armed after each ping, so the first delay can differ
838
+ // from the cadence without a second timer to keep in step.
839
+ const arm = (delay) => {
840
+ this.heartbeatTimer = setTimeout(() => {
841
+ // The one thing running on an idle connection, so it is also what
842
+ // notices the socket stopped carrying anything.
843
+ if (this.socketHasGoneSilent()) {
844
+ this.dropSilentSocket();
845
+ return;
846
+ }
847
+ this.sendIfOpen({ type: 'ping' });
848
+ arm(HEARTBEAT_INTERVAL_MS);
849
+ }, delay);
850
+ };
851
+ arm(HEARTBEAT_FIRST_MS);
852
+ }
853
+ socketHasGoneSilent() {
854
+ if (!this.ws || this.lastInboundAt === 0)
855
+ return false;
856
+ return Date.now() - this.lastInboundAt >= this.silenceLimitMs;
857
+ }
858
+ // Retire a socket the server has stopped answering on. `onClose` runs
859
+ // directly rather than being waited for: closing a dead path has nothing to
860
+ // complete the handshake with, so the socket can sit in CLOSING and never
861
+ // fire `close`, which is the state this exists to escape. Running the
862
+ // teardown first also lets the identity guard drop a late `close`, so the
863
+ // reconnect is scheduled once.
864
+ dropSilentSocket() {
865
+ const dead = this.ws;
866
+ this.onClose();
867
+ try {
868
+ dead?.close();
869
+ }
870
+ catch {
871
+ // Closing an already-dead socket is fine; the teardown already ran.
872
+ }
559
873
  }
560
874
  stopHeartbeat() {
561
875
  if (this.heartbeatTimer) {
562
- clearInterval(this.heartbeatTimer);
876
+ clearTimeout(this.heartbeatTimer);
563
877
  this.heartbeatTimer = null;
564
878
  }
565
879
  }
@@ -583,6 +897,11 @@ export class BrassWsClient {
583
897
  function isObject(value) {
584
898
  return typeof value === 'object' && value !== null;
585
899
  }
900
+ // True for a frame the server answers by name, which is what lets a
901
+ // subscriber that holds it re-assert exactly that frame later.
902
+ function isIdentifiedFrame(frame) {
903
+ return isObject(frame) && typeof frame['client_op_id'] === 'string';
904
+ }
586
905
  // The frame's topic, if any. `send()` uses it to decide whether the frame is
587
906
  // subject to the subscribe-pending hold (see `pendingTopicSends`). Returns
588
907
  // `undefined` for transport frames (`subscribe` / `unsubscribe` / `ping` /