@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.
- package/AGENTS.md +72 -63
- package/CHANGELOG.md +97 -0
- package/README.md +325 -235
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +245 -76
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +22 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +49 -16
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +981 -237
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +204 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +30 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1905 -322
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +335 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +590 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +356 -83
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +162 -20
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +27 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +408 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +23 -17
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +21 -22
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +262 -80
- package/src/document-checkpoints.ts +30 -0
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +120 -8
- package/src/document-streams.ts +95 -9
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +38 -13
- package/src/documents.ts +1103 -290
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3407 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +216 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +7 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2441 -502
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +686 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +387 -89
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +183 -26
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +461 -92
- 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
|
|
8
|
-
// `onError
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
|
|
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.
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
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(
|
|
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
|
|
287
|
+
set = new Map();
|
|
135
288
|
this.listeners.set(topic, set);
|
|
136
289
|
}
|
|
137
|
-
set.
|
|
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
|
-
|
|
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
|
|
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.
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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
|
-
|
|
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.
|
|
424
|
-
this.
|
|
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.
|
|
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
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
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,
|
|
482
|
-
// `fire_error`. A topic with no
|
|
483
|
-
// or torn down our side) swallows:
|
|
484
|
-
// presence-update racing a `doc_gone`
|
|
485
|
-
// `forbidden` reply would otherwise surface as
|
|
486
|
-
// "subscribe(...): forbidden" with no subscribe in
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
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:
|
|
556
|
-
//
|
|
557
|
-
//
|
|
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.
|
|
568
|
-
this.
|
|
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 (
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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` /
|