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