@brass-build/client 0.1.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 +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
package/src/ws-client.ts
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// One WS connection per SDK instance, lazily opened on the first `subscribe`.
|
|
2
|
+
// Multiplexes N user callbacks onto one WS-level subscription per topic: the
|
|
3
|
+
// subscribe frame is sent on the first callback for a topic, the unsubscribe
|
|
4
|
+
// frame when the last detaches.
|
|
5
|
+
//
|
|
6
|
+
// Reconnect: on `close` back off (1s..30s) and replay the "wanted-state" set
|
|
7
|
+
// of topics. After ~5 minutes of failure *while foregrounded* bail and fire
|
|
8
|
+
// `onError`, keeping the wanted-state so a later resume / `subscribe`
|
|
9
|
+
// recovers.
|
|
10
|
+
//
|
|
11
|
+
// Background-tab resilience: a hidden tab throttles timers but wall-clock
|
|
12
|
+
// (which the give-up budget measures) keeps advancing, so running the budget
|
|
13
|
+
// while hidden would bail on a healthy connection. The reconnect loop pauses
|
|
14
|
+
// while `document.hidden`; foregrounding (`visibilitychange`) or coming back
|
|
15
|
+
// `online` resets the backoff + budget and reconnects with a fresh token.
|
|
16
|
+
//
|
|
17
|
+
// Heartbeat: send `{ type: 'ping' }` every 60 seconds while any topic is
|
|
18
|
+
// subscribed. One interval serves two purposes: it resets the server's
|
|
19
|
+
// 10-minute idle timeout (only client-originated frames do), and it is the
|
|
20
|
+
// presence liveness signal (the server refreshes `last_seen` on each ping;
|
|
21
|
+
// the staleness threshold is 3 missed heartbeats). The server replies
|
|
22
|
+
// `pong`; a topic-less `{ type: 'error', reason: 'bad_message' }` is
|
|
23
|
+
// accepted as a no-op too, so a deploy without the `ping` handler is
|
|
24
|
+
// tolerated. Both are dropped.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
IDLE_TOPIC,
|
|
28
|
+
holdsFrames,
|
|
29
|
+
step,
|
|
30
|
+
type TopicEffect,
|
|
31
|
+
type TopicEvent,
|
|
32
|
+
type TopicState,
|
|
33
|
+
} from './topic-lifecycle.js';
|
|
34
|
+
import { SDK_CLIENT_ID } from './version.js';
|
|
35
|
+
|
|
36
|
+
interface WsClientOptions {
|
|
37
|
+
wsBaseUrl: string;
|
|
38
|
+
appId: string;
|
|
39
|
+
getAccessToken: () => Promise<string | null>;
|
|
40
|
+
// Resolves the current app assertion (the auth proxy mints it alongside
|
|
41
|
+
// the access token). Sent as the `app_assertion` connect param so the
|
|
42
|
+
// server can enforce Layer 2 on the socket the same way it does on HTTP;
|
|
43
|
+
// absent / null just means the connection is not app-attested (the
|
|
44
|
+
// server treats it as first-party, un-gated). Optional so a client
|
|
45
|
+
// constructed without it (or on the first-party dashboard path) connects
|
|
46
|
+
// unchanged.
|
|
47
|
+
getAppAssertion?: () => Promise<string | null>;
|
|
48
|
+
// The anonymous link-guest token (`?brass_link=`), or null on the
|
|
49
|
+
// ordinary signed-in path. When `getAccessToken` returns null but this
|
|
50
|
+
// returns a token, the socket connects with `brass_link=<token>` instead
|
|
51
|
+
// of `token=<accessToken>`, and the server admits a read-only,
|
|
52
|
+
// single-doc-scoped guest connection. Optional so a normal client is
|
|
53
|
+
// unchanged.
|
|
54
|
+
getGuestLinkToken?: () => string | null;
|
|
55
|
+
// WebSocket constructor injection for tests; defaults to
|
|
56
|
+
// `globalThis.WebSocket`.
|
|
57
|
+
webSocketImpl?: typeof WebSocket;
|
|
58
|
+
// Surfaces transport errors (auth fail, reconnect bail, subscribe forbidden)
|
|
59
|
+
// to the client's onError channel.
|
|
60
|
+
fireError: (error: Error) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type WsTopicCallback = (frame: unknown) => void;
|
|
64
|
+
|
|
65
|
+
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
66
|
+
const RECONNECT_GIVE_UP_MS = 5 * 60 * 1000;
|
|
67
|
+
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
68
|
+
// Cap on the frames held for one topic's unacked subscribe, matching the
|
|
69
|
+
// `outboundQueue` bound. Both hold the same kind of mid-reconnect frames
|
|
70
|
+
// (a disconnect rolls every wanted topic back to `subscribing`, so ops sent
|
|
71
|
+
// during the reconnect window are HELD here rather than in `outboundQueue`),
|
|
72
|
+
// so both need the same memory ceiling or a doc edited through a long outage
|
|
73
|
+
// grows one queue without bound. Generous for a single user's in-flight ops.
|
|
74
|
+
const MAX_QUEUED_FRAMES = 1024;
|
|
75
|
+
|
|
76
|
+
type Listeners = Set<WsTopicCallback>;
|
|
77
|
+
|
|
78
|
+
export class BrassWsClient {
|
|
79
|
+
private ws: WebSocket | null = null;
|
|
80
|
+
private connecting = false;
|
|
81
|
+
// Monotonic connect-attempt token. `close()` and each new attempt bump it,
|
|
82
|
+
// so a continuation parked on the token / assertion awaits in
|
|
83
|
+
// `ensureConnected` can tell its attempt was superseded and bail instead
|
|
84
|
+
// of adopting a socket nobody wants (a zombie the heartbeat would keep
|
|
85
|
+
// alive past the gateway idle timeout). Mechanical: any await added to
|
|
86
|
+
// the connect path is covered by re-checking the epoch after it.
|
|
87
|
+
private connectEpoch = 0;
|
|
88
|
+
// Per-topic lifecycle state, driven exclusively through the pure
|
|
89
|
+
// `step` transition table in `topic-lifecycle.ts`: handlers translate
|
|
90
|
+
// frames / user calls into events via `dispatch` and execute the
|
|
91
|
+
// returned effects. Wanted topics (the ones a reconnect replays) are
|
|
92
|
+
// the entries in a non-`tornDown` phase; the listener map below is the
|
|
93
|
+
// callback data those decisions act on.
|
|
94
|
+
private topics = new Map<string, TopicState>();
|
|
95
|
+
private listeners = new Map<string, Listeners>();
|
|
96
|
+
// Outbound frames queued while the socket is not OPEN. Drained on `open`
|
|
97
|
+
// (after subscribe replay) so an optimistic `mutate()` survives a reconnect
|
|
98
|
+
// window.
|
|
99
|
+
private outboundQueue: unknown[] = [];
|
|
100
|
+
// Per-topic queues of frames held while `holdsFrames` says the topic's
|
|
101
|
+
// subscribe is unacked: a topic-scoped frame (`presence`, `op`) sent
|
|
102
|
+
// before the `subscribed` reply races the SUB# row write and the
|
|
103
|
+
// server's conditional update fails as a spurious `forbidden`. The
|
|
104
|
+
// machine's `flush_held` / `drop_held` effects drain or discard them.
|
|
105
|
+
// Bounded per topic (`MAX_QUEUED_FRAMES`, drop-oldest) so a doc edited
|
|
106
|
+
// through a long outage can't grow it without limit.
|
|
107
|
+
private pendingTopicSends = new Map<string, unknown[]>();
|
|
108
|
+
// Warn-once latch for held-queue overflow drops, reset when a topic's held
|
|
109
|
+
// frames flush on the `subscribed` ack, so each unacked window reports once.
|
|
110
|
+
private heldOverflowReported = false;
|
|
111
|
+
private reconnectAttempt = 0;
|
|
112
|
+
private reconnectStartedAt: number | null = null;
|
|
113
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
114
|
+
// Warn-once latch for outbound-queue overflow drops; reset when the queue
|
|
115
|
+
// drains on a successful (re)connect, so each disconnect reports at most
|
|
116
|
+
// once.
|
|
117
|
+
private outboundOverflowReported = false;
|
|
118
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
119
|
+
private closedByUser = false;
|
|
120
|
+
// Page-lifecycle listeners, installed lazily on first connect and removed on
|
|
121
|
+
// user close. Guarded for non-DOM environments (Node, SSR).
|
|
122
|
+
private visibilityListener: (() => void) | null = null;
|
|
123
|
+
private onlineListener: (() => void) | null = null;
|
|
124
|
+
|
|
125
|
+
constructor(private readonly opts: WsClientOptions) {}
|
|
126
|
+
|
|
127
|
+
// Returns an unsubscribe that detaches THIS callback only; the WS-level
|
|
128
|
+
// subscription stays open while other callbacks remain, and the unsubscribe
|
|
129
|
+
// frame is sent only when the last detaches.
|
|
130
|
+
subscribe(topic: string, callback: WsTopicCallback): () => void {
|
|
131
|
+
let set = this.listeners.get(topic);
|
|
132
|
+
const firstForTopic = !set;
|
|
133
|
+
if (!set) {
|
|
134
|
+
set = new Set();
|
|
135
|
+
this.listeners.set(topic, set);
|
|
136
|
+
}
|
|
137
|
+
set.add(callback);
|
|
138
|
+
|
|
139
|
+
if (firstForTopic) {
|
|
140
|
+
this.dispatch(topic, { type: 'user_subscribe' });
|
|
141
|
+
void this.ensureConnected();
|
|
142
|
+
} else if (!this.ws && !this.connecting && !this.reconnectTimer) {
|
|
143
|
+
// The wanted-state held this topic but there's no socket and no
|
|
144
|
+
// reconnect in flight (a prior give-up or a drop while hidden): a fresh
|
|
145
|
+
// subscribe is the retry signal. (A pending reconnect timer is left
|
|
146
|
+
// alone so its backoff isn't reset.)
|
|
147
|
+
this.resume();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return () => {
|
|
151
|
+
const current = this.listeners.get(topic);
|
|
152
|
+
if (!current) return;
|
|
153
|
+
current.delete(callback);
|
|
154
|
+
if (current.size > 0) return;
|
|
155
|
+
this.listeners.delete(topic);
|
|
156
|
+
this.dispatch(topic, { type: 'user_unsubscribe' });
|
|
157
|
+
// Drop the connection once no topic is wanted, so no idle socket lingers.
|
|
158
|
+
if (this.listeners.size === 0) {
|
|
159
|
+
this.close();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Feed one event through the topic's machine, commit the new state, then
|
|
165
|
+
// execute the effects. `idle` results are pruned; `tornDown` entries are
|
|
166
|
+
// kept so a same-connection re-subscribe inherits the spent retry, and
|
|
167
|
+
// the next socket transition prunes them.
|
|
168
|
+
private dispatch(topic: string, event: TopicEvent): void {
|
|
169
|
+
const prior = this.topics.get(topic) ?? IDLE_TOPIC;
|
|
170
|
+
const { state, effects } = step(prior, event);
|
|
171
|
+
if (state.phase === 'idle') {
|
|
172
|
+
this.topics.delete(topic);
|
|
173
|
+
} else if (event.type === 'user_subscribe') {
|
|
174
|
+
// Re-insert so the map's order (the reconnect replay order) tracks
|
|
175
|
+
// subscription order even for a topic re-subscribed after teardown.
|
|
176
|
+
this.topics.delete(topic);
|
|
177
|
+
this.topics.set(topic, state);
|
|
178
|
+
} else {
|
|
179
|
+
this.topics.set(topic, state);
|
|
180
|
+
}
|
|
181
|
+
for (const effect of effects) {
|
|
182
|
+
this.runEffect(topic, effect);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private runEffect(topic: string, effect: TopicEffect): void {
|
|
187
|
+
switch (effect.kind) {
|
|
188
|
+
case 'send_subscribe':
|
|
189
|
+
// Dropped when the socket is not open; `socket_open` re-derives a
|
|
190
|
+
// `send_subscribe` for every wanted topic, so nothing is lost.
|
|
191
|
+
this.sendIfOpen({ type: 'subscribe', topic });
|
|
192
|
+
return;
|
|
193
|
+
case 'send_unsubscribe':
|
|
194
|
+
this.sendIfOpen({ type: 'unsubscribe', topic });
|
|
195
|
+
return;
|
|
196
|
+
case 'flush_held': {
|
|
197
|
+
const queued = this.pendingTopicSends.get(topic);
|
|
198
|
+
if (queued) {
|
|
199
|
+
this.pendingTopicSends.delete(topic);
|
|
200
|
+
for (const f of queued) this.sendFrame(f);
|
|
201
|
+
}
|
|
202
|
+
// The unacked window closed; re-arm the overflow warning for the next.
|
|
203
|
+
this.heldOverflowReported = false;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
case 'drop_held':
|
|
207
|
+
this.pendingTopicSends.delete(topic);
|
|
208
|
+
return;
|
|
209
|
+
case 'drop_listeners':
|
|
210
|
+
this.listeners.delete(topic);
|
|
211
|
+
return;
|
|
212
|
+
case 'fire_error':
|
|
213
|
+
this.opts.fireError(
|
|
214
|
+
new Error(`Brass WebSocket subscribe(${topic}): ${effect.reason}`),
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Send an arbitrary frame. Queued when the socket is not open and drained on
|
|
221
|
+
// the next `open` (covering a mutate during the subscribe handshake gap and
|
|
222
|
+
// ops that must survive a mid-session reconnect). Failures (overflow, closed
|
|
223
|
+
// transport) are silently dropped.
|
|
224
|
+
send(frame: unknown): void {
|
|
225
|
+
// Topic-scoped frames sent before the `subscribed` ack race the SUB# row
|
|
226
|
+
// write and surface as spurious `forbidden`, so hold them until the ack
|
|
227
|
+
// (see `pendingTopicSends`). The machine's `holdsFrames` covers every
|
|
228
|
+
// unacked wanted state (fresh subscribe, reconnect replay, retry).
|
|
229
|
+
const topic = topicOfFrame(frame);
|
|
230
|
+
const topicState = topic !== undefined ? this.topics.get(topic) : undefined;
|
|
231
|
+
|
|
232
|
+
if (topic !== undefined && topicState && holdsFrames(topicState)) {
|
|
233
|
+
let queue = this.pendingTopicSends.get(topic);
|
|
234
|
+
if (!queue) {
|
|
235
|
+
queue = [];
|
|
236
|
+
this.pendingTopicSends.set(topic, queue);
|
|
237
|
+
}
|
|
238
|
+
// Bounded like `outboundQueue`: drop the oldest held frame on
|
|
239
|
+
// overflow. Safe to drop here because an op is re-asserted by the
|
|
240
|
+
// per-doc resend tracker (keyed independently on `client_op_id`), and
|
|
241
|
+
// a dropped presence frame is superseded by the next `set()`.
|
|
242
|
+
if (queue.length >= MAX_QUEUED_FRAMES) {
|
|
243
|
+
queue.shift();
|
|
244
|
+
if (!this.heldOverflowReported) {
|
|
245
|
+
this.heldOverflowReported = true;
|
|
246
|
+
this.opts.fireError(
|
|
247
|
+
new Error(
|
|
248
|
+
'Brass WebSocket: held-frame queue overflowed while a subscribe was unacked; the oldest queued frames were dropped',
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
queue.push(frame);
|
|
254
|
+
// Ensure a connection is in flight; the machine re-emits the
|
|
255
|
+
// subscribe on `socket_open`.
|
|
256
|
+
void this.ensureConnected();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
|
261
|
+
this.sendFrame(frame);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Bounded so a misbehaving caller can't leak memory; 1024 is generous for
|
|
265
|
+
// a single user's mid-session ops during a reconnect (each typically <1KB).
|
|
266
|
+
if (this.outboundQueue.length >= 1024) {
|
|
267
|
+
this.outboundQueue.shift();
|
|
268
|
+
// A dropped frame can be an op the caller believes is saved-pending;
|
|
269
|
+
// surface it, once per overflow episode so a runaway producer can't
|
|
270
|
+
// flood the error channel.
|
|
271
|
+
if (!this.outboundOverflowReported) {
|
|
272
|
+
this.outboundOverflowReported = true;
|
|
273
|
+
this.opts.fireError(
|
|
274
|
+
new Error(
|
|
275
|
+
'Brass WebSocket: outbound queue overflowed while disconnected; the oldest queued frames were dropped and will not reach the server',
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
this.outboundQueue.push(frame);
|
|
281
|
+
// Kick the connect path if no socket is open yet (a caller that `send`s
|
|
282
|
+
// without ever subscribing), or the queue would never drain.
|
|
283
|
+
if (!this.ws && !this.connecting && !this.closedByUser) {
|
|
284
|
+
void this.ensureConnected();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
close(): void {
|
|
289
|
+
this.closedByUser = true;
|
|
290
|
+
// Supersede any in-flight connect attempt, and clear `connecting` so a
|
|
291
|
+
// subscribe / send that lands after this close starts its own fresh
|
|
292
|
+
// attempt through the normal entry path (fresh token, lifecycle
|
|
293
|
+
// listeners re-armed) instead of finding the entry guard held by a
|
|
294
|
+
// continuation that is about to bail.
|
|
295
|
+
this.connectEpoch++;
|
|
296
|
+
this.connecting = false;
|
|
297
|
+
this.removeLifecycleListeners();
|
|
298
|
+
if (this.reconnectTimer) {
|
|
299
|
+
clearTimeout(this.reconnectTimer);
|
|
300
|
+
this.reconnectTimer = null;
|
|
301
|
+
}
|
|
302
|
+
this.stopHeartbeat();
|
|
303
|
+
const ws = this.ws;
|
|
304
|
+
this.ws = null;
|
|
305
|
+
// `socket_closed` per topic: drops held frames and rolls wanted topics
|
|
306
|
+
// back to `subscribing` so a later resume replays them.
|
|
307
|
+
for (const topic of this.topics.keys()) {
|
|
308
|
+
this.dispatch(topic, { type: 'socket_closed' });
|
|
309
|
+
}
|
|
310
|
+
this.outboundQueue = [];
|
|
311
|
+
if (ws && ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
|
|
312
|
+
try {
|
|
313
|
+
ws.close();
|
|
314
|
+
} catch {
|
|
315
|
+
// Closing an already-closing socket is fine.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private async ensureConnected(): Promise<void> {
|
|
321
|
+
if (this.ws && this.ws.readyState === this.ws.OPEN) return;
|
|
322
|
+
if (this.connecting) return;
|
|
323
|
+
this.ensureLifecycleListeners();
|
|
324
|
+
this.connecting = true;
|
|
325
|
+
this.closedByUser = false;
|
|
326
|
+
const epoch = ++this.connectEpoch;
|
|
327
|
+
try {
|
|
328
|
+
const token = await this.opts.getAccessToken();
|
|
329
|
+
if (epoch !== this.connectEpoch) return;
|
|
330
|
+
// Anonymous link guest: no access token, but a link token authorises a
|
|
331
|
+
// read-only connection.
|
|
332
|
+
const guestToken = token ? null : this.opts.getGuestLinkToken?.() ?? null;
|
|
333
|
+
if (!token && !guestToken) {
|
|
334
|
+
this.connecting = false;
|
|
335
|
+
this.opts.fireError(
|
|
336
|
+
new Error('Brass WebSocket subscribe: not signed in'),
|
|
337
|
+
);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const Ctor = this.opts.webSocketImpl ?? globalThis.WebSocket;
|
|
341
|
+
if (!Ctor) {
|
|
342
|
+
this.connecting = false;
|
|
343
|
+
this.opts.fireError(
|
|
344
|
+
new Error('Brass WebSocket subscribe: WebSocket is not available'),
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const assertion =
|
|
349
|
+
token && this.opts.getAppAssertion
|
|
350
|
+
? await this.opts.getAppAssertion()
|
|
351
|
+
: null;
|
|
352
|
+
if (epoch !== this.connectEpoch) return;
|
|
353
|
+
const url = token
|
|
354
|
+
? this.buildUrl(token, assertion)
|
|
355
|
+
: this.buildGuestUrl(guestToken as string);
|
|
356
|
+
// The OPEN guard at the top returns only for a socket that is OPEN; a
|
|
357
|
+
// CONNECTING/CLOSING one falls through to here, so a prior socket can
|
|
358
|
+
// still be around. Adopt the new socket as `this.ws` FIRST, then close
|
|
359
|
+
// the old one: its `close` then fails the identity guard below and is
|
|
360
|
+
// ignored, rather than nulling out the live socket.
|
|
361
|
+
const prev = this.ws;
|
|
362
|
+
const ws = new Ctor(url);
|
|
363
|
+
this.ws = ws;
|
|
364
|
+
// Guard every lifecycle handler on socket identity. A superseded
|
|
365
|
+
// socket's delayed `close` (common on a throttled hidden tab) would
|
|
366
|
+
// otherwise run `onClose`, null out the live `this.ws`, roll every
|
|
367
|
+
// topic back to `subscribing`, and schedule a spurious reconnect.
|
|
368
|
+
ws.addEventListener('open', () => {
|
|
369
|
+
if (this.ws === ws) this.onOpen();
|
|
370
|
+
});
|
|
371
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
372
|
+
if (this.ws === ws) this.onMessage(ev);
|
|
373
|
+
});
|
|
374
|
+
ws.addEventListener('close', () => {
|
|
375
|
+
if (this.ws === ws) this.onClose();
|
|
376
|
+
});
|
|
377
|
+
ws.addEventListener('error', () => {
|
|
378
|
+
// The `close` handler runs after `error` and drives reconnect;
|
|
379
|
+
// swallowing keeps a transient network error from bubbling.
|
|
380
|
+
});
|
|
381
|
+
if (prev) {
|
|
382
|
+
try {
|
|
383
|
+
prev.close();
|
|
384
|
+
} catch {
|
|
385
|
+
// Closing an already-closing socket is fine.
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
// A superseded attempt's failure is not this client's failure: the
|
|
390
|
+
// `connecting` flag now belongs to the newer attempt, so leave it.
|
|
391
|
+
if (epoch !== this.connectEpoch) return;
|
|
392
|
+
this.connecting = false;
|
|
393
|
+
this.opts.fireError(err instanceof Error ? err : new Error(String(err)));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private buildUrl(token: string, assertion: string | null): string {
|
|
398
|
+
const base = this.opts.wsBaseUrl.replace(/\/$/, '');
|
|
399
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
400
|
+
const assertionParam = assertion
|
|
401
|
+
? `&app_assertion=${encodeURIComponent(assertion)}`
|
|
402
|
+
: '';
|
|
403
|
+
// `client` feeds the ws.connect audit so the deployed SDK version
|
|
404
|
+
// distribution is measurable per live connection.
|
|
405
|
+
return `${base}${sep}app_id=${encodeURIComponent(
|
|
406
|
+
this.opts.appId,
|
|
407
|
+
)}&token=${encodeURIComponent(token)}${assertionParam}&client=${encodeURIComponent(SDK_CLIENT_ID)}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Anonymous link-guest connect URL: the link token authorises the socket
|
|
411
|
+
// (no access token, no app assertion). The server resolves it to a
|
|
412
|
+
// read-only, single-doc-scoped connection.
|
|
413
|
+
private buildGuestUrl(guestToken: string): string {
|
|
414
|
+
const base = this.opts.wsBaseUrl.replace(/\/$/, '');
|
|
415
|
+
const sep = base.includes('?') ? '&' : '?';
|
|
416
|
+
return `${base}${sep}app_id=${encodeURIComponent(
|
|
417
|
+
this.opts.appId,
|
|
418
|
+
)}&brass_link=${encodeURIComponent(guestToken)}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private onOpen(): void {
|
|
422
|
+
this.connecting = false;
|
|
423
|
+
this.reconnectAttempt = 0;
|
|
424
|
+
this.reconnectStartedAt = null;
|
|
425
|
+
// `socket_open` per topic: re-subscribes every wanted topic (in
|
|
426
|
+
// subscription order) with a fresh retry grant, and prunes `tornDown`
|
|
427
|
+
// leftovers. Runs before the outbound drain so the server sees the
|
|
428
|
+
// subscribes first; otherwise an op right after a reconnect races its
|
|
429
|
+
// subscribe and gets `bad_message` (not subscribed yet).
|
|
430
|
+
for (const topic of this.topics.keys()) {
|
|
431
|
+
this.dispatch(topic, { type: 'socket_open' });
|
|
432
|
+
}
|
|
433
|
+
this.flushOutbound();
|
|
434
|
+
this.startHeartbeat();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private flushOutbound(): void {
|
|
438
|
+
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
|
439
|
+
const drained = this.outboundQueue;
|
|
440
|
+
this.outboundQueue = [];
|
|
441
|
+
this.outboundOverflowReported = false;
|
|
442
|
+
for (const frame of drained) {
|
|
443
|
+
this.sendFrame(frame);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private onMessage(ev: MessageEvent): void {
|
|
448
|
+
let frame: unknown;
|
|
449
|
+
try {
|
|
450
|
+
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data));
|
|
451
|
+
} catch {
|
|
452
|
+
// Drop garbage; the server protocol is strict JSON.
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!isObject(frame)) return;
|
|
456
|
+
const type = frame.type;
|
|
457
|
+
if (typeof type !== 'string') return;
|
|
458
|
+
|
|
459
|
+
if (type === 'subscribed' && typeof frame.topic === 'string') {
|
|
460
|
+
// `flush_held` drains the frames held back for the unacked subscribe.
|
|
461
|
+
this.dispatch(frame.topic, { type: 'subscribed_ack' });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (type === 'unsubscribed') {
|
|
465
|
+
// No callbacks fire; the user-side unsubscribe already returned.
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (type === 'pong') {
|
|
469
|
+
// Heartbeat reply; drop silently (the round-trip already reset API
|
|
470
|
+
// Gateway's idle-timeout).
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (type === 'error') {
|
|
474
|
+
const topic = typeof frame.topic === 'string' ? frame.topic : undefined;
|
|
475
|
+
const reason = typeof frame.reason === 'string' ? frame.reason : 'error';
|
|
476
|
+
// A deploy without the `ping` handler replies to our ping with a
|
|
477
|
+
// topic-less `bad_message` instead of `pong`; treat it as a heartbeat
|
|
478
|
+
// ack (see the header).
|
|
479
|
+
if (reason === 'bad_message' && !topic) return;
|
|
480
|
+
if (topic) {
|
|
481
|
+
// The machine decides: silent retry, swallow, or teardown +
|
|
482
|
+
// `fire_error`. A topic with no live state (already unsubscribed
|
|
483
|
+
// or torn down our side) swallows: the canonical case is a
|
|
484
|
+
// presence-update racing a `doc_gone` eviction, whose late
|
|
485
|
+
// `forbidden` reply would otherwise surface as a misleading
|
|
486
|
+
// "subscribe(...): forbidden" with no subscribe in flight to fail.
|
|
487
|
+
this.dispatch(topic, { type: 'error', reason });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this.opts.fireError(
|
|
491
|
+
new Error(`Brass WebSocket subscribe: ${reason}`),
|
|
492
|
+
);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// Topic-scoped server frames: fan out to every callback for the topic.
|
|
496
|
+
if (typeof frame.topic === 'string') {
|
|
497
|
+
const set = this.listeners.get(frame.topic);
|
|
498
|
+
if (!set) return;
|
|
499
|
+
for (const cb of set) {
|
|
500
|
+
try {
|
|
501
|
+
cb(frame);
|
|
502
|
+
} catch {
|
|
503
|
+
// A bad listener must not break the others or kill the read loop.
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private onClose(): void {
|
|
510
|
+
this.stopHeartbeat();
|
|
511
|
+
this.ws = null;
|
|
512
|
+
// Held frames are tied to the connection's unacked subscribes;
|
|
513
|
+
// `socket_closed` drops them and preserves wantedness for the replay.
|
|
514
|
+
for (const topic of this.topics.keys()) {
|
|
515
|
+
this.dispatch(topic, { type: 'socket_closed' });
|
|
516
|
+
}
|
|
517
|
+
this.connecting = false;
|
|
518
|
+
if (this.closedByUser) return;
|
|
519
|
+
if (this.listeners.size === 0) return;
|
|
520
|
+
this.scheduleReconnect();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private scheduleReconnect(): void {
|
|
524
|
+
// Don't run the loop while hidden: throttled timers but advancing
|
|
525
|
+
// wall-clock would burn the give-up budget without real attempts and bail
|
|
526
|
+
// on a healthy connection. `resume()` restarts with a fresh budget on
|
|
527
|
+
// foreground / network recovery.
|
|
528
|
+
if (this.isHidden()) return;
|
|
529
|
+
if (this.reconnectStartedAt === null) {
|
|
530
|
+
this.reconnectStartedAt = Date.now();
|
|
531
|
+
}
|
|
532
|
+
if (Date.now() - this.reconnectStartedAt >= RECONNECT_GIVE_UP_MS) {
|
|
533
|
+
this.opts.fireError(
|
|
534
|
+
new Error(
|
|
535
|
+
'Brass WebSocket subscribe: reconnect gave up after 5 minutes',
|
|
536
|
+
),
|
|
537
|
+
);
|
|
538
|
+
// Keep the wanted-state: a resume / fresh `subscribe` recovers by
|
|
539
|
+
// replaying it. The budget resets so resume starts clean; clearing
|
|
540
|
+
// listeners here would make the bail permanent (the background-tab bug
|
|
541
|
+
// this guards against).
|
|
542
|
+
this.reconnectAttempt = 0;
|
|
543
|
+
this.reconnectStartedAt = null;
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const idx = Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1);
|
|
547
|
+
const delay = RECONNECT_DELAYS_MS[idx]!;
|
|
548
|
+
this.reconnectAttempt += 1;
|
|
549
|
+
this.reconnectTimer = setTimeout(() => {
|
|
550
|
+
this.reconnectTimer = null;
|
|
551
|
+
void this.ensureConnected();
|
|
552
|
+
}, delay);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Foreground return / network recovery: reset the backoff + give-up budget
|
|
556
|
+
// and reconnect promptly with a fresh token instead of waiting out a long
|
|
557
|
+
// delay or staying dead after a bail. No-op when nothing is wanted, the user
|
|
558
|
+
// closed us, or a socket is already open.
|
|
559
|
+
private resume(): void {
|
|
560
|
+
if (this.closedByUser) return;
|
|
561
|
+
if (this.listeners.size === 0) return;
|
|
562
|
+
if (this.ws && this.ws.readyState === this.ws.OPEN) return;
|
|
563
|
+
if (this.reconnectTimer) {
|
|
564
|
+
clearTimeout(this.reconnectTimer);
|
|
565
|
+
this.reconnectTimer = null;
|
|
566
|
+
}
|
|
567
|
+
this.reconnectAttempt = 0;
|
|
568
|
+
this.reconnectStartedAt = null;
|
|
569
|
+
void this.ensureConnected();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private isHidden(): boolean {
|
|
573
|
+
const doc = (globalThis as { document?: { hidden?: boolean } }).document;
|
|
574
|
+
return doc?.hidden === true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private ensureLifecycleListeners(): void {
|
|
578
|
+
if (this.visibilityListener || this.onlineListener) return;
|
|
579
|
+
const doc = (
|
|
580
|
+
globalThis as {
|
|
581
|
+
document?: {
|
|
582
|
+
addEventListener?: (type: string, listener: () => void) => void;
|
|
583
|
+
hidden?: boolean;
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
).document;
|
|
587
|
+
if (doc && typeof doc.addEventListener === 'function') {
|
|
588
|
+
const listener = (): void => {
|
|
589
|
+
if (doc.hidden !== true) this.resume();
|
|
590
|
+
};
|
|
591
|
+
this.visibilityListener = listener;
|
|
592
|
+
doc.addEventListener('visibilitychange', listener);
|
|
593
|
+
}
|
|
594
|
+
if (typeof globalThis.addEventListener === 'function') {
|
|
595
|
+
const listener = (): void => this.resume();
|
|
596
|
+
this.onlineListener = listener;
|
|
597
|
+
globalThis.addEventListener('online', listener);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private removeLifecycleListeners(): void {
|
|
602
|
+
const doc = (
|
|
603
|
+
globalThis as {
|
|
604
|
+
document?: {
|
|
605
|
+
removeEventListener?: (type: string, listener: () => void) => void;
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
).document;
|
|
609
|
+
if (
|
|
610
|
+
this.visibilityListener &&
|
|
611
|
+
doc &&
|
|
612
|
+
typeof doc.removeEventListener === 'function'
|
|
613
|
+
) {
|
|
614
|
+
doc.removeEventListener('visibilitychange', this.visibilityListener);
|
|
615
|
+
}
|
|
616
|
+
this.visibilityListener = null;
|
|
617
|
+
if (
|
|
618
|
+
this.onlineListener &&
|
|
619
|
+
typeof globalThis.removeEventListener === 'function'
|
|
620
|
+
) {
|
|
621
|
+
globalThis.removeEventListener('online', this.onlineListener);
|
|
622
|
+
}
|
|
623
|
+
this.onlineListener = null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private startHeartbeat(): void {
|
|
627
|
+
this.stopHeartbeat();
|
|
628
|
+
this.heartbeatTimer = setInterval(() => {
|
|
629
|
+
this.sendIfOpen({ type: 'ping' });
|
|
630
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private stopHeartbeat(): void {
|
|
634
|
+
if (this.heartbeatTimer) {
|
|
635
|
+
clearInterval(this.heartbeatTimer);
|
|
636
|
+
this.heartbeatTimer = null;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private sendIfOpen(frame: unknown): void {
|
|
641
|
+
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
|
642
|
+
this.sendFrame(frame);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private sendFrame(frame: unknown): void {
|
|
646
|
+
if (!this.ws) return;
|
|
647
|
+
try {
|
|
648
|
+
this.ws.send(JSON.stringify(frame));
|
|
649
|
+
} catch {
|
|
650
|
+
// A send failure means the connection is half-dead; `close` fires
|
|
651
|
+
// shortly and drives reconnect.
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
657
|
+
return typeof value === 'object' && value !== null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// The frame's topic, if any. `send()` uses it to decide whether the frame is
|
|
661
|
+
// subject to the subscribe-pending hold (see `pendingTopicSends`). Returns
|
|
662
|
+
// `undefined` for transport frames (`subscribe` / `unsubscribe` / `ping` /
|
|
663
|
+
// `pong`), which the hold must NOT apply to (the subscribe is what it waits
|
|
664
|
+
// for).
|
|
665
|
+
function topicOfFrame(frame: unknown): string | undefined {
|
|
666
|
+
if (!isObject(frame)) return undefined;
|
|
667
|
+
const type = frame['type'];
|
|
668
|
+
if (
|
|
669
|
+
type === 'subscribe' ||
|
|
670
|
+
type === 'unsubscribe' ||
|
|
671
|
+
type === 'ping' ||
|
|
672
|
+
type === 'pong'
|
|
673
|
+
) {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
const topic = frame['topic'];
|
|
677
|
+
return typeof topic === 'string' ? topic : undefined;
|
|
678
|
+
}
|