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