@supalive/core 0.1.4 → 0.1.5
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/README.md +17 -2
- package/dist/helper-CiacMqje.d.ts +14 -0
- package/dist/helper-CiacMqje.d.ts.map +1 -0
- package/dist/helper-zdJT5FUc.js +58 -0
- package/dist/helper-zdJT5FUc.js.map +1 -0
- package/dist/index-CTyXKcq_.d.ts +1736 -0
- package/dist/index-CTyXKcq_.d.ts.map +1 -0
- package/dist/mysql-C8SN443m.d.ts +104 -0
- package/dist/mysql-C8SN443m.d.ts.map +1 -0
- package/dist/mysql-DELSRrpY.js +569 -0
- package/dist/mysql-DELSRrpY.js.map +1 -0
- package/dist/overlap-checker-CVkbbuEQ.js +187 -0
- package/dist/overlap-checker-CVkbbuEQ.js.map +1 -0
- package/dist/postgres-DE-XyNAQ.js +823 -0
- package/dist/postgres-DE-XyNAQ.js.map +1 -0
- package/dist/postgres-D_gwCHx-.d.ts +108 -0
- package/dist/postgres-D_gwCHx-.d.ts.map +1 -0
- package/dist/query-CbQLjeME.js +713 -0
- package/dist/query-CbQLjeME.js.map +1 -0
- package/dist/realtime_db-D9p2X2PH.js +246 -0
- package/dist/realtime_db-D9p2X2PH.js.map +1 -0
- package/dist/router-DlTYWpop.js +81 -0
- package/dist/router-DlTYWpop.js.map +1 -0
- package/dist/schema-SKcIWHfo.js +167 -0
- package/dist/schema-SKcIWHfo.js.map +1 -0
- package/dist/src/client/index.d.ts +2 -17
- package/dist/src/client/index.js +1151 -18
- package/dist/src/client/index.js.map +1 -1
- package/dist/src/exports/mysql.d.ts +2 -2
- package/dist/src/exports/mysql.js +2 -2
- package/dist/src/exports/postgres.d.ts +2 -2
- package/dist/src/exports/postgres.js +2 -2
- package/dist/src/exports/procedure.d.ts +3 -5
- package/dist/src/exports/procedure.js +5 -5
- package/dist/src/exports/server.d.ts +368 -2
- package/dist/src/exports/server.d.ts.map +1 -1
- package/dist/src/exports/server.js +1346 -2
- package/dist/src/exports/server.js.map +1 -1
- package/dist/src/exports/subscription-manager-worker-entry.d.ts +1 -0
- package/dist/src/exports/subscription-manager-worker-entry.js +115 -0
- package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -0
- package/dist/src/exports/types.d.ts +4 -6
- package/dist/src/exports/types.js +5 -6
- package/dist/subscription-worker-BurdPO8d.js +511 -0
- package/dist/subscription-worker-BurdPO8d.js.map +1 -0
- package/dist/types_client_rpc-CIsjQQGG.js +105 -0
- package/dist/types_client_rpc-CIsjQQGG.js.map +1 -0
- package/dist/types_db-Ctf-ksoy.js +168 -0
- package/dist/types_db-Ctf-ksoy.js.map +1 -0
- package/dist/types_server-Ch56y8L3.d.ts +216 -0
- package/dist/types_server-Ch56y8L3.d.ts.map +1 -0
- package/dist/types_server-Dk6o_B7N.js +24 -0
- package/dist/types_server-Dk6o_B7N.js.map +1 -0
- package/package.json +3 -3
- package/dist/src/client/caller_client.d.ts +0 -42
- package/dist/src/client/caller_client.d.ts.map +0 -1
- package/dist/src/client/caller_client.js +0 -77
- package/dist/src/client/caller_client.js.map +0 -1
- package/dist/src/client/client.d.ts +0 -149
- package/dist/src/client/client.d.ts.map +0 -1
- package/dist/src/client/client.js +0 -308
- package/dist/src/client/client.js.map +0 -1
- package/dist/src/client/index.d.ts.map +0 -1
- package/dist/src/client/sub_key.d.ts +0 -20
- package/dist/src/client/sub_key.d.ts.map +0 -1
- package/dist/src/client/sub_key.js +0 -37
- package/dist/src/client/sub_key.js.map +0 -1
- package/dist/src/client/ws_client_manager.d.ts +0 -268
- package/dist/src/client/ws_client_manager.d.ts.map +0 -1
- package/dist/src/client/ws_client_manager.js +0 -1013
- package/dist/src/client/ws_client_manager.js.map +0 -1
- package/dist/src/config.d.ts +0 -117
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js +0 -2
- package/dist/src/config.js.map +0 -1
- package/dist/src/db/bytes.d.ts +0 -4
- package/dist/src/db/bytes.d.ts.map +0 -1
- package/dist/src/db/bytes.js +0 -24
- package/dist/src/db/bytes.js.map +0 -1
- package/dist/src/db/cache.d.ts +0 -30
- package/dist/src/db/cache.d.ts.map +0 -1
- package/dist/src/db/cache.js +0 -153
- package/dist/src/db/cache.js.map +0 -1
- package/dist/src/db/codec.d.ts +0 -10
- package/dist/src/db/codec.d.ts.map +0 -1
- package/dist/src/db/codec.js +0 -73
- package/dist/src/db/codec.js.map +0 -1
- package/dist/src/db/context.d.ts +0 -28
- package/dist/src/db/context.d.ts.map +0 -1
- package/dist/src/db/context.js +0 -147
- package/dist/src/db/context.js.map +0 -1
- package/dist/src/db/db.d.ts +0 -154
- package/dist/src/db/db.d.ts.map +0 -1
- package/dist/src/db/db.js +0 -23
- package/dist/src/db/db.js.map +0 -1
- package/dist/src/db/mysql.d.ts +0 -120
- package/dist/src/db/mysql.d.ts.map +0 -1
- package/dist/src/db/mysql.js +0 -708
- package/dist/src/db/mysql.js.map +0 -1
- package/dist/src/db/postgres.d.ts +0 -116
- package/dist/src/db/postgres.d.ts.map +0 -1
- package/dist/src/db/postgres.js +0 -998
- package/dist/src/db/postgres.js.map +0 -1
- package/dist/src/db/query.d.ts +0 -174
- package/dist/src/db/query.d.ts.map +0 -1
- package/dist/src/db/query.js +0 -790
- package/dist/src/db/query.js.map +0 -1
- package/dist/src/db/query_definition.d.ts +0 -54
- package/dist/src/db/query_definition.d.ts.map +0 -1
- package/dist/src/db/query_definition.js +0 -47
- package/dist/src/db/query_definition.js.map +0 -1
- package/dist/src/db/realtime_db.d.ts +0 -19
- package/dist/src/db/realtime_db.d.ts.map +0 -1
- package/dist/src/db/realtime_db.js +0 -98
- package/dist/src/db/realtime_db.js.map +0 -1
- package/dist/src/db/schema.d.ts +0 -110
- package/dist/src/db/schema.d.ts.map +0 -1
- package/dist/src/db/schema.js +0 -47
- package/dist/src/db/schema.js.map +0 -1
- package/dist/src/db/types_client_rpc.d.ts +0 -157
- package/dist/src/db/types_client_rpc.d.ts.map +0 -1
- package/dist/src/db/types_client_rpc.js +0 -106
- package/dist/src/db/types_client_rpc.js.map +0 -1
- package/dist/src/db/types_db.d.ts +0 -236
- package/dist/src/db/types_db.d.ts.map +0 -1
- package/dist/src/db/types_db.js +0 -188
- package/dist/src/db/types_db.js.map +0 -1
- package/dist/src/exports/mysql.d.ts.map +0 -1
- package/dist/src/exports/mysql.js.map +0 -1
- package/dist/src/exports/postgres.d.ts.map +0 -1
- package/dist/src/exports/postgres.js.map +0 -1
- package/dist/src/exports/procedure.d.ts.map +0 -1
- package/dist/src/exports/procedure.js.map +0 -1
- package/dist/src/exports/types.d.ts.map +0 -1
- package/dist/src/exports/types.js.map +0 -1
- package/dist/src/helper.d.ts +0 -11
- package/dist/src/helper.d.ts.map +0 -1
- package/dist/src/helper.js +0 -45
- package/dist/src/helper.js.map +0 -1
- package/dist/src/logger.d.ts +0 -7
- package/dist/src/logger.d.ts.map +0 -1
- package/dist/src/logger.js +0 -47
- package/dist/src/logger.js.map +0 -1
- package/dist/src/router/index.d.ts +0 -4
- package/dist/src/router/index.d.ts.map +0 -1
- package/dist/src/router/index.js +0 -4
- package/dist/src/router/index.js.map +0 -1
- package/dist/src/router/procedure.d.ts +0 -204
- package/dist/src/router/procedure.d.ts.map +0 -1
- package/dist/src/router/procedure.js +0 -122
- package/dist/src/router/procedure.js.map +0 -1
- package/dist/src/router/router.d.ts +0 -88
- package/dist/src/router/router.d.ts.map +0 -1
- package/dist/src/router/router.js +0 -88
- package/dist/src/router/router.js.map +0 -1
- package/dist/src/server/hash.d.ts +0 -12
- package/dist/src/server/hash.d.ts.map +0 -1
- package/dist/src/server/hash.js +0 -20
- package/dist/src/server/hash.js.map +0 -1
- package/dist/src/server/hash.test.d.ts +0 -2
- package/dist/src/server/hash.test.d.ts.map +0 -1
- package/dist/src/server/hash.test.js +0 -43
- package/dist/src/server/hash.test.js.map +0 -1
- package/dist/src/server/index.d.ts +0 -9
- package/dist/src/server/index.d.ts.map +0 -1
- package/dist/src/server/index.js +0 -8
- package/dist/src/server/index.js.map +0 -1
- package/dist/src/server/overlap-checker.d.ts +0 -18
- package/dist/src/server/overlap-checker.d.ts.map +0 -1
- package/dist/src/server/overlap-checker.js +0 -247
- package/dist/src/server/overlap-checker.js.map +0 -1
- package/dist/src/server/sub_hash.d.ts +0 -23
- package/dist/src/server/sub_hash.d.ts.map +0 -1
- package/dist/src/server/sub_hash.js +0 -38
- package/dist/src/server/sub_hash.js.map +0 -1
- package/dist/src/server/subscription-manager-client.d.ts +0 -120
- package/dist/src/server/subscription-manager-client.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-client.js +0 -207
- package/dist/src/server/subscription-manager-client.js.map +0 -1
- package/dist/src/server/subscription-manager-types.d.ts +0 -168
- package/dist/src/server/subscription-manager-types.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-types.js +0 -94
- package/dist/src/server/subscription-manager-types.js.map +0 -1
- package/dist/src/server/subscription-manager-worker-entry.d.ts +0 -2
- package/dist/src/server/subscription-manager-worker-entry.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-worker-entry.js +0 -127
- package/dist/src/server/subscription-manager-worker-entry.js.map +0 -1
- package/dist/src/server/subscription-manager-worker-protocol.d.ts +0 -40
- package/dist/src/server/subscription-manager-worker-protocol.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-worker-protocol.js +0 -20
- package/dist/src/server/subscription-manager-worker-protocol.js.map +0 -1
- package/dist/src/server/subscription-manager.d.ts +0 -32
- package/dist/src/server/subscription-manager.d.ts.map +0 -1
- package/dist/src/server/subscription-manager.js +0 -249
- package/dist/src/server/subscription-manager.js.map +0 -1
- package/dist/src/server/subscription-manager.test.d.ts +0 -2
- package/dist/src/server/subscription-manager.test.d.ts.map +0 -1
- package/dist/src/server/subscription-manager.test.js +0 -610
- package/dist/src/server/subscription-manager.test.js.map +0 -1
- package/dist/src/server/subscription-worker.d.ts +0 -32
- package/dist/src/server/subscription-worker.d.ts.map +0 -1
- package/dist/src/server/subscription-worker.js +0 -190
- package/dist/src/server/subscription-worker.js.map +0 -1
- package/dist/src/server/supalive-server.d.ts +0 -158
- package/dist/src/server/supalive-server.d.ts.map +0 -1
- package/dist/src/server/supalive-server.js +0 -1048
- package/dist/src/server/supalive-server.js.map +0 -1
- package/dist/src/server/types_server.d.ts +0 -96
- package/dist/src/server/types_server.d.ts.map +0 -1
- package/dist/src/server/types_server.js +0 -36
- package/dist/src/server/types_server.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -1,1013 +0,0 @@
|
|
|
1
|
-
// ─── Client WebSocket Manager ─────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// Manages a WebSocket connection to the server and handles:
|
|
4
|
-
// - Connection lifecycle (connect, auth, reconnect)
|
|
5
|
-
// - Connection ID management with localStorage persistence
|
|
6
|
-
// - Request/response matching via request IDs
|
|
7
|
-
// - Subscription management with callbacks
|
|
8
|
-
// - Message encoding/decoding
|
|
9
|
-
// - Queued calls when not connected (FIFO, flushed on reconnect)
|
|
10
|
-
// - Pending in-flight calls are re-queued (not rejected) on transport drop
|
|
11
|
-
// so they re-issue on the next successful connect.
|
|
12
|
-
// - Auto re-subscribe of live queries after reconnect
|
|
13
|
-
// - Passive server-inactivity watchdog: the client never sends a ping; the
|
|
14
|
-
// server emits `{type:"ping"}` while idle and we close+reconnect after
|
|
15
|
-
// `serverInactivityMs` of total silence.
|
|
16
|
-
//
|
|
17
|
-
// Subscription identity is server-driven: the client sends `subscribe` with
|
|
18
|
-
// (procedure, input) and the server replies with the canonical `subId` on
|
|
19
|
-
// the response.
|
|
20
|
-
import { ServerMessageSchema } from "../db/types_client_rpc";
|
|
21
|
-
import { computeLocalSubKey, prewarmHasher } from "./sub_key";
|
|
22
|
-
import { supaliveStringify } from "../helper";
|
|
23
|
-
// readyState 1 = OPEN in both environments.
|
|
24
|
-
const WS_OPEN = 1;
|
|
25
|
-
export class RPCError extends Error {
|
|
26
|
-
code;
|
|
27
|
-
constructor(message, code) {
|
|
28
|
-
super(message);
|
|
29
|
-
this.name = "RPCError";
|
|
30
|
-
this.code = code;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
export class WsClientManager {
|
|
34
|
-
ws = null;
|
|
35
|
-
options;
|
|
36
|
-
lastSuccessfulConnectionTime = 0;
|
|
37
|
-
reconnectionTimeout;
|
|
38
|
-
requestId = 0;
|
|
39
|
-
pendingRequests = new Map();
|
|
40
|
-
/**
|
|
41
|
-
* Single source of truth for live subscriptions.
|
|
42
|
-
*
|
|
43
|
-
* Keying:
|
|
44
|
-
* - In-flight subscribe (no subId yet): `_pending:<requestId>`.
|
|
45
|
-
* - Established: the server-returned canonical `subId`.
|
|
46
|
-
*
|
|
47
|
-
* Routing `sub:update` / `sub:gone` is O(1) (`get(subId)`). Subscribe-time
|
|
48
|
-
* dedup scans `values()` for a matching `localKey` (O(N)), which is cheap
|
|
49
|
-
* since subscribes happen on component mounts, not in the hot path.
|
|
50
|
-
*/
|
|
51
|
-
subscriptions = new Map();
|
|
52
|
-
queuedCalls = [];
|
|
53
|
-
reconnectAttempts = 0;
|
|
54
|
-
reconnectTimer = null;
|
|
55
|
-
connectionState = "disconnected";
|
|
56
|
-
authState = "pending";
|
|
57
|
-
/**
|
|
58
|
-
* Authenticated user id from `auth:success`. Distinct from `queryIdentity`
|
|
59
|
-
* (server-side cache/sub segmentation key); this is just "who am I logged
|
|
60
|
-
* in as" for app display purposes. Null while unauthenticated / anonymous.
|
|
61
|
-
*/
|
|
62
|
-
authUserId = null;
|
|
63
|
-
explicitlyClosed = false;
|
|
64
|
-
stateListeners = new Set();
|
|
65
|
-
lastPublicState = "disconnected";
|
|
66
|
-
// ─── Inactivity watchdog (passive — server drives pings) ─────────────────
|
|
67
|
-
serverInactivityMs;
|
|
68
|
-
lastServerMessageAt = 0;
|
|
69
|
-
inactivityTimer = null;
|
|
70
|
-
// ─── Pending connect() promise ──────────────────────────────────────────
|
|
71
|
-
//
|
|
72
|
-
// Tracked on `this` so paths outside the `connect()` closure (the watchdog
|
|
73
|
-
// firing during the connecting window, the OS-level close handler, the
|
|
74
|
-
// reconnect-exhaustion drain) can settle the caller's awaiting promise
|
|
75
|
-
// rather than leaving it suspended. Each setter wraps the underlying
|
|
76
|
-
// resolve/reject with a one-shot guard so we can call freely; subsequent
|
|
77
|
-
// calls are no-ops.
|
|
78
|
-
pendingConnectResolve = null;
|
|
79
|
-
pendingConnectReject = null;
|
|
80
|
-
// ─── Send throttle ──────────────────────────────────────────────────────
|
|
81
|
-
//
|
|
82
|
-
// The first `throttleEveryN` calls in a quiet window dispatch directly.
|
|
83
|
-
// The N+1th call flips `throttling = true` and any subsequent call is
|
|
84
|
-
// parked on `queuedCalls`. A single async loop drains the queue in
|
|
85
|
-
// batches of up to `throttleEveryN`, sleeping `throttleDelayMs` between
|
|
86
|
-
// batches, and exits as soon as the queue drains in a single batch
|
|
87
|
-
// (burst over → back to direct-dispatch mode).
|
|
88
|
-
throttleEveryN;
|
|
89
|
-
throttleDelayMs;
|
|
90
|
-
throttleCounter = 0;
|
|
91
|
-
throttling = false;
|
|
92
|
-
// ─── Browser env listeners ──────────────────────────────────────────────
|
|
93
|
-
visibilityListener = null;
|
|
94
|
-
onlineListener = null;
|
|
95
|
-
constructor(options) {
|
|
96
|
-
this.options = {
|
|
97
|
-
reconnectionTimeout: 180000, // 3 minutes default
|
|
98
|
-
reconnect: {
|
|
99
|
-
enabled: true,
|
|
100
|
-
maxAttempts: 5,
|
|
101
|
-
delay: 1000,
|
|
102
|
-
backoff: 1.5,
|
|
103
|
-
...options.reconnect,
|
|
104
|
-
},
|
|
105
|
-
...options,
|
|
106
|
-
};
|
|
107
|
-
this.reconnectionTimeout = this.options.reconnectionTimeout;
|
|
108
|
-
this.serverInactivityMs = options.heartbeat?.serverInactivityMs ?? 60_000;
|
|
109
|
-
this.throttleEveryN = options.throttle?.everyN ?? 100;
|
|
110
|
-
this.throttleDelayMs = options.throttle?.delayMs ?? 10;
|
|
111
|
-
this.installEnvironmentListeners();
|
|
112
|
-
// Fire and forget: warm the wasm hash module so the first subscribe
|
|
113
|
-
// isn't billed for the ~5–10ms wasm bootstrap. Swallow rejections —
|
|
114
|
-
// the lazy path inside `computeLocalSubKey` will surface any real
|
|
115
|
-
// wasm failure on the first actual subscribe.
|
|
116
|
-
prewarmHasher().catch(() => { });
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Subscribe to client state transitions. Returns an unsubscribe fn.
|
|
120
|
-
* The listener is called immediately with the current state.
|
|
121
|
-
*/
|
|
122
|
-
onState(listener) {
|
|
123
|
-
this.stateListeners.add(listener);
|
|
124
|
-
listener(this.computePublicState());
|
|
125
|
-
return () => {
|
|
126
|
-
this.stateListeners.delete(listener);
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
/** Current public state. */
|
|
130
|
-
getPublicState() {
|
|
131
|
-
return this.computePublicState();
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Connect to the WebSocket server.
|
|
135
|
-
* Returns a promise that resolves when connected and authenticated.
|
|
136
|
-
*/
|
|
137
|
-
async connect() {
|
|
138
|
-
this.explicitlyClosed = false;
|
|
139
|
-
if (this.connectionState == "connected" || this.connectionState == "connecting") {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
this.connectionState = "connecting";
|
|
143
|
-
this.emitState();
|
|
144
|
-
return new Promise(async (resolve, reject) => {
|
|
145
|
-
// One-shot guards. Whichever side fires first nulls both slots so
|
|
146
|
-
// later callers (e.g. handleClose after a successful auth) are no-ops.
|
|
147
|
-
this.pendingConnectResolve = () => {
|
|
148
|
-
this.pendingConnectResolve = null;
|
|
149
|
-
this.pendingConnectReject = null;
|
|
150
|
-
resolve();
|
|
151
|
-
};
|
|
152
|
-
this.pendingConnectReject = (err) => {
|
|
153
|
-
this.pendingConnectResolve = null;
|
|
154
|
-
this.pendingConnectReject = null;
|
|
155
|
-
reject(err);
|
|
156
|
-
};
|
|
157
|
-
try {
|
|
158
|
-
const WS = await getWebSocket();
|
|
159
|
-
const url = new URL(this.options.url);
|
|
160
|
-
const ws = new WS(url.toString());
|
|
161
|
-
this.ws = ws;
|
|
162
|
-
ws.onopen = () => {
|
|
163
|
-
// Arm the inactivity watchdog the moment the upgrade completes.
|
|
164
|
-
// If the server stalls and never sends `connected`, the watchdog
|
|
165
|
-
// fires at `serverInactivityMs` and forces a reconnect instead
|
|
166
|
-
// of leaving the client wedged in "connecting" forever.
|
|
167
|
-
// `handleMessage` keeps the timestamp fresh on every inbound
|
|
168
|
-
// frame, including the `connected` JSON itself.
|
|
169
|
-
this.startInactivityWatchdog();
|
|
170
|
-
};
|
|
171
|
-
ws.onmessage = (event) => {
|
|
172
|
-
const data = typeof event.data === "string" ? event.data : event.data.toString();
|
|
173
|
-
this.handleMessage(data);
|
|
174
|
-
};
|
|
175
|
-
ws.onclose = () => {
|
|
176
|
-
this.handleClose();
|
|
177
|
-
};
|
|
178
|
-
ws.onerror = (err) => {
|
|
179
|
-
const error = err instanceof Error ? err : new Error(`WebSocket error: `, { cause: err });
|
|
180
|
-
this.handleError(error);
|
|
181
|
-
this.pendingConnectReject?.(error);
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
catch (err) {
|
|
185
|
-
this.connectionState = "disconnected";
|
|
186
|
-
this.emitState();
|
|
187
|
-
this.pendingConnectReject?.(err);
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Disconnect from the server.
|
|
193
|
-
*/
|
|
194
|
-
disconnect() {
|
|
195
|
-
this.explicitlyClosed = true;
|
|
196
|
-
this.cleanup();
|
|
197
|
-
const oldWs = this.ws;
|
|
198
|
-
this.ws = null;
|
|
199
|
-
if (oldWs) {
|
|
200
|
-
oldWs.onmessage = null;
|
|
201
|
-
oldWs.onerror = null;
|
|
202
|
-
oldWs.onopen = null;
|
|
203
|
-
oldWs.onclose = null;
|
|
204
|
-
try {
|
|
205
|
-
oldWs.close();
|
|
206
|
-
}
|
|
207
|
-
catch { /* swallow */ }
|
|
208
|
-
}
|
|
209
|
-
this.removeEnvironmentListeners();
|
|
210
|
-
this.emitState();
|
|
211
|
-
}
|
|
212
|
-
/**
|
|
213
|
-
* Check if connected and authenticated.
|
|
214
|
-
*/
|
|
215
|
-
isReady() {
|
|
216
|
-
return this.connectionState == "connected" && this.authState != "pending";
|
|
217
|
-
}
|
|
218
|
-
/**
|
|
219
|
-
* Check if authenticated.
|
|
220
|
-
*/
|
|
221
|
-
isAuthenticated() {
|
|
222
|
-
return this.authState == "authenticated";
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Check if anonymouse.
|
|
226
|
-
*/
|
|
227
|
-
isAnonymouse() {
|
|
228
|
-
return this.authState == "anonymouse";
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* The authenticated user's id (from `auth:success`), or null when
|
|
232
|
-
* unauthenticated / anonymous. Useful for "Logged in as …" display.
|
|
233
|
-
*
|
|
234
|
-
* Not to be confused with server-side `queryIdentity`, which is the
|
|
235
|
-
* cache/subscription segmentation key (resolved entirely on the server).
|
|
236
|
-
*/
|
|
237
|
-
getAuthUserId() {
|
|
238
|
-
return this.authUserId;
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* Call a procedure (query, mutation, or action).
|
|
242
|
-
*
|
|
243
|
-
* If the client is not ready (disconnected or still authenticating), the
|
|
244
|
-
* call is FIFO-queued and dispatched on the next ready state. The promise
|
|
245
|
-
* still resolves with the eventual server response.
|
|
246
|
-
*
|
|
247
|
-
* Pass `onSend` to be notified when the call actually leaves the queue
|
|
248
|
-
* (used by `useMutation` to flip `state` from "queued" → "loading").
|
|
249
|
-
*/
|
|
250
|
-
async call(procedure, input, options) {
|
|
251
|
-
// Not ready — park on the disconnect queue. flushQueuedCalls picks
|
|
252
|
-
// these up when the next session reaches ready.
|
|
253
|
-
if (!this.isReady()) {
|
|
254
|
-
return new Promise((resolve, reject) => {
|
|
255
|
-
this.queuedCalls.push({
|
|
256
|
-
procedure,
|
|
257
|
-
input,
|
|
258
|
-
resolve,
|
|
259
|
-
reject,
|
|
260
|
-
onSend: options?.onSend,
|
|
261
|
-
});
|
|
262
|
-
if (!this.explicitlyClosed &&
|
|
263
|
-
this.connectionState === "disconnected") {
|
|
264
|
-
this.connect().catch(() => { });
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
// Throttle disabled — straight dispatch.
|
|
269
|
-
if (this.throttleEveryN <= 0) {
|
|
270
|
-
options?.onSend?.();
|
|
271
|
-
return this.dispatchCall(procedure, input);
|
|
272
|
-
}
|
|
273
|
-
// Already throttling — park and let the loop flush us in the next batch.
|
|
274
|
-
if (this.throttling) {
|
|
275
|
-
return new Promise((resolve, reject) => {
|
|
276
|
-
this.queuedCalls.push({
|
|
277
|
-
procedure,
|
|
278
|
-
input,
|
|
279
|
-
resolve,
|
|
280
|
-
reject,
|
|
281
|
-
onSend: options?.onSend,
|
|
282
|
-
});
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
// Direct dispatch, count toward throttle cap. When we hit the cap,
|
|
286
|
-
// flip into throttling mode and kick off the drain loop.
|
|
287
|
-
this.throttleCounter++;
|
|
288
|
-
if (this.throttleCounter >= this.throttleEveryN) {
|
|
289
|
-
this.throttling = true;
|
|
290
|
-
await this.runThrottleLoop();
|
|
291
|
-
}
|
|
292
|
-
options?.onSend?.();
|
|
293
|
-
return this.dispatchCall(procedure, input);
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Throttle drain loop. Each iteration: sleep `throttleDelayMs`, then
|
|
297
|
-
* dispatch up to `throttleEveryN` queued items. Exits as soon as the
|
|
298
|
-
* queue drains inside a single batch (burst over, back to direct
|
|
299
|
-
* dispatch on the next `call()`) or the connection goes away
|
|
300
|
-
* (reconnect path will restart us via `flushQueuedCalls`).
|
|
301
|
-
*/
|
|
302
|
-
async runThrottleLoop() {
|
|
303
|
-
while (this.throttling) {
|
|
304
|
-
await new Promise((r) => setTimeout(r, this.throttleDelayMs));
|
|
305
|
-
if (!this.isReady()) {
|
|
306
|
-
this.throttling = false;
|
|
307
|
-
this.throttleCounter = 0;
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
this.throttleCounter = 0;
|
|
311
|
-
while (this.throttleCounter < this.throttleEveryN &&
|
|
312
|
-
this.queuedCalls.length > 0) {
|
|
313
|
-
const item = this.queuedCalls.shift();
|
|
314
|
-
item.onSend?.();
|
|
315
|
-
this.dispatchCall(item.procedure, item.input).then(item.resolve, item.reject);
|
|
316
|
-
this.throttleCounter++;
|
|
317
|
-
}
|
|
318
|
-
if (this.queuedCalls.length === 0) {
|
|
319
|
-
// Drained without hitting the cap — release the brake.
|
|
320
|
-
this.throttling = false;
|
|
321
|
-
this.throttleCounter = 0;
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
// Else: hit cap, more pending — loop and wait again.
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Issue the call against an established connection. Caller must ensure
|
|
329
|
-
* `isReady()` before invoking.
|
|
330
|
-
*/
|
|
331
|
-
dispatchCall(procedure, input) {
|
|
332
|
-
const id = this.generateRequestId();
|
|
333
|
-
return new Promise((resolve, reject) => {
|
|
334
|
-
this.pendingRequests.set(id, { resolve, reject });
|
|
335
|
-
this.send({
|
|
336
|
-
type: "call",
|
|
337
|
-
id,
|
|
338
|
-
procedure,
|
|
339
|
-
input,
|
|
340
|
-
});
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
/**
|
|
344
|
-
* Flush queued calls in FIFO order. Called once `isReady()` becomes
|
|
345
|
-
* true. With the throttle enabled, dispatch the first batch
|
|
346
|
-
* synchronously then hand off to the drain loop so a large
|
|
347
|
-
* post-reconnect flush respects backpressure instead of dumping 10k
|
|
348
|
-
* frames in one tick.
|
|
349
|
-
*/
|
|
350
|
-
flushQueuedCalls() {
|
|
351
|
-
if (this.queuedCalls.length === 0)
|
|
352
|
-
return;
|
|
353
|
-
if (this.throttleEveryN <= 0) {
|
|
354
|
-
const queue = this.queuedCalls;
|
|
355
|
-
this.queuedCalls = [];
|
|
356
|
-
for (const item of queue) {
|
|
357
|
-
item.onSend?.();
|
|
358
|
-
this.dispatchCall(item.procedure, item.input).then(item.resolve, item.reject);
|
|
359
|
-
}
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
this.throttleCounter = 0;
|
|
363
|
-
while (this.throttleCounter < this.throttleEveryN &&
|
|
364
|
-
this.queuedCalls.length > 0) {
|
|
365
|
-
const item = this.queuedCalls.shift();
|
|
366
|
-
item.onSend?.();
|
|
367
|
-
this.dispatchCall(item.procedure, item.input).then(item.resolve, item.reject);
|
|
368
|
-
this.throttleCounter++;
|
|
369
|
-
}
|
|
370
|
-
if (this.queuedCalls.length > 0) {
|
|
371
|
-
this.throttling = true;
|
|
372
|
-
this.runThrottleLoop();
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
/**
|
|
376
|
-
* Re-send `subscribe` for every existing subscription. The subId for each
|
|
377
|
-
* may legitimately change (e.g. `queryIdentity` was a function of user
|
|
378
|
-
* identity and the user changed during the disconnect), so we re-key the
|
|
379
|
-
* unified `subscriptions` Map: snapshot every record, drop them from the
|
|
380
|
-
* Map, then reinsert under a fresh `_pending:<reqId>` key. Each pending
|
|
381
|
-
* request's `resolve` rekeys the parked record under the new server-issued
|
|
382
|
-
* subId so subsequent `sub:update` frames route to the existing
|
|
383
|
-
* `onUpdate` callback (which the React handle is still holding).
|
|
384
|
-
*/
|
|
385
|
-
resubscribeAll() {
|
|
386
|
-
if (this.subscriptions.size === 0)
|
|
387
|
-
return;
|
|
388
|
-
const subs = Array.from(this.subscriptions.values());
|
|
389
|
-
this.subscriptions.clear();
|
|
390
|
-
for (const sub of subs) {
|
|
391
|
-
const id = this.generateRequestId();
|
|
392
|
-
const pendingKey = `_pending:${id}`;
|
|
393
|
-
sub.subId = null;
|
|
394
|
-
this.subscriptions.set(pendingKey, sub);
|
|
395
|
-
this.pendingRequests.set(id, {
|
|
396
|
-
subscribeResolve: (subId) => {
|
|
397
|
-
const parked = this.subscriptions.get(pendingKey);
|
|
398
|
-
if (!subId || !parked) {
|
|
399
|
-
// Server didn't echo a subId, or the record was torn down
|
|
400
|
-
// mid-replay (e.g. component unmounted). Drop the parked
|
|
401
|
-
// entry so it can't leak.
|
|
402
|
-
this.subscriptions.delete(pendingKey);
|
|
403
|
-
this.rejectJoiners(parked, new RPCError("Subscribe response missing subId after replay", "PROTOCOL_ERROR"));
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
// Mutate the SAME Subscription object — the LiveQueryHandleImpl
|
|
407
|
-
// is still holding its `onUpdate` closure indirectly through
|
|
408
|
-
// this record. Replacing the object would orphan the handle.
|
|
409
|
-
parked.subId = subId;
|
|
410
|
-
this.subscriptions.delete(pendingKey);
|
|
411
|
-
this.subscriptions.set(subId, parked);
|
|
412
|
-
// Drain joiners that joined during the parked phase.
|
|
413
|
-
this.resolveJoiners(parked, subId);
|
|
414
|
-
},
|
|
415
|
-
reject: (err) => {
|
|
416
|
-
// Keep the parked record alive on transport-level drops so the
|
|
417
|
-
// next resubscribeAll replays it; purge (and reject joiners)
|
|
418
|
-
// only on protocol-level errors where re-issuing would not help.
|
|
419
|
-
if (!(err instanceof RPCError) || err.code !== "DISCONNECTED") {
|
|
420
|
-
const parked = this.subscriptions.get(pendingKey);
|
|
421
|
-
this.subscriptions.delete(pendingKey);
|
|
422
|
-
this.rejectJoiners(parked, err);
|
|
423
|
-
}
|
|
424
|
-
console.error(`[WSClient] Failed to re-subscribe ${sub.procedure}:`, err);
|
|
425
|
-
},
|
|
426
|
-
});
|
|
427
|
-
this.send({
|
|
428
|
-
type: "subscribe",
|
|
429
|
-
id,
|
|
430
|
-
procedure: sub.procedure,
|
|
431
|
-
input: sub.input,
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
/**
|
|
436
|
-
* Subscribe to a live query.
|
|
437
|
-
*
|
|
438
|
-
* The client does not compute the subId locally — it sends the
|
|
439
|
-
* (procedure, input) pair and receives the canonical subId on the
|
|
440
|
-
* subscribe response. This lets the server's per-procedure
|
|
441
|
-
* `queryIdentity` override be authoritative without forcing the client
|
|
442
|
-
* to mirror any of the resolution logic.
|
|
443
|
-
*
|
|
444
|
-
* Returns the server-assigned subId and an unsubscribe function.
|
|
445
|
-
*/
|
|
446
|
-
async subscribe(procedure, input, serializedInput, onUpdate, onGone) {
|
|
447
|
-
await this.ensureConnected();
|
|
448
|
-
const localKey = await computeLocalSubKey(procedure, serializedInput);
|
|
449
|
-
// Manager-level dedup: scan values for an existing subscription with
|
|
450
|
-
// the same logical (procedure, input). Subscribe is a cold path (mounts,
|
|
451
|
-
// not the per-message hot path), so a linear scan is the right shape
|
|
452
|
-
// here in exchange for a single unified Map.
|
|
453
|
-
const existing = this.findSubByLocalKey(localKey);
|
|
454
|
-
if (existing) {
|
|
455
|
-
existing.onUpdate = onUpdate;
|
|
456
|
-
existing.onGone = onGone;
|
|
457
|
-
if (existing.subId) {
|
|
458
|
-
return {
|
|
459
|
-
subId: existing.subId,
|
|
460
|
-
unsubscribe: () => this.unsubscribeByLocalKey(localKey, existing.subId),
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
// Subscribe response for the existing record is still in flight.
|
|
464
|
-
// Park an event-driven joiner on the record — the originating
|
|
465
|
-
// subscribeResolve (or the replay's, if the socket dropped first)
|
|
466
|
-
// drains us when the response lands.
|
|
467
|
-
return new Promise((resolve, reject) => {
|
|
468
|
-
const joinerTimeout = setTimeout(() => {
|
|
469
|
-
existing.pendingJoiners?.delete(joiner);
|
|
470
|
-
reject(new RPCError(`Subscribe timeout: ${procedure}`, "TIMEOUT"));
|
|
471
|
-
}, 30_000);
|
|
472
|
-
const joiner = {
|
|
473
|
-
resolve: (subId) => {
|
|
474
|
-
clearTimeout(joinerTimeout);
|
|
475
|
-
existing.pendingJoiners?.delete(joiner);
|
|
476
|
-
resolve({
|
|
477
|
-
subId,
|
|
478
|
-
unsubscribe: () => this.unsubscribeByLocalKey(localKey, subId),
|
|
479
|
-
});
|
|
480
|
-
},
|
|
481
|
-
reject: (err) => {
|
|
482
|
-
clearTimeout(joinerTimeout);
|
|
483
|
-
existing.pendingJoiners?.delete(joiner);
|
|
484
|
-
reject(err);
|
|
485
|
-
},
|
|
486
|
-
};
|
|
487
|
-
(existing.pendingJoiners ??= new Set()).add(joiner);
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
const id = this.generateRequestId();
|
|
491
|
-
const pendingKey = `_pending:${id}`;
|
|
492
|
-
const record = {
|
|
493
|
-
subId: null,
|
|
494
|
-
localKey,
|
|
495
|
-
procedure,
|
|
496
|
-
input,
|
|
497
|
-
onUpdate,
|
|
498
|
-
onGone,
|
|
499
|
-
};
|
|
500
|
-
this.subscriptions.set(pendingKey, record);
|
|
501
|
-
let timeout = null;
|
|
502
|
-
return new Promise((resolve, reject) => {
|
|
503
|
-
this.pendingRequests.set(id, {
|
|
504
|
-
subscribeResolve: (subId) => {
|
|
505
|
-
if (timeout) {
|
|
506
|
-
clearTimeout(timeout);
|
|
507
|
-
timeout = null;
|
|
508
|
-
}
|
|
509
|
-
const sub = this.subscriptions.get(pendingKey);
|
|
510
|
-
if (!subId || !sub) {
|
|
511
|
-
this.subscriptions.delete(pendingKey);
|
|
512
|
-
const err = new RPCError("Subscribe response missing subId", "PROTOCOL_ERROR");
|
|
513
|
-
this.rejectJoiners(sub, err);
|
|
514
|
-
reject(err);
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
// Re-key from pending sentinel to real subId, same object.
|
|
518
|
-
sub.subId = subId;
|
|
519
|
-
this.subscriptions.delete(pendingKey);
|
|
520
|
-
this.subscriptions.set(subId, sub);
|
|
521
|
-
resolve({
|
|
522
|
-
subId,
|
|
523
|
-
unsubscribe: () => this.unsubscribeByLocalKey(localKey, subId),
|
|
524
|
-
});
|
|
525
|
-
this.resolveJoiners(sub, subId);
|
|
526
|
-
},
|
|
527
|
-
reject: (err) => {
|
|
528
|
-
if (timeout) {
|
|
529
|
-
clearTimeout(timeout);
|
|
530
|
-
timeout = null;
|
|
531
|
-
}
|
|
532
|
-
// Keep the record alive on transport-level drops (DISCONNECTED)
|
|
533
|
-
// so `resubscribeAll` replays it after the reconnect handshake.
|
|
534
|
-
// Joiners stay attached to the parked record and are drained by
|
|
535
|
-
// the replay's subscribeResolve. Purge (and reject joiners) only
|
|
536
|
-
// on real protocol-level errors or the 30s timeout.
|
|
537
|
-
if (!(err instanceof RPCError) || err.code !== "DISCONNECTED") {
|
|
538
|
-
const sub = this.subscriptions.get(pendingKey);
|
|
539
|
-
this.subscriptions.delete(pendingKey);
|
|
540
|
-
this.rejectJoiners(sub, err);
|
|
541
|
-
}
|
|
542
|
-
reject(err);
|
|
543
|
-
},
|
|
544
|
-
});
|
|
545
|
-
this.send({
|
|
546
|
-
type: "subscribe",
|
|
547
|
-
id,
|
|
548
|
-
procedure,
|
|
549
|
-
input,
|
|
550
|
-
});
|
|
551
|
-
timeout = setTimeout(() => {
|
|
552
|
-
if (this.pendingRequests.has(id)) {
|
|
553
|
-
this.pendingRequests.delete(id);
|
|
554
|
-
const sub = this.subscriptions.get(pendingKey);
|
|
555
|
-
this.subscriptions.delete(pendingKey);
|
|
556
|
-
const err = new RPCError(`Subscribe timeout: ${procedure}`, "TIMEOUT");
|
|
557
|
-
this.rejectJoiners(sub, err);
|
|
558
|
-
reject(err);
|
|
559
|
-
}
|
|
560
|
-
}, 30_000);
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
findSubByLocalKey(localKey) {
|
|
564
|
-
for (const sub of this.subscriptions.values()) {
|
|
565
|
-
if (sub.localKey === localKey)
|
|
566
|
-
return sub;
|
|
567
|
-
}
|
|
568
|
-
return undefined;
|
|
569
|
-
}
|
|
570
|
-
/** Resolve every joiner waiting on this Subscription with the canonical subId. */
|
|
571
|
-
resolveJoiners(sub, subId) {
|
|
572
|
-
if (!sub.pendingJoiners)
|
|
573
|
-
return;
|
|
574
|
-
// Snapshot before iterating — each joiner.resolve() mutates the set.
|
|
575
|
-
const snapshot = Array.from(sub.pendingJoiners);
|
|
576
|
-
for (const j of snapshot)
|
|
577
|
-
j.resolve(subId);
|
|
578
|
-
}
|
|
579
|
-
/** Reject every joiner waiting on this Subscription with the given error. */
|
|
580
|
-
rejectJoiners(sub, err) {
|
|
581
|
-
if (!sub?.pendingJoiners)
|
|
582
|
-
return;
|
|
583
|
-
const snapshot = Array.from(sub.pendingJoiners);
|
|
584
|
-
for (const j of snapshot)
|
|
585
|
-
j.reject(err);
|
|
586
|
-
}
|
|
587
|
-
unsubscribeByLocalKey(localKey, subId) {
|
|
588
|
-
const sendUnsubscribe = (subId) => {
|
|
589
|
-
if (this.connectionState === "connected") {
|
|
590
|
-
this.send({ type: "unsubscribe", subId });
|
|
591
|
-
}
|
|
592
|
-
};
|
|
593
|
-
if (subId) {
|
|
594
|
-
// If we have the subId, we can skip straight to the unsubscribe message
|
|
595
|
-
this.subscriptions.delete(subId);
|
|
596
|
-
sendUnsubscribe(subId);
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
// We don't know whether the entry is parked under a pending sentinel or
|
|
600
|
-
// a real subId, so iterate. O(N) is fine here: unsubscribe runs at
|
|
601
|
-
// most once per handle teardown.
|
|
602
|
-
for (const [key, sub] of this.subscriptions.entries()) {
|
|
603
|
-
if (sub.localKey !== localKey)
|
|
604
|
-
continue;
|
|
605
|
-
this.subscriptions.delete(key);
|
|
606
|
-
// Cancel any callers still waiting on the in-flight subscribe.
|
|
607
|
-
this.rejectJoiners(sub, new RPCError("Subscription cancelled before response", "CANCELLED"));
|
|
608
|
-
sendUnsubscribe(sub.subId);
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
// ─── Internal Handlers ──────────────────────────────────────────────────────
|
|
613
|
-
async handleMessage(dataValue) {
|
|
614
|
-
// ANY inbound frame counts as a liveness signal — the bytes arrived,
|
|
615
|
-
// therefore the socket is alive. Done before parse/validate so that
|
|
616
|
-
// even malformed frames still refresh the watchdog (it's the OS
|
|
617
|
-
// delivery we care about, not the JSON correctness).
|
|
618
|
-
this.lastServerMessageAt = Date.now();
|
|
619
|
-
// Validate against the full server-message union rather than trusting a
|
|
620
|
-
// naked cast. A protocol-version skew (or a man-in-the-middle proxy
|
|
621
|
-
// that mangles the payload) would otherwise blow up downstream with a
|
|
622
|
-
// runtime TypeError on a missing field; better to drop the message
|
|
623
|
-
// with a clear diagnostic.
|
|
624
|
-
let data = undefined;
|
|
625
|
-
try {
|
|
626
|
-
data = JSON.parse(dataValue);
|
|
627
|
-
}
|
|
628
|
-
catch {
|
|
629
|
-
console.error("[WSClient] Failed to parse server message as JSON:", dataValue);
|
|
630
|
-
}
|
|
631
|
-
const parsed = ServerMessageSchema.safeParse(data);
|
|
632
|
-
if (!parsed.success) {
|
|
633
|
-
console.error("[WSClient] Invalid server message:", parsed.error.issues, data);
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
const message = parsed.data;
|
|
637
|
-
switch (message.type) {
|
|
638
|
-
case "connected": {
|
|
639
|
-
this.connectionState = "connected";
|
|
640
|
-
this.reconnectAttempts = 0;
|
|
641
|
-
// Track successful connection time for reconnection logic
|
|
642
|
-
this.lastSuccessfulConnectionTime = Date.now();
|
|
643
|
-
// (Inactivity watchdog already started in `ws.onopen`. The top of
|
|
644
|
-
// handleMessage refreshed `lastServerMessageAt` for this very frame.)
|
|
645
|
-
// Send auth if token provided
|
|
646
|
-
const token = await this.options.token?.();
|
|
647
|
-
if (token) {
|
|
648
|
-
this.authState = "pending";
|
|
649
|
-
this.emitState();
|
|
650
|
-
this.send({ type: "auth", data: { token } });
|
|
651
|
-
}
|
|
652
|
-
else {
|
|
653
|
-
this.authState = "anonymouse";
|
|
654
|
-
this.options.onConnect?.();
|
|
655
|
-
this.emitState();
|
|
656
|
-
this.afterReady();
|
|
657
|
-
this.pendingConnectResolve?.();
|
|
658
|
-
}
|
|
659
|
-
break;
|
|
660
|
-
}
|
|
661
|
-
case "auth:success":
|
|
662
|
-
this.authState = "authenticated";
|
|
663
|
-
this.authUserId = message.userId;
|
|
664
|
-
this.options.onConnect?.();
|
|
665
|
-
this.emitState();
|
|
666
|
-
this.afterReady();
|
|
667
|
-
this.pendingConnectResolve?.();
|
|
668
|
-
break;
|
|
669
|
-
case "auth:failed":
|
|
670
|
-
// The WebSocket itself is fine — only auth rejected. Resolve the
|
|
671
|
-
// connect promise so the app can decide what to do via
|
|
672
|
-
// `isAuthenticated()` / `getPublicState()`. Avoids flooding the
|
|
673
|
-
// console for anonymous-friendly apps that simply continue without
|
|
674
|
-
// a token.
|
|
675
|
-
this.authState = "unauthenticated";
|
|
676
|
-
this.authUserId = null;
|
|
677
|
-
this.options.onConnect?.();
|
|
678
|
-
this.emitState();
|
|
679
|
-
this.afterReady();
|
|
680
|
-
this.pendingConnectResolve?.();
|
|
681
|
-
break;
|
|
682
|
-
case "response":
|
|
683
|
-
this.handleResponse(message);
|
|
684
|
-
break;
|
|
685
|
-
case "sub:update":
|
|
686
|
-
this.handleSubscriptionUpdate(message);
|
|
687
|
-
break;
|
|
688
|
-
case "sub:gone":
|
|
689
|
-
this.handleSubscriptionGone(message);
|
|
690
|
-
break;
|
|
691
|
-
case "ping":
|
|
692
|
-
// The `lastServerMessageAt` update at the top of `handleMessage`
|
|
693
|
-
// is the only thing this signal exists for. No reply.
|
|
694
|
-
break;
|
|
695
|
-
default: {
|
|
696
|
-
// Exhaustive over `ServerMessageSchema`; reaching here means the
|
|
697
|
-
// schema was extended without updating this switch.
|
|
698
|
-
const _exhaustive = message;
|
|
699
|
-
console.warn("[WSClient] Unhandled server message type:", _exhaustive);
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
/**
|
|
704
|
-
* Called once the client transitions to a "ready" state on a fresh
|
|
705
|
-
* connection. Replays live-query subscriptions and flushes any queued
|
|
706
|
-
* calls in FIFO order.
|
|
707
|
-
*/
|
|
708
|
-
afterReady() {
|
|
709
|
-
this.resubscribeAll();
|
|
710
|
-
this.flushQueuedCalls();
|
|
711
|
-
}
|
|
712
|
-
handleResponse(message) {
|
|
713
|
-
const pending = this.pendingRequests.get(message.id);
|
|
714
|
-
if (!pending)
|
|
715
|
-
return;
|
|
716
|
-
this.pendingRequests.delete(message.id);
|
|
717
|
-
if (message.success) {
|
|
718
|
-
if (pending.subscribeResolve) {
|
|
719
|
-
pending.subscribeResolve(message.subId);
|
|
720
|
-
}
|
|
721
|
-
else {
|
|
722
|
-
pending.resolve?.(message.data);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
else {
|
|
726
|
-
pending.reject(new RPCError(message.error ?? "Unknown error", message.code));
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
handleSubscriptionUpdate(message) {
|
|
730
|
-
const sub = this.subscriptions.get(message.subId);
|
|
731
|
-
if (sub) {
|
|
732
|
-
sub.onUpdate(message.data);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
/**
|
|
736
|
-
* Handles a `sub:gone` message: the server no longer tracks this
|
|
737
|
-
* subscription (e.g. it was fully unregistered while we were disconnected
|
|
738
|
-
* from the sub-manager). Notify the handle and drop the local entry so a
|
|
739
|
-
* re-subscribe is required to receive further updates.
|
|
740
|
-
*/
|
|
741
|
-
handleSubscriptionGone(message) {
|
|
742
|
-
const sub = this.subscriptions.get(message.subId);
|
|
743
|
-
if (!sub)
|
|
744
|
-
return;
|
|
745
|
-
this.subscriptions.delete(message.subId);
|
|
746
|
-
try {
|
|
747
|
-
sub.onGone?.(message.reason);
|
|
748
|
-
}
|
|
749
|
-
catch (e) {
|
|
750
|
-
console.error("[WSClient] onGone listener threw:", e);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
handleClose() {
|
|
754
|
-
// Idempotent: `closeAndReconnect()` calls us synchronously to fast-track
|
|
755
|
-
// the reconnect, and then the OS-level `ws.onclose` fires again on the
|
|
756
|
-
// discarded socket. Re-entering would double-fire `onDisconnect` and
|
|
757
|
-
// double-bump `reconnectAttempts`.
|
|
758
|
-
if (this.connectionState === "disconnected")
|
|
759
|
-
return;
|
|
760
|
-
this.connectionState = "disconnected";
|
|
761
|
-
this.authState = "pending";
|
|
762
|
-
this.ws = null;
|
|
763
|
-
this.stopInactivityWatchdog();
|
|
764
|
-
this.options.onDisconnect?.();
|
|
765
|
-
// If a `connect()` promise is still in flight (we never reached
|
|
766
|
-
// auth:success / anonymous-connected before the close), reject it so
|
|
767
|
-
// any `await client.connect()` doesn't hang. Auto-reconnect will
|
|
768
|
-
// still take over via scheduleReconnect below — this only settles
|
|
769
|
-
// the user-facing promise.
|
|
770
|
-
this.pendingConnectReject?.(new RPCError("Connection closed before ready", "DISCONNECTED"));
|
|
771
|
-
// Reject every in-flight request. These were already on the wire — the
|
|
772
|
-
// server may have processed a mutation but the response frame was lost
|
|
773
|
-
// when the socket dropped. Re-issuing would risk double-execution
|
|
774
|
-
// (e.g. a transfer applied twice), so we surface DISCONNECTED to the
|
|
775
|
-
// caller; the side effect, if it committed, is reflected via the live
|
|
776
|
-
// query subscription cluster on reconnect.
|
|
777
|
-
//
|
|
778
|
-
// Queued calls (this.queuedCalls) are intentionally preserved and
|
|
779
|
-
// flushed on the next ready state — those were never sent, so
|
|
780
|
-
// re-issuing them is safe and is the user-visible "I clicked
|
|
781
|
-
// before the socket reconnected" path. Subscribe pendings are
|
|
782
|
-
// replayed via resubscribeAll in afterReady.
|
|
783
|
-
for (const pending of this.pendingRequests.values()) {
|
|
784
|
-
pending.reject(new RPCError("Connection closed", "DISCONNECTED"));
|
|
785
|
-
}
|
|
786
|
-
this.pendingRequests.clear();
|
|
787
|
-
this.emitState();
|
|
788
|
-
// Attempt reconnection (unless the caller explicitly disconnected)
|
|
789
|
-
if (!this.explicitlyClosed && this.options.reconnect?.enabled) {
|
|
790
|
-
this.scheduleReconnect();
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
handleError(err) {
|
|
794
|
-
this.options.onError?.(err);
|
|
795
|
-
}
|
|
796
|
-
// ─── Inactivity watchdog ────────────────────────────────────────────────
|
|
797
|
-
//
|
|
798
|
-
// The browser API does not surface ws-protocol pings to JS, and even if
|
|
799
|
-
// it did, asking the JS thread to *send* a periodic ping is fragile
|
|
800
|
-
// during synchronous bursts (a tight 10k mutation loop starves the
|
|
801
|
-
// setInterval long enough to trigger spurious reconnects).
|
|
802
|
-
//
|
|
803
|
-
// The server is in charge of liveness instead: it emits `{type:"ping"}`
|
|
804
|
-
// every `HEARTBEAT_INTERVAL_MS` of channel idle (server side). We
|
|
805
|
-
// just track the wall-clock of the most recent inbound message, and if
|
|
806
|
-
// it slips past `serverInactivityMs` we treat the socket as dead.
|
|
807
|
-
startInactivityWatchdog() {
|
|
808
|
-
this.stopInactivityWatchdog();
|
|
809
|
-
this.lastServerMessageAt = Date.now();
|
|
810
|
-
// Poll at ~quarter cadence so a fresh-stale boundary fires within
|
|
811
|
-
// serverInactivityMs * 1.25 of the actual silence onset. Minimum 1s.
|
|
812
|
-
const tickMs = Math.max(1_000, Math.floor(this.serverInactivityMs / 4));
|
|
813
|
-
this.inactivityTimer = setInterval(() => {
|
|
814
|
-
if (Date.now() - this.lastServerMessageAt > this.serverInactivityMs) {
|
|
815
|
-
this.closeAndReconnect("InactiveServer");
|
|
816
|
-
}
|
|
817
|
-
}, tickMs);
|
|
818
|
-
}
|
|
819
|
-
stopInactivityWatchdog() {
|
|
820
|
-
if (this.inactivityTimer) {
|
|
821
|
-
clearInterval(this.inactivityTimer);
|
|
822
|
-
this.inactivityTimer = null;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* Close the current WebSocket and schedule a reconnect via `handleClose`.
|
|
827
|
-
*
|
|
828
|
-
* Atomically swaps the old socket's event handlers to no-ops BEFORE
|
|
829
|
-
* `close()` so a frame in flight at the moment of close cannot fire
|
|
830
|
-
* `handleMessage` into a half-cleaned state — that was the source of
|
|
831
|
-
* the "browser says socket is alive but client says disconnected" race.
|
|
832
|
-
*/
|
|
833
|
-
closeAndReconnect(reason) {
|
|
834
|
-
if (this.connectionState === "disconnected")
|
|
835
|
-
return;
|
|
836
|
-
console.warn(`[WSClient] closeAndReconnect: ${reason}`);
|
|
837
|
-
const oldWs = this.ws;
|
|
838
|
-
if (oldWs) {
|
|
839
|
-
oldWs.onmessage = null;
|
|
840
|
-
oldWs.onerror = null;
|
|
841
|
-
oldWs.onopen = null;
|
|
842
|
-
// Discard the close handler too — we don't want the OS-level close
|
|
843
|
-
// event to re-enter handleClose; we invoke it synchronously below.
|
|
844
|
-
oldWs.onclose = null;
|
|
845
|
-
try {
|
|
846
|
-
oldWs.close(4000, reason);
|
|
847
|
-
}
|
|
848
|
-
catch { /* swallow */ }
|
|
849
|
-
}
|
|
850
|
-
// Synchronous state transition + reconnect scheduling. Same effect
|
|
851
|
-
// as the OS-level onclose path, but doesn't wait on the close
|
|
852
|
-
// handshake (which can stall when the socket is half-open).
|
|
853
|
-
this.handleClose();
|
|
854
|
-
}
|
|
855
|
-
installEnvironmentListeners() {
|
|
856
|
-
// visibilitychange → "visible": a backgrounded tab that just became
|
|
857
|
-
// visible may have been frozen by the browser. Check staleness now
|
|
858
|
-
// and reconnect immediately if the watchdog is already past due.
|
|
859
|
-
if (typeof document !== "undefined" &&
|
|
860
|
-
typeof document.addEventListener === "function" &&
|
|
861
|
-
!this.visibilityListener) {
|
|
862
|
-
this.visibilityListener = () => {
|
|
863
|
-
if (typeof document === "undefined")
|
|
864
|
-
return;
|
|
865
|
-
if (document.visibilityState !== "visible")
|
|
866
|
-
return;
|
|
867
|
-
if (this.connectionState !== "connected")
|
|
868
|
-
return;
|
|
869
|
-
if (Date.now() - this.lastServerMessageAt > this.serverInactivityMs) {
|
|
870
|
-
this.closeAndReconnect("WokeUpStale");
|
|
871
|
-
}
|
|
872
|
-
};
|
|
873
|
-
document.addEventListener("visibilitychange", this.visibilityListener);
|
|
874
|
-
}
|
|
875
|
-
// online: a flip back to online is a strong signal that any current
|
|
876
|
-
// disconnected-state backoff should collapse to "try right now."
|
|
877
|
-
if (typeof window !== "undefined" &&
|
|
878
|
-
typeof window.addEventListener === "function" &&
|
|
879
|
-
!this.onlineListener) {
|
|
880
|
-
this.onlineListener = () => {
|
|
881
|
-
if (this.connectionState !== "disconnected")
|
|
882
|
-
return;
|
|
883
|
-
if (this.explicitlyClosed)
|
|
884
|
-
return;
|
|
885
|
-
if (this.reconnectTimer) {
|
|
886
|
-
clearTimeout(this.reconnectTimer);
|
|
887
|
-
this.reconnectTimer = null;
|
|
888
|
-
}
|
|
889
|
-
this.connect().catch(() => { });
|
|
890
|
-
};
|
|
891
|
-
window.addEventListener("online", this.onlineListener);
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
removeEnvironmentListeners() {
|
|
895
|
-
if (this.visibilityListener && typeof document !== "undefined") {
|
|
896
|
-
document.removeEventListener("visibilitychange", this.visibilityListener);
|
|
897
|
-
this.visibilityListener = null;
|
|
898
|
-
}
|
|
899
|
-
if (this.onlineListener && typeof window !== "undefined") {
|
|
900
|
-
window.removeEventListener("online", this.onlineListener);
|
|
901
|
-
this.onlineListener = null;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
// ─── Reconnection ───────────────────────────────────────────────────────────
|
|
905
|
-
scheduleReconnect() {
|
|
906
|
-
if (this.reconnectAttempts >= (this.options.reconnect?.maxAttempts ?? 5)) {
|
|
907
|
-
console.error("[WSClient] Max reconnection attempts reached");
|
|
908
|
-
// We're not going to try again — drain any user-facing promises
|
|
909
|
-
// that would otherwise hang forever waiting for a connection that
|
|
910
|
-
// never comes.
|
|
911
|
-
const giveUp = new RPCError("Max reconnection attempts reached", "DISCONNECTED");
|
|
912
|
-
for (const q of this.queuedCalls)
|
|
913
|
-
q.reject(giveUp);
|
|
914
|
-
this.queuedCalls = [];
|
|
915
|
-
this.pendingConnectReject?.(giveUp);
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
const base = (this.options.reconnect?.delay ?? 1000) *
|
|
919
|
-
Math.pow(this.options.reconnect?.backoff ?? 1.5, this.reconnectAttempts);
|
|
920
|
-
// ±50% jitter so two tabs that disconnected at the same instant don't
|
|
921
|
-
// wake the server in lockstep on reconnect.
|
|
922
|
-
const delay = base + base * (Math.random() - 0.5);
|
|
923
|
-
this.reconnectAttempts++;
|
|
924
|
-
this.reconnectTimer = setTimeout(() => {
|
|
925
|
-
this.connect().catch((err) => {
|
|
926
|
-
console.error("[WSClient] Reconnection failed:", err);
|
|
927
|
-
});
|
|
928
|
-
}, delay);
|
|
929
|
-
}
|
|
930
|
-
// ─── State Events ───────────────────────────────────────────────────────────
|
|
931
|
-
computePublicState() {
|
|
932
|
-
if (this.connectionState === "disconnected")
|
|
933
|
-
return "disconnected";
|
|
934
|
-
if (this.connectionState === "connecting")
|
|
935
|
-
return "connecting";
|
|
936
|
-
// connectionState === "connected"
|
|
937
|
-
if (this.authState === "pending")
|
|
938
|
-
return "authenticating";
|
|
939
|
-
return "ready";
|
|
940
|
-
}
|
|
941
|
-
emitState() {
|
|
942
|
-
const next = this.computePublicState();
|
|
943
|
-
if (next === this.lastPublicState)
|
|
944
|
-
return;
|
|
945
|
-
this.lastPublicState = next;
|
|
946
|
-
for (const l of this.stateListeners) {
|
|
947
|
-
try {
|
|
948
|
-
l(next);
|
|
949
|
-
}
|
|
950
|
-
catch (e) {
|
|
951
|
-
console.error("[WSClient] state listener threw:", e);
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
956
|
-
async ensureConnected() {
|
|
957
|
-
if (this.connectionState != "connected") {
|
|
958
|
-
await this.connect();
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
send(message) {
|
|
962
|
-
if (this.ws && this.ws.readyState === WS_OPEN) {
|
|
963
|
-
this.ws.send(supaliveStringify(message));
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
cleanup() {
|
|
967
|
-
this.connectionState = "disconnected";
|
|
968
|
-
this.authState = "pending";
|
|
969
|
-
this.stopInactivityWatchdog();
|
|
970
|
-
if (this.reconnectTimer) {
|
|
971
|
-
clearTimeout(this.reconnectTimer);
|
|
972
|
-
this.reconnectTimer = null;
|
|
973
|
-
}
|
|
974
|
-
const teardown = new RPCError("Client disconnected", "DISCONNECTED");
|
|
975
|
-
for (const pending of this.pendingRequests.values()) {
|
|
976
|
-
pending.reject(teardown);
|
|
977
|
-
}
|
|
978
|
-
this.pendingRequests.clear();
|
|
979
|
-
// Explicit disconnect drains the queue with an error — the consumer
|
|
980
|
-
// tore down on purpose, so we shouldn't hold onto their promises.
|
|
981
|
-
for (const q of this.queuedCalls) {
|
|
982
|
-
q.reject(teardown);
|
|
983
|
-
}
|
|
984
|
-
this.queuedCalls = [];
|
|
985
|
-
// The throttle loop (if running) wakes from its sleep, sees
|
|
986
|
-
// !isReady, and exits on its own. Reset its flags so the next
|
|
987
|
-
// session starts clean.
|
|
988
|
-
this.throttling = false;
|
|
989
|
-
this.throttleCounter = 0;
|
|
990
|
-
// Also settle any in-flight `connect()` promise so a caller that did
|
|
991
|
-
// `await client.connect()` racing with `client.disconnect()` doesn't
|
|
992
|
-
// hang. No-op if already settled.
|
|
993
|
-
this.pendingConnectReject?.(teardown);
|
|
994
|
-
}
|
|
995
|
-
generateRequestId() {
|
|
996
|
-
return `${++this.requestId}`;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
// ─── Browser/Node WebSocket Handling ──────────────────────────────────────────
|
|
1000
|
-
let WebSocketImpl = null;
|
|
1001
|
-
// Check if we're in a browser environment
|
|
1002
|
-
if (typeof globalThis !== "undefined" && globalThis.WebSocket) {
|
|
1003
|
-
WebSocketImpl = globalThis.WebSocket;
|
|
1004
|
-
}
|
|
1005
|
-
async function getWebSocket() {
|
|
1006
|
-
if (WebSocketImpl)
|
|
1007
|
-
return WebSocketImpl;
|
|
1008
|
-
// Dynamic import for Node.js
|
|
1009
|
-
const ws = await import("ws");
|
|
1010
|
-
return ws.WebSocket;
|
|
1011
|
-
}
|
|
1012
|
-
export { getWebSocket };
|
|
1013
|
-
//# sourceMappingURL=ws_client_manager.js.map
|