@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/dist/ws-client.js
CHANGED
|
@@ -4,35 +4,71 @@
|
|
|
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
|
import { IDLE_TOPIC, holdsFrames, step, } from './topic-lifecycle.js';
|
|
26
25
|
import { SDK_CLIENT_ID } from './version.js';
|
|
27
26
|
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
28
|
-
|
|
27
|
+
// Failed attempts before the loop reports and slows, counted rather than
|
|
28
|
+
// timed because the loop's pacing is not ours: a hidden page's timers are
|
|
29
|
+
// throttled to roughly one firing a minute while wall-clock runs on at full
|
|
30
|
+
// speed. A budget measured in time therefore either bails on a healthy
|
|
31
|
+
// connection the moment a reader tabs away, or excludes hidden time and
|
|
32
|
+
// leaves a hidden page unbounded. A count measures what the loop actually
|
|
33
|
+
// did, so neither the page's visibility nor the machine's clock enters into
|
|
34
|
+
// it.
|
|
35
|
+
//
|
|
36
|
+
// Against the delays above this spans 301s, so a foregrounded page reaches it
|
|
37
|
+
// in about five minutes, which is what a genuinely broken connection
|
|
38
|
+
// (offline, signed out, endpoint down) looks like. A throttled one takes as
|
|
39
|
+
// long as its own timers take, roughly a quarter hour. Retuning the delays
|
|
40
|
+
// re-prices this, which `ws-client.test.ts` pins.
|
|
41
|
+
const RECONNECT_MAX_ATTEMPTS = 15;
|
|
42
|
+
// What the loop backs off to once it has passed that threshold. It keeps
|
|
43
|
+
// retrying at this pace for as long as topics are wanted, so a client is
|
|
44
|
+
// never left with nothing running: a misjudged connection recovers on its
|
|
45
|
+
// own within one interval instead of waiting for a reader to come back.
|
|
46
|
+
// Against an endpoint that is genuinely down this is the whole standing
|
|
47
|
+
// cost, which is why it is minutes rather than the 30s ceiling above.
|
|
48
|
+
const RECONNECT_IDLE_DELAY_MS = 5 * 60_000;
|
|
29
49
|
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
50
|
+
// When a socket sends its FIRST ping. The steady cadence answers API
|
|
51
|
+
// Gateway's 10-minute idle timeout; this one exists so the reconnect loop
|
|
52
|
+
// can tell a working connection from a flapping one.
|
|
53
|
+
//
|
|
54
|
+
// It is a MINIMUM LIFETIME, not a latency tweak, and that is the part to
|
|
55
|
+
// keep: a `pong` is the only thing a quiet subscription can draw (a topic
|
|
56
|
+
// with no activity produces its ack and then nothing), so this delay is how
|
|
57
|
+
// long a socket must survive before it can clear the count. Send the first
|
|
58
|
+
// ping at open instead and a backend that accepts, answers and drops in
|
|
59
|
+
// 200ms credits itself on every socket, so the loop never ends and retries
|
|
60
|
+
// at the 1s floor for as long as the page is open.
|
|
61
|
+
//
|
|
62
|
+
// A background page clamps its timers to about a minute, so a hidden reader
|
|
63
|
+
// on a quiet topic falls back to the steady cadence. That page is bounded
|
|
64
|
+
// rather than wrong: it parks, and returning to it clears the count. A
|
|
65
|
+
// hidden page doing WORK is unaffected, because its ops draw `op_ack`.
|
|
66
|
+
const HEARTBEAT_FIRST_MS = 10_000;
|
|
30
67
|
// 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.
|
|
68
|
+
// `outboundQueue` bound. A disconnect rolls every wanted topic back to
|
|
69
|
+
// `subscribing`, so both queues hold the same kind of mid-reconnect frame
|
|
70
|
+
// and both need the same memory ceiling, or a topic written to through a
|
|
71
|
+
// long outage grows one of them without bound.
|
|
36
72
|
const MAX_QUEUED_FRAMES = 1024;
|
|
37
73
|
export class BrassWsClient {
|
|
38
74
|
opts;
|
|
@@ -54,23 +90,53 @@ export class BrassWsClient {
|
|
|
54
90
|
topics = new Map();
|
|
55
91
|
listeners = new Map();
|
|
56
92
|
// Outbound frames queued while the socket is not OPEN. Drained on `open`
|
|
57
|
-
// (after subscribe replay) so
|
|
58
|
-
// window.
|
|
93
|
+
// (after subscribe replay) so a frame nobody re-asserts survives a
|
|
94
|
+
// reconnect window.
|
|
59
95
|
outboundQueue = [];
|
|
60
96
|
// Per-topic queues of frames held while `holdsFrames` says the topic's
|
|
61
97
|
// subscribe is unacked: a topic-scoped frame (`presence`, `op`) sent
|
|
62
|
-
// before the `subscribed` reply races the
|
|
98
|
+
// before the `subscribed` reply races the subscription record write and the
|
|
63
99
|
// server's conditional update fails as a spurious `forbidden`. The
|
|
64
100
|
// 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.
|
|
101
|
+
// Bounded per topic (`MAX_QUEUED_FRAMES`, drop-oldest) so a topic written
|
|
102
|
+
// to through a long outage can't grow it without limit.
|
|
67
103
|
pendingTopicSends = new Map();
|
|
104
|
+
// Topics whose subscriber re-asserts its own identified frames (see
|
|
105
|
+
// `SubscribeOptions.ownsFrameRetry`). Held per topic rather than per
|
|
106
|
+
// listener because the decision is about the frame's fate on the wire,
|
|
107
|
+
// which every listener on the topic shares.
|
|
108
|
+
ownFrameRetryTopics = new Set();
|
|
68
109
|
// Warn-once latch for held-queue overflow drops, reset when a topic's held
|
|
69
110
|
// frames flush on the `subscribed` ack, so each unacked window reports once.
|
|
70
111
|
heldOverflowReported = false;
|
|
71
|
-
|
|
72
|
-
|
|
112
|
+
// Consecutive failures, which paces the loop AND decides whether this
|
|
113
|
+
// connection is working at all. One number answers both, because the two
|
|
114
|
+
// questions have the same evidence behind them: a socket that opened is
|
|
115
|
+
// not progress, so pacing on opening pins the delay at the 1s floor
|
|
116
|
+
// against a backend that accepts and hangs up, at ~86,000 sockets a day
|
|
117
|
+
// per tab for zero connected time. Only a frame the server sent in answer
|
|
118
|
+
// to this client clears it.
|
|
119
|
+
failedAttempts = 0;
|
|
120
|
+
// Latches the failing report, so one incident is reported once however
|
|
121
|
+
// long it lasts.
|
|
122
|
+
reportedFailing = false;
|
|
123
|
+
// Credited frames on the CURRENT socket, reset on open. Two of them is
|
|
124
|
+
// what re-arms the latch, and the pair is the point: a single answer is
|
|
125
|
+
// survivable by a socket that dies straight after, where a second means
|
|
126
|
+
// this socket lived past `HEARTBEAT_FIRST_MS` into the steady cadence and
|
|
127
|
+
// is genuinely serving the reader. Without a recovery-observed clear the
|
|
128
|
+
// latch never lifts on its own, since `resume` returns early while a
|
|
129
|
+
// socket is open and a Node client has no `visibilitychange` to fire it,
|
|
130
|
+
// so a second outage after a recovery would go unreported for the life of
|
|
131
|
+
// the process.
|
|
132
|
+
creditsThisSocket = 0;
|
|
73
133
|
reconnectTimer = null;
|
|
134
|
+
// Sockets this client has opened. Every topic and every open document
|
|
135
|
+
// rides this one socket, so the count is a fact about the client rather
|
|
136
|
+
// than about any one of them. Reported in a client's own sync-health
|
|
137
|
+
// report, where a high count beside unacked work is what distinguishes a
|
|
138
|
+
// connection that keeps dropping mid-write from one that is merely slow.
|
|
139
|
+
socketsOpened = 0;
|
|
74
140
|
// Warn-once latch for outbound-queue overflow drops; reset when the queue
|
|
75
141
|
// drains on a successful (re)connect, so each disconnect reports at most
|
|
76
142
|
// once.
|
|
@@ -87,14 +153,22 @@ export class BrassWsClient {
|
|
|
87
153
|
// Returns an unsubscribe that detaches THIS callback only; the WS-level
|
|
88
154
|
// subscription stays open while other callbacks remain, and the unsubscribe
|
|
89
155
|
// frame is sent only when the last detaches.
|
|
90
|
-
subscribe(topic, callback) {
|
|
156
|
+
subscribe(topic, callback, options = {}) {
|
|
91
157
|
let set = this.listeners.get(topic);
|
|
92
158
|
const firstForTopic = !set;
|
|
93
159
|
if (!set) {
|
|
94
|
-
set = new
|
|
160
|
+
set = new Map();
|
|
95
161
|
this.listeners.set(topic, set);
|
|
96
162
|
}
|
|
97
|
-
set.
|
|
163
|
+
set.set(callback, {
|
|
164
|
+
...(options.onSubscribed ? { onSubscribed: options.onSubscribed } : {}),
|
|
165
|
+
...(options.onEnded ? { onEnded: options.onEnded } : {}),
|
|
166
|
+
// Attaching to an already-established subscription: the next ack this
|
|
167
|
+
// listener sees is a reconnect, and the gap since attach is real.
|
|
168
|
+
expectGap: this.topics.get(topic)?.phase === 'established',
|
|
169
|
+
});
|
|
170
|
+
if (options.ownsFrameRetry)
|
|
171
|
+
this.ownFrameRetryTopics.add(topic);
|
|
98
172
|
if (firstForTopic) {
|
|
99
173
|
this.dispatch(topic, { type: 'user_subscribe' });
|
|
100
174
|
void this.ensureConnected();
|
|
@@ -104,7 +178,16 @@ export class BrassWsClient {
|
|
|
104
178
|
// reconnect in flight (a prior give-up or a drop while hidden): a fresh
|
|
105
179
|
// subscribe is the retry signal. (A pending reconnect timer is left
|
|
106
180
|
// alone so its backoff isn't reset.)
|
|
107
|
-
|
|
181
|
+
//
|
|
182
|
+
// A closed client goes through the front door instead, because
|
|
183
|
+
// `resume()` refuses one and this branch would otherwise be the single
|
|
184
|
+
// retry signal with no way out: subscribing a topic the wanted-state
|
|
185
|
+
// still holds would leave the client dead for good, where subscribing
|
|
186
|
+
// any other topic recovers it.
|
|
187
|
+
if (this.closedByUser)
|
|
188
|
+
void this.ensureConnected();
|
|
189
|
+
else
|
|
190
|
+
this.resume();
|
|
108
191
|
}
|
|
109
192
|
return () => {
|
|
110
193
|
const current = this.listeners.get(topic);
|
|
@@ -114,6 +197,7 @@ export class BrassWsClient {
|
|
|
114
197
|
if (current.size > 0)
|
|
115
198
|
return;
|
|
116
199
|
this.listeners.delete(topic);
|
|
200
|
+
this.ownFrameRetryTopics.delete(topic);
|
|
117
201
|
this.dispatch(topic, { type: 'user_unsubscribe' });
|
|
118
202
|
// Drop the connection once no topic is wanted, so no idle socket lingers.
|
|
119
203
|
if (this.listeners.size === 0) {
|
|
@@ -140,9 +224,31 @@ export class BrassWsClient {
|
|
|
140
224
|
else {
|
|
141
225
|
this.topics.set(topic, state);
|
|
142
226
|
}
|
|
227
|
+
// Teardown detaches the topic's callbacks, so the subscribers owed an
|
|
228
|
+
// end notice are captured before the effects drop them and told once the
|
|
229
|
+
// topic is released: a subscriber that closes itself from `onEnded` then
|
|
230
|
+
// finds nothing left to unsubscribe. `fire_error` carries the same reason
|
|
231
|
+
// to the session-wide channel; this notice is what reaches the one
|
|
232
|
+
// subscriber that has to give up the work it holds for the topic.
|
|
233
|
+
const ending = event.type === 'error' && effects.some((e) => e.kind === 'drop_listeners')
|
|
234
|
+
? {
|
|
235
|
+
reason: event.reason,
|
|
236
|
+
metas: [...(this.listeners.get(topic)?.values() ?? [])],
|
|
237
|
+
}
|
|
238
|
+
: null;
|
|
143
239
|
for (const effect of effects) {
|
|
144
240
|
this.runEffect(topic, effect);
|
|
145
241
|
}
|
|
242
|
+
if (ending) {
|
|
243
|
+
for (const meta of ending.metas) {
|
|
244
|
+
try {
|
|
245
|
+
meta.onEnded?.({ reason: ending.reason });
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// A bad listener must not break the others or kill the read loop.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
146
252
|
}
|
|
147
253
|
runEffect(topic, effect) {
|
|
148
254
|
switch (effect.kind) {
|
|
@@ -171,6 +277,11 @@ export class BrassWsClient {
|
|
|
171
277
|
case 'drop_listeners':
|
|
172
278
|
this.listeners.delete(topic);
|
|
173
279
|
return;
|
|
280
|
+
case 'deliver_error':
|
|
281
|
+
// Rebuilt rather than forwarded: a topic error frame is exactly
|
|
282
|
+
// these three fields.
|
|
283
|
+
this.fanOut(topic, { type: 'error', topic, reason: effect.reason });
|
|
284
|
+
return;
|
|
174
285
|
case 'fire_error':
|
|
175
286
|
this.opts.fireError(new Error(`Brass WebSocket subscribe(${topic}): ${effect.reason}`));
|
|
176
287
|
return;
|
|
@@ -181,22 +292,29 @@ export class BrassWsClient {
|
|
|
181
292
|
// ops that must survive a mid-session reconnect). Failures (overflow, closed
|
|
182
293
|
// transport) are silently dropped.
|
|
183
294
|
send(frame) {
|
|
184
|
-
// Topic-scoped frames sent before the `subscribed` ack race the
|
|
295
|
+
// Topic-scoped frames sent before the `subscribed` ack race the subscription record
|
|
185
296
|
// write and surface as spurious `forbidden`, so hold them until the ack
|
|
186
297
|
// (see `pendingTopicSends`). The machine's `holdsFrames` covers every
|
|
187
298
|
// unacked wanted state (fresh subscribe, reconnect replay, retry).
|
|
188
299
|
const topic = topicOfFrame(frame);
|
|
189
300
|
const topicState = topic !== undefined ? this.topics.get(topic) : undefined;
|
|
301
|
+
// Buffering this frame would duplicate it, because its owner sends it
|
|
302
|
+
// again on the next `subscribed` ack (see `ownsFrameRetry`).
|
|
303
|
+
const ownerResends = topic !== undefined &&
|
|
304
|
+
this.ownFrameRetryTopics.has(topic) &&
|
|
305
|
+
isIdentifiedFrame(frame);
|
|
190
306
|
if (topic !== undefined && topicState && holdsFrames(topicState)) {
|
|
307
|
+
if (ownerResends)
|
|
308
|
+
return;
|
|
191
309
|
let queue = this.pendingTopicSends.get(topic);
|
|
192
310
|
if (!queue) {
|
|
193
311
|
queue = [];
|
|
194
312
|
this.pendingTopicSends.set(topic, queue);
|
|
195
313
|
}
|
|
196
314
|
// Bounded like `outboundQueue`: drop the oldest held frame on
|
|
197
|
-
// overflow.
|
|
198
|
-
//
|
|
199
|
-
//
|
|
315
|
+
// overflow. What reaches this queue is a frame nobody re-asserts (the
|
|
316
|
+
// ones that are re-asserted took the `ownerResends` exit above), so
|
|
317
|
+
// the drop is reported.
|
|
200
318
|
if (queue.length >= MAX_QUEUED_FRAMES) {
|
|
201
319
|
queue.shift();
|
|
202
320
|
if (!this.heldOverflowReported) {
|
|
@@ -214,6 +332,8 @@ export class BrassWsClient {
|
|
|
214
332
|
this.sendFrame(frame);
|
|
215
333
|
return;
|
|
216
334
|
}
|
|
335
|
+
if (ownerResends)
|
|
336
|
+
return;
|
|
217
337
|
// Bounded so a misbehaving caller can't leak memory; 1024 is generous for
|
|
218
338
|
// a single user's mid-session ops during a reconnect (each typically <1KB).
|
|
219
339
|
if (this.outboundQueue.length >= 1024) {
|
|
@@ -242,6 +362,10 @@ export class BrassWsClient {
|
|
|
242
362
|
// continuation that is about to bail.
|
|
243
363
|
this.connectEpoch++;
|
|
244
364
|
this.connecting = false;
|
|
365
|
+
// `failedAttempts` deliberately survives. An app reaches `close()`
|
|
366
|
+
// routinely rather than only on teardown, so clearing it here lets a
|
|
367
|
+
// client one step from the bound start over on every cycle and the loop
|
|
368
|
+
// never ends. A frame from the server is what clears it.
|
|
245
369
|
this.removeLifecycleListeners();
|
|
246
370
|
if (this.reconnectTimer) {
|
|
247
371
|
clearTimeout(this.reconnectTimer);
|
|
@@ -266,6 +390,10 @@ export class BrassWsClient {
|
|
|
266
390
|
}
|
|
267
391
|
}
|
|
268
392
|
async ensureConnected() {
|
|
393
|
+
// The reconnect bound is deliberately not consulted here. A caller's own
|
|
394
|
+
// connect always attempts, so a client parked on the bound still reaches
|
|
395
|
+
// a network that has recovered, and the frame that comes back clears the
|
|
396
|
+
// count. What the bound stops is the unattended retry loop.
|
|
269
397
|
if (this.ws && this.ws.readyState === this.ws.OPEN)
|
|
270
398
|
return;
|
|
271
399
|
if (this.connecting)
|
|
@@ -328,6 +456,10 @@ export class BrassWsClient {
|
|
|
328
456
|
// The `close` handler runs after `error` and drives reconnect;
|
|
329
457
|
// swallowing keeps a transient network error from bubbling.
|
|
330
458
|
});
|
|
459
|
+
// The superseded socket's `close` is dropped by the identity guard, so
|
|
460
|
+
// `onClose` never runs for it and its heartbeat would outlive it.
|
|
461
|
+
if (prev)
|
|
462
|
+
this.stopHeartbeat();
|
|
331
463
|
if (prev) {
|
|
332
464
|
try {
|
|
333
465
|
prev.close();
|
|
@@ -343,7 +475,25 @@ export class BrassWsClient {
|
|
|
343
475
|
if (epoch !== this.connectEpoch)
|
|
344
476
|
return;
|
|
345
477
|
this.connecting = false;
|
|
346
|
-
|
|
478
|
+
// Reported until the loop has said the connection is failing, then
|
|
479
|
+
// left to that one report. The throw here repeats on every attempt of
|
|
480
|
+
// an outage, so relaying each one turns a single incident into an
|
|
481
|
+
// error per attempt for as long as it lasts.
|
|
482
|
+
if (!this.reportedFailing) {
|
|
483
|
+
this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
|
|
484
|
+
}
|
|
485
|
+
// The token and the assertion reject on a momentary outage (a shed
|
|
486
|
+
// `/refresh`), which is the same transient the reconnect loop backs
|
|
487
|
+
// off on when a live socket drops. The throw happens before any
|
|
488
|
+
// socket exists, so no `close` event drives that loop and the retry
|
|
489
|
+
// is scheduled from here, on the same backoff and the same give-up
|
|
490
|
+
// budget. A signed-out session resolves null instead and takes the
|
|
491
|
+
// branch above, which stays terminal.
|
|
492
|
+
if (this.closedByUser)
|
|
493
|
+
return;
|
|
494
|
+
if (this.listeners.size === 0)
|
|
495
|
+
return;
|
|
496
|
+
this.scheduleReconnect();
|
|
347
497
|
}
|
|
348
498
|
}
|
|
349
499
|
buildUrl(token, assertion) {
|
|
@@ -366,8 +516,8 @@ export class BrassWsClient {
|
|
|
366
516
|
}
|
|
367
517
|
onOpen() {
|
|
368
518
|
this.connecting = false;
|
|
369
|
-
this.
|
|
370
|
-
this.
|
|
519
|
+
this.socketsOpened += 1;
|
|
520
|
+
this.creditsThisSocket = 0;
|
|
371
521
|
// `socket_open` per topic: re-subscribes every wanted topic (in
|
|
372
522
|
// subscription order) with a fresh retry grant, and prunes `tornDown`
|
|
373
523
|
// leftovers. Runs before the outbound drain so the server sees the
|
|
@@ -379,6 +529,15 @@ export class BrassWsClient {
|
|
|
379
529
|
this.flushOutbound();
|
|
380
530
|
this.startHeartbeat();
|
|
381
531
|
}
|
|
532
|
+
// Re-`send` each drained frame rather than writing it straight to the
|
|
533
|
+
// socket: the topic it belongs to has just been re-subscribed and its ack
|
|
534
|
+
// is still outstanding, so a frame written now races the server's
|
|
535
|
+
// subscription record write and comes back `not_subscribed`, which tears
|
|
536
|
+
// the topic down
|
|
537
|
+
// and leaves the document deaf on a live connection. `send` applies the
|
|
538
|
+
// hold (or the drop) the topic's current state calls for. The queue is
|
|
539
|
+
// swapped out first, so a frame `send` re-queues lands in the new array
|
|
540
|
+
// instead of being re-drained here.
|
|
382
541
|
flushOutbound() {
|
|
383
542
|
if (!this.ws || this.ws.readyState !== this.ws.OPEN)
|
|
384
543
|
return;
|
|
@@ -386,7 +545,7 @@ export class BrassWsClient {
|
|
|
386
545
|
this.outboundQueue = [];
|
|
387
546
|
this.outboundOverflowReported = false;
|
|
388
547
|
for (const frame of drained) {
|
|
389
|
-
this.
|
|
548
|
+
this.send(frame);
|
|
390
549
|
}
|
|
391
550
|
}
|
|
392
551
|
onMessage(ev) {
|
|
@@ -403,9 +562,46 @@ export class BrassWsClient {
|
|
|
403
562
|
const type = frame.type;
|
|
404
563
|
if (typeof type !== 'string')
|
|
405
564
|
return;
|
|
565
|
+
// The connection is working, so the reconnect loop starts over.
|
|
566
|
+
//
|
|
567
|
+
// What these have in common is that each ANSWERS something this client
|
|
568
|
+
// sent, which is why they read as the connection working where the rest
|
|
569
|
+
// of the protocol does not: the subscribe handshake is `subscribed` and
|
|
570
|
+
// then `doc_init` or `presence_snapshot`, so a backend that accepts,
|
|
571
|
+
// acks and hangs up would credit itself on every cycle, and an `error`
|
|
572
|
+
// frame is the server reporting it could NOT do the work.
|
|
573
|
+
//
|
|
574
|
+
// Credited as the evidence arrives rather than when the socket ends,
|
|
575
|
+
// because `close()` drops its own socket's `close` event (it nulls
|
|
576
|
+
// `this.ws` first, so the identity guard rejects it) and an app reaches
|
|
577
|
+
// `close()` routinely, so a session that ended that way would credit
|
|
578
|
+
// nothing however well it worked.
|
|
579
|
+
//
|
|
580
|
+
// Getting this wrong is survivable by design: the count decides when the
|
|
581
|
+
// loop slows and reports, not whether it runs at all, so a client this
|
|
582
|
+
// rule misjudges is late to a notification rather than disconnected.
|
|
583
|
+
if (type === 'pong' || type === 'op_ack') {
|
|
584
|
+
this.failedAttempts = 0;
|
|
585
|
+
this.creditsThisSocket += 1;
|
|
586
|
+
if (this.creditsThisSocket >= 2)
|
|
587
|
+
this.reportedFailing = false;
|
|
588
|
+
}
|
|
406
589
|
if (type === 'subscribed' && typeof frame.topic === 'string') {
|
|
407
590
|
// `flush_held` drains the frames held back for the unacked subscribe.
|
|
408
591
|
this.dispatch(frame.topic, { type: 'subscribed_ack' });
|
|
592
|
+
const acked = this.listeners.get(frame.topic);
|
|
593
|
+
if (acked) {
|
|
594
|
+
for (const meta of acked.values()) {
|
|
595
|
+
const resubscribed = meta.expectGap;
|
|
596
|
+
meta.expectGap = true;
|
|
597
|
+
try {
|
|
598
|
+
meta.onSubscribed?.({ resubscribed });
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
// A bad listener must not break the others or kill the read loop.
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
409
605
|
return;
|
|
410
606
|
}
|
|
411
607
|
if (type === 'unsubscribed') {
|
|
@@ -420,18 +616,22 @@ export class BrassWsClient {
|
|
|
420
616
|
if (type === 'error') {
|
|
421
617
|
const topic = typeof frame.topic === 'string' ? frame.topic : undefined;
|
|
422
618
|
const reason = typeof frame.reason === 'string' ? frame.reason : 'error';
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
619
|
+
// The server's catch-all rejection: a deploy without the `ping`
|
|
620
|
+
// handler answers a heartbeat with it, and one without the
|
|
621
|
+
// `client_report` handler answers every sync-health report with it.
|
|
622
|
+
// Swallowed so a version skew is not an error the app sees, and
|
|
623
|
+
// deliberately not credited above, since a client whose every frame is
|
|
624
|
+
// refused would otherwise read its own rejections as a healthy socket.
|
|
426
625
|
if (reason === 'bad_message' && !topic)
|
|
427
626
|
return;
|
|
428
627
|
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
|
|
628
|
+
// The machine decides: silent retry, delivery to the topic's
|
|
629
|
+
// callbacks, swallow, or teardown + `fire_error`. A topic with no
|
|
630
|
+
// live state (already unsubscribed or torn down our side) swallows:
|
|
631
|
+
// the canonical case is a presence-update racing a `doc_gone`
|
|
632
|
+
// eviction, whose late `forbidden` reply would otherwise surface as
|
|
633
|
+
// a misleading "subscribe(...): forbidden" with no subscribe in
|
|
634
|
+
// flight to fail.
|
|
435
635
|
this.dispatch(topic, { type: 'error', reason });
|
|
436
636
|
return;
|
|
437
637
|
}
|
|
@@ -440,16 +640,19 @@ export class BrassWsClient {
|
|
|
440
640
|
}
|
|
441
641
|
// Topic-scoped server frames: fan out to every callback for the topic.
|
|
442
642
|
if (typeof frame.topic === 'string') {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
643
|
+
this.fanOut(frame.topic, frame);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
fanOut(topic, frame) {
|
|
647
|
+
const set = this.listeners.get(topic);
|
|
648
|
+
if (!set)
|
|
649
|
+
return;
|
|
650
|
+
for (const cb of set.keys()) {
|
|
651
|
+
try {
|
|
652
|
+
cb(frame);
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
// A bad listener must not break the others or kill the read loop.
|
|
453
656
|
}
|
|
454
657
|
}
|
|
455
658
|
}
|
|
@@ -468,38 +671,62 @@ export class BrassWsClient {
|
|
|
468
671
|
return;
|
|
469
672
|
this.scheduleReconnect();
|
|
470
673
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
674
|
+
// Tell the app the connection is failing, once per incident. `onError`
|
|
675
|
+
// has no dedupe of its own and reaches every listener, so an app that
|
|
676
|
+
// surfaces it would otherwise show the same outage repeatedly: the count
|
|
677
|
+
// is cleared by any credited frame, and a flapping link supplies one every
|
|
678
|
+
// so often, which starts a fresh episode each time.
|
|
679
|
+
reportFailing() {
|
|
680
|
+
if (this.reportedFailing)
|
|
477
681
|
return;
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
682
|
+
this.reportedFailing = true;
|
|
683
|
+
this.opts.fireError(new Error(`Brass WebSocket subscribe: reconnect failing after ${RECONNECT_MAX_ATTEMPTS} attempts`));
|
|
684
|
+
}
|
|
685
|
+
scheduleReconnect() {
|
|
686
|
+
this.failedAttempts += 1;
|
|
687
|
+
if (this.failedAttempts >= RECONNECT_MAX_ATTEMPTS)
|
|
688
|
+
this.reportFailing();
|
|
689
|
+
// Past the threshold the loop SLOWS; it does not stop. Stopping is what
|
|
690
|
+
// makes a wrong verdict expensive: deciding a working connection is dead
|
|
691
|
+
// leaves a reader disconnected with nothing retrying, and every rule for
|
|
692
|
+
// telling a working connection from a failing one has some client it
|
|
693
|
+
// misjudges (a quiet topic draws no traffic, a short socket answers
|
|
694
|
+
// nothing, a flapping backend answers everything). Slowing costs one
|
|
695
|
+
// attempt every few minutes against an endpoint that is genuinely down,
|
|
696
|
+
// and it takes the whole class of "gave up on a healthy network" out of
|
|
697
|
+
// the design rather than out of the current predicate.
|
|
698
|
+
//
|
|
699
|
+
// The slow phase costs a reader who is being served by short sockets:
|
|
700
|
+
// a subscriber that never writes draws no `op_ack`, and a socket that
|
|
701
|
+
// dies before `HEARTBEAT_FIRST_MS` draws no `pong`, so frames pushed to
|
|
702
|
+
// it never speed the loop back up. Measured against a server pushing one
|
|
703
|
+
// event every 30s, that reader receives 4% of them at a 9s socket
|
|
704
|
+
// lifetime where pacing on opening receives 82%. It is the accepted
|
|
705
|
+
// cost: the signal that buys those frames back is one a hangup backend
|
|
706
|
+
// satisfies without carrying anything, which is the 86,000-sockets-a-day
|
|
707
|
+
// trade, and this reader still gets a full `doc_init` resync on every
|
|
708
|
+
// reconnect plus the failing report.
|
|
709
|
+
const delay = this.failedAttempts > RECONNECT_MAX_ATTEMPTS
|
|
710
|
+
? RECONNECT_IDLE_DELAY_MS
|
|
711
|
+
: RECONNECT_DELAYS_MS[Math.min(this.failedAttempts - 1, RECONNECT_DELAYS_MS.length - 1)];
|
|
712
|
+
// Clear before arming. An out-of-band connect (a `send` during a backoff
|
|
713
|
+
// window) can bring this through a second time with a timer already
|
|
714
|
+
// armed, and the overwritten handle is one `close()` cannot reach: it
|
|
715
|
+
// fires afterwards, and `ensureConnected` re-enters at the front door,
|
|
716
|
+
// clearing `closedByUser` and standing a socket and heartbeat back up.
|
|
717
|
+
if (this.reconnectTimer)
|
|
718
|
+
clearTimeout(this.reconnectTimer);
|
|
494
719
|
this.reconnectTimer = setTimeout(() => {
|
|
495
720
|
this.reconnectTimer = null;
|
|
721
|
+
// The user may have closed us while this was armed.
|
|
722
|
+
if (this.closedByUser)
|
|
723
|
+
return;
|
|
496
724
|
void this.ensureConnected();
|
|
497
725
|
}, delay);
|
|
498
726
|
}
|
|
499
|
-
// Foreground return / network recovery:
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
// closed us, or a socket is already open.
|
|
727
|
+
// Foreground return / network recovery: a reader is back, so reconnect
|
|
728
|
+
// promptly with a fresh token rather than waiting out a long delay or
|
|
729
|
+
// staying parked after a bail.
|
|
503
730
|
resume() {
|
|
504
731
|
if (this.closedByUser)
|
|
505
732
|
return;
|
|
@@ -511,10 +738,22 @@ export class BrassWsClient {
|
|
|
511
738
|
clearTimeout(this.reconnectTimer);
|
|
512
739
|
this.reconnectTimer = null;
|
|
513
740
|
}
|
|
514
|
-
this.
|
|
515
|
-
this.
|
|
741
|
+
this.failedAttempts = 0;
|
|
742
|
+
this.reportedFailing = false;
|
|
516
743
|
void this.ensureConnected();
|
|
517
744
|
}
|
|
745
|
+
/** Reconnects this client has made: every socket after the first. */
|
|
746
|
+
get reconnectCount() {
|
|
747
|
+
return Math.max(0, this.socketsOpened - 1);
|
|
748
|
+
}
|
|
749
|
+
/** Whether the page is hidden right now. */
|
|
750
|
+
get pageHidden() {
|
|
751
|
+
return this.isHidden();
|
|
752
|
+
}
|
|
753
|
+
/** Whether a socket is open right now. */
|
|
754
|
+
get connected() {
|
|
755
|
+
return this.ws !== null && this.ws.readyState === this.ws.OPEN;
|
|
756
|
+
}
|
|
518
757
|
isHidden() {
|
|
519
758
|
const doc = globalThis.document;
|
|
520
759
|
return doc?.hidden === true;
|
|
@@ -525,7 +764,7 @@ export class BrassWsClient {
|
|
|
525
764
|
const doc = globalThis.document;
|
|
526
765
|
if (doc && typeof doc.addEventListener === 'function') {
|
|
527
766
|
const listener = () => {
|
|
528
|
-
if (
|
|
767
|
+
if (!this.isHidden())
|
|
529
768
|
this.resume();
|
|
530
769
|
};
|
|
531
770
|
this.visibilityListener = listener;
|
|
@@ -553,13 +792,19 @@ export class BrassWsClient {
|
|
|
553
792
|
}
|
|
554
793
|
startHeartbeat() {
|
|
555
794
|
this.stopHeartbeat();
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
795
|
+
// One handle, re-armed after each ping, so the first delay can differ
|
|
796
|
+
// from the cadence without a second timer to keep in step.
|
|
797
|
+
const arm = (delay) => {
|
|
798
|
+
this.heartbeatTimer = setTimeout(() => {
|
|
799
|
+
this.sendIfOpen({ type: 'ping' });
|
|
800
|
+
arm(HEARTBEAT_INTERVAL_MS);
|
|
801
|
+
}, delay);
|
|
802
|
+
};
|
|
803
|
+
arm(HEARTBEAT_FIRST_MS);
|
|
559
804
|
}
|
|
560
805
|
stopHeartbeat() {
|
|
561
806
|
if (this.heartbeatTimer) {
|
|
562
|
-
|
|
807
|
+
clearTimeout(this.heartbeatTimer);
|
|
563
808
|
this.heartbeatTimer = null;
|
|
564
809
|
}
|
|
565
810
|
}
|
|
@@ -583,6 +828,11 @@ export class BrassWsClient {
|
|
|
583
828
|
function isObject(value) {
|
|
584
829
|
return typeof value === 'object' && value !== null;
|
|
585
830
|
}
|
|
831
|
+
// True for a frame the server answers by name, which is what lets a
|
|
832
|
+
// subscriber that holds it re-assert exactly that frame later.
|
|
833
|
+
function isIdentifiedFrame(frame) {
|
|
834
|
+
return isObject(frame) && typeof frame['client_op_id'] === 'string';
|
|
835
|
+
}
|
|
586
836
|
// The frame's topic, if any. `send()` uses it to decide whether the frame is
|
|
587
837
|
// subject to the subscribe-pending hold (see `pendingTopicSends`). Returns
|
|
588
838
|
// `undefined` for transport frames (`subscribe` / `unsubscribe` / `ping` /
|