@pramen/client 0.0.14 → 0.0.16
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/dist/index.d.ts +14 -0
- package/dist/index.js +63 -9
- package/package.json +1 -1
- package/src/index.ts +72 -9
package/dist/index.d.ts
CHANGED
|
@@ -19,13 +19,27 @@ export interface ClientOptions {
|
|
|
19
19
|
/** Override for non-browser environments (defaults to globals). */
|
|
20
20
|
WebSocketImpl?: typeof WebSocket;
|
|
21
21
|
fetchImpl?: typeof fetch;
|
|
22
|
+
/** Give up reconnecting the live socket after this many consecutive failed
|
|
23
|
+
* attempts and surface a connection error to every subscriber (default 8). */
|
|
24
|
+
maxReconnectAttempts?: number;
|
|
22
25
|
}
|
|
23
26
|
export interface SubHandlers<T> {
|
|
24
27
|
onData: (result: T) => void;
|
|
28
|
+
/** Called when the server rejects THIS subscription (a `{type:"error"}` frame). */
|
|
25
29
|
onError?: (err: {
|
|
26
30
|
error: string;
|
|
27
31
|
code: string;
|
|
28
32
|
}) => void;
|
|
33
|
+
/** Called when the underlying live connection itself fails — no WebSocket
|
|
34
|
+
* implementation is available, or the socket has closed and stayed down past
|
|
35
|
+
* `maxReconnectAttempts`. Distinct from `onError` (a per-subscription server error):
|
|
36
|
+
* this is a transport-level failure that affects every sub on the connection.
|
|
37
|
+
* Optional and additive — omitting it preserves the prior (silent) behavior for
|
|
38
|
+
* existing callers. */
|
|
39
|
+
onConnectionError?: (err: {
|
|
40
|
+
error: string;
|
|
41
|
+
code: string;
|
|
42
|
+
}) => void;
|
|
29
43
|
}
|
|
30
44
|
/** Result of a file upload (the persisted blob's storage metadata). */
|
|
31
45
|
export interface UploadResult {
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,11 @@ export class PramenError extends Error {
|
|
|
21
21
|
export function createClient(opts) {
|
|
22
22
|
const doFetch = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
23
23
|
const WS = opts.WebSocketImpl ?? globalThis.WebSocket;
|
|
24
|
+
// Normalize the base url: strip any trailing slash so `${base}/rpc/...` can't become
|
|
25
|
+
// `//rpc/...` (which the Worker serves as a plain-text help page — a 200 that isn't an
|
|
26
|
+
// envelope, silently resolving `call()` to undefined for every RPC).
|
|
27
|
+
const baseUrl = opts.url.replace(/\/+$/, "");
|
|
28
|
+
const maxReconnectAttempts = opts.maxReconnectAttempts ?? 8;
|
|
24
29
|
let token = opts.token;
|
|
25
30
|
// D1 read-your-writes: when the backend runs on the D1 store it returns an
|
|
26
31
|
// `x-pramen-d1-bookmark` header marking the latest write this client has observed.
|
|
@@ -33,7 +38,10 @@ export function createClient(opts) {
|
|
|
33
38
|
let counter = 0;
|
|
34
39
|
let reconnectAttempts = 0;
|
|
35
40
|
let reconnectTimer = null;
|
|
41
|
+
let stableTimer = null;
|
|
36
42
|
let closed = false;
|
|
43
|
+
// How long a connection must stay open before we trust it and reset the backoff.
|
|
44
|
+
const STABLE_MS = 3000;
|
|
37
45
|
async function call(name, input) {
|
|
38
46
|
const headers = { "content-type": "application/json" };
|
|
39
47
|
if (token)
|
|
@@ -42,23 +50,29 @@ export function createClient(opts) {
|
|
|
42
50
|
headers["x-pramen-tenant"] = opts.tenant;
|
|
43
51
|
if (d1Bookmark)
|
|
44
52
|
headers["x-pramen-d1-bookmark"] = d1Bookmark;
|
|
45
|
-
const res = await doFetch(`${
|
|
53
|
+
const res = await doFetch(`${baseUrl}/rpc/${name}`, {
|
|
46
54
|
method: "POST",
|
|
47
55
|
headers,
|
|
48
56
|
body: JSON.stringify(input ?? {}),
|
|
49
57
|
});
|
|
50
58
|
// Capture the D1 read-your-writes bookmark (D1 store only; absent on the DO path).
|
|
59
|
+
// Keep the MAXIMUM bookmark seen — D1 session bookmarks are lexicographically
|
|
60
|
+
// ordered, so a slower earlier response arriving after a mutation must not clobber a
|
|
61
|
+
// newer one and regress read-your-writes.
|
|
51
62
|
const bookmark = res.headers.get("x-pramen-d1-bookmark");
|
|
52
|
-
if (bookmark)
|
|
63
|
+
if (bookmark && (!d1Bookmark || bookmark > d1Bookmark))
|
|
53
64
|
d1Bookmark = bookmark;
|
|
54
65
|
const body = (await res.json().catch(() => ({})));
|
|
55
|
-
|
|
66
|
+
// Require the success envelope explicitly: only `{ ok: true }` is a success. Any
|
|
67
|
+
// other 2xx body (e.g. a non-JSON help page parsed to `{}`, or `ok` absent) is an
|
|
68
|
+
// error, so a call never silently resolves undefined.
|
|
69
|
+
if (!res.ok || body.ok !== true) {
|
|
56
70
|
throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
|
|
57
71
|
}
|
|
58
72
|
return body.result;
|
|
59
73
|
}
|
|
60
74
|
function liveUrl() {
|
|
61
|
-
const u = new URL(
|
|
75
|
+
const u = new URL(baseUrl);
|
|
62
76
|
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
63
77
|
u.pathname = "/live";
|
|
64
78
|
if (opts.tenant)
|
|
@@ -67,13 +81,34 @@ export function createClient(opts) {
|
|
|
67
81
|
u.searchParams.set("token", token);
|
|
68
82
|
return u.toString();
|
|
69
83
|
}
|
|
84
|
+
/** Surface a transport-level failure (no WS impl, or reconnects exhausted) to every
|
|
85
|
+
* subscriber so a `useLiveQuery` can leave its loading state instead of hanging. */
|
|
86
|
+
function reportConnectionError(error, code) {
|
|
87
|
+
for (const sub of subs.values())
|
|
88
|
+
sub.onConnectionError?.({ error, code });
|
|
89
|
+
}
|
|
70
90
|
function ensureSocket() {
|
|
71
|
-
if (closed || ws ||
|
|
91
|
+
if (closed || ws || subs.size === 0)
|
|
92
|
+
return;
|
|
93
|
+
if (!WS) {
|
|
94
|
+
// No WebSocket implementation in this environment — the subscription can never
|
|
95
|
+
// fire. Surface it instead of returning silently (the old hang-forever behavior).
|
|
96
|
+
reportConnectionError("no WebSocket implementation available", "no_websocket");
|
|
72
97
|
return;
|
|
98
|
+
}
|
|
73
99
|
const socket = new WS(liveUrl());
|
|
74
100
|
ws = socket;
|
|
75
101
|
socket.addEventListener("open", () => {
|
|
76
|
-
|
|
102
|
+
// Don't reset the backoff on open alone: an accept-then-immediately-close server
|
|
103
|
+
// would otherwise hot-loop with no backoff growth. Only clear it once the
|
|
104
|
+
// connection has stayed up (stableTimer below).
|
|
105
|
+
if (stableTimer)
|
|
106
|
+
clearTimeout(stableTimer);
|
|
107
|
+
stableTimer = setTimeout(() => {
|
|
108
|
+
stableTimer = null;
|
|
109
|
+
if (ws === socket)
|
|
110
|
+
reconnectAttempts = 0;
|
|
111
|
+
}, STABLE_MS);
|
|
77
112
|
for (const sub of subs.values()) {
|
|
78
113
|
socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
|
|
79
114
|
}
|
|
@@ -97,6 +132,10 @@ export function createClient(opts) {
|
|
|
97
132
|
socket.addEventListener("close", () => {
|
|
98
133
|
if (ws === socket)
|
|
99
134
|
ws = null;
|
|
135
|
+
if (stableTimer) {
|
|
136
|
+
clearTimeout(stableTimer);
|
|
137
|
+
stableTimer = null;
|
|
138
|
+
}
|
|
100
139
|
scheduleReconnect();
|
|
101
140
|
});
|
|
102
141
|
socket.addEventListener("error", () => {
|
|
@@ -111,7 +150,15 @@ export function createClient(opts) {
|
|
|
111
150
|
function scheduleReconnect() {
|
|
112
151
|
if (closed || reconnectTimer || subs.size === 0)
|
|
113
152
|
return;
|
|
114
|
-
|
|
153
|
+
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
154
|
+
// Give up rather than reconnect forever at the 10s cap — a rejected upgrade (e.g.
|
|
155
|
+
// auth 403) would otherwise spin silently behind a permanent spinner.
|
|
156
|
+
reportConnectionError(`live connection failed after ${reconnectAttempts} attempts`, "connection_failed");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Exponential backoff with jitter (so a fleet of clients doesn't reconnect in lockstep).
|
|
160
|
+
const base = Math.min(500 * 2 ** reconnectAttempts, 10_000);
|
|
161
|
+
const delay = base / 2 + Math.random() * (base / 2);
|
|
115
162
|
reconnectAttempts++;
|
|
116
163
|
reconnectTimer = setTimeout(() => {
|
|
117
164
|
reconnectTimer = null;
|
|
@@ -132,17 +179,21 @@ export function createClient(opts) {
|
|
|
132
179
|
clearTimeout(reconnectTimer);
|
|
133
180
|
reconnectTimer = null;
|
|
134
181
|
}
|
|
182
|
+
if (stableTimer) {
|
|
183
|
+
clearTimeout(stableTimer);
|
|
184
|
+
stableTimer = null;
|
|
185
|
+
}
|
|
135
186
|
reconnectAttempts = 0;
|
|
136
187
|
ensureSocket();
|
|
137
188
|
}
|
|
138
|
-
const fileUrl = (path) => new URL(path,
|
|
189
|
+
const fileUrl = (path) => new URL(path, baseUrl).toString();
|
|
139
190
|
async function upload(uploadUrl, body, o) {
|
|
140
191
|
const headers = {};
|
|
141
192
|
if (o?.contentType)
|
|
142
193
|
headers["content-type"] = o.contentType;
|
|
143
194
|
const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
|
|
144
195
|
const j = (await res.json().catch(() => ({})));
|
|
145
|
-
if (!res.ok || j.ok
|
|
196
|
+
if (!res.ok || j.ok !== true) {
|
|
146
197
|
throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
|
|
147
198
|
}
|
|
148
199
|
return j.result;
|
|
@@ -159,6 +210,7 @@ export function createClient(opts) {
|
|
|
159
210
|
input,
|
|
160
211
|
onData: handlers.onData,
|
|
161
212
|
onError: handlers.onError,
|
|
213
|
+
onConnectionError: handlers.onConnectionError,
|
|
162
214
|
};
|
|
163
215
|
subs.set(id, sub);
|
|
164
216
|
if (ws && ws.readyState === 1) {
|
|
@@ -182,6 +234,8 @@ export function createClient(opts) {
|
|
|
182
234
|
subs.clear();
|
|
183
235
|
if (reconnectTimer)
|
|
184
236
|
clearTimeout(reconnectTimer);
|
|
237
|
+
if (stableTimer)
|
|
238
|
+
clearTimeout(stableTimer);
|
|
185
239
|
if (ws) {
|
|
186
240
|
try {
|
|
187
241
|
ws.close();
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -34,11 +34,22 @@ export interface ClientOptions {
|
|
|
34
34
|
/** Override for non-browser environments (defaults to globals). */
|
|
35
35
|
WebSocketImpl?: typeof WebSocket;
|
|
36
36
|
fetchImpl?: typeof fetch;
|
|
37
|
+
/** Give up reconnecting the live socket after this many consecutive failed
|
|
38
|
+
* attempts and surface a connection error to every subscriber (default 8). */
|
|
39
|
+
maxReconnectAttempts?: number;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export interface SubHandlers<T> {
|
|
40
43
|
onData: (result: T) => void;
|
|
44
|
+
/** Called when the server rejects THIS subscription (a `{type:"error"}` frame). */
|
|
41
45
|
onError?: (err: { error: string; code: string }) => void;
|
|
46
|
+
/** Called when the underlying live connection itself fails — no WebSocket
|
|
47
|
+
* implementation is available, or the socket has closed and stayed down past
|
|
48
|
+
* `maxReconnectAttempts`. Distinct from `onError` (a per-subscription server error):
|
|
49
|
+
* this is a transport-level failure that affects every sub on the connection.
|
|
50
|
+
* Optional and additive — omitting it preserves the prior (silent) behavior for
|
|
51
|
+
* existing callers. */
|
|
52
|
+
onConnectionError?: (err: { error: string; code: string }) => void;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
/** Result of a file upload (the persisted blob's storage metadata). */
|
|
@@ -72,11 +83,17 @@ interface Sub {
|
|
|
72
83
|
input: unknown;
|
|
73
84
|
onData: (result: unknown) => void;
|
|
74
85
|
onError?: (err: { error: string; code: string }) => void;
|
|
86
|
+
onConnectionError?: (err: { error: string; code: string }) => void;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
export function createClient<Api = Record<string, never>>(opts: ClientOptions): PramenClient<Api> {
|
|
78
90
|
const doFetch = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
79
91
|
const WS = opts.WebSocketImpl ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
|
|
92
|
+
// Normalize the base url: strip any trailing slash so `${base}/rpc/...` can't become
|
|
93
|
+
// `//rpc/...` (which the Worker serves as a plain-text help page — a 200 that isn't an
|
|
94
|
+
// envelope, silently resolving `call()` to undefined for every RPC).
|
|
95
|
+
const baseUrl = opts.url.replace(/\/+$/, "");
|
|
96
|
+
const maxReconnectAttempts = opts.maxReconnectAttempts ?? 8;
|
|
80
97
|
let token = opts.token;
|
|
81
98
|
|
|
82
99
|
// D1 read-your-writes: when the backend runs on the D1 store it returns an
|
|
@@ -91,30 +108,39 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
91
108
|
let counter = 0;
|
|
92
109
|
let reconnectAttempts = 0;
|
|
93
110
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
|
+
let stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
94
112
|
let closed = false;
|
|
113
|
+
// How long a connection must stay open before we trust it and reset the backoff.
|
|
114
|
+
const STABLE_MS = 3000;
|
|
95
115
|
|
|
96
116
|
async function call(name: string, input?: unknown): Promise<unknown> {
|
|
97
117
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
98
118
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
99
119
|
if (opts.tenant) headers["x-pramen-tenant"] = opts.tenant;
|
|
100
120
|
if (d1Bookmark) headers["x-pramen-d1-bookmark"] = d1Bookmark;
|
|
101
|
-
const res = await doFetch(`${
|
|
121
|
+
const res = await doFetch(`${baseUrl}/rpc/${name}`, {
|
|
102
122
|
method: "POST",
|
|
103
123
|
headers,
|
|
104
124
|
body: JSON.stringify(input ?? {}),
|
|
105
125
|
});
|
|
106
126
|
// Capture the D1 read-your-writes bookmark (D1 store only; absent on the DO path).
|
|
127
|
+
// Keep the MAXIMUM bookmark seen — D1 session bookmarks are lexicographically
|
|
128
|
+
// ordered, so a slower earlier response arriving after a mutation must not clobber a
|
|
129
|
+
// newer one and regress read-your-writes.
|
|
107
130
|
const bookmark = res.headers.get("x-pramen-d1-bookmark");
|
|
108
|
-
if (bookmark) d1Bookmark = bookmark;
|
|
131
|
+
if (bookmark && (!d1Bookmark || bookmark > d1Bookmark)) d1Bookmark = bookmark;
|
|
109
132
|
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string; code?: string };
|
|
110
|
-
|
|
133
|
+
// Require the success envelope explicitly: only `{ ok: true }` is a success. Any
|
|
134
|
+
// other 2xx body (e.g. a non-JSON help page parsed to `{}`, or `ok` absent) is an
|
|
135
|
+
// error, so a call never silently resolves undefined.
|
|
136
|
+
if (!res.ok || body.ok !== true) {
|
|
111
137
|
throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
|
|
112
138
|
}
|
|
113
139
|
return body.result;
|
|
114
140
|
}
|
|
115
141
|
|
|
116
142
|
function liveUrl(): string {
|
|
117
|
-
const u = new URL(
|
|
143
|
+
const u = new URL(baseUrl);
|
|
118
144
|
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
119
145
|
u.pathname = "/live";
|
|
120
146
|
if (opts.tenant) u.searchParams.set("tenant", opts.tenant);
|
|
@@ -122,12 +148,31 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
122
148
|
return u.toString();
|
|
123
149
|
}
|
|
124
150
|
|
|
151
|
+
/** Surface a transport-level failure (no WS impl, or reconnects exhausted) to every
|
|
152
|
+
* subscriber so a `useLiveQuery` can leave its loading state instead of hanging. */
|
|
153
|
+
function reportConnectionError(error: string, code: string): void {
|
|
154
|
+
for (const sub of subs.values()) sub.onConnectionError?.({ error, code });
|
|
155
|
+
}
|
|
156
|
+
|
|
125
157
|
function ensureSocket(): void {
|
|
126
|
-
if (closed || ws ||
|
|
158
|
+
if (closed || ws || subs.size === 0) return;
|
|
159
|
+
if (!WS) {
|
|
160
|
+
// No WebSocket implementation in this environment — the subscription can never
|
|
161
|
+
// fire. Surface it instead of returning silently (the old hang-forever behavior).
|
|
162
|
+
reportConnectionError("no WebSocket implementation available", "no_websocket");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
127
165
|
const socket = new WS(liveUrl());
|
|
128
166
|
ws = socket;
|
|
129
167
|
socket.addEventListener("open", () => {
|
|
130
|
-
|
|
168
|
+
// Don't reset the backoff on open alone: an accept-then-immediately-close server
|
|
169
|
+
// would otherwise hot-loop with no backoff growth. Only clear it once the
|
|
170
|
+
// connection has stayed up (stableTimer below).
|
|
171
|
+
if (stableTimer) clearTimeout(stableTimer);
|
|
172
|
+
stableTimer = setTimeout(() => {
|
|
173
|
+
stableTimer = null;
|
|
174
|
+
if (ws === socket) reconnectAttempts = 0;
|
|
175
|
+
}, STABLE_MS);
|
|
131
176
|
for (const sub of subs.values()) {
|
|
132
177
|
socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
|
|
133
178
|
}
|
|
@@ -146,6 +191,10 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
146
191
|
});
|
|
147
192
|
socket.addEventListener("close", () => {
|
|
148
193
|
if (ws === socket) ws = null;
|
|
194
|
+
if (stableTimer) {
|
|
195
|
+
clearTimeout(stableTimer);
|
|
196
|
+
stableTimer = null;
|
|
197
|
+
}
|
|
149
198
|
scheduleReconnect();
|
|
150
199
|
});
|
|
151
200
|
socket.addEventListener("error", () => {
|
|
@@ -159,7 +208,15 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
159
208
|
|
|
160
209
|
function scheduleReconnect(): void {
|
|
161
210
|
if (closed || reconnectTimer || subs.size === 0) return;
|
|
162
|
-
|
|
211
|
+
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
212
|
+
// Give up rather than reconnect forever at the 10s cap — a rejected upgrade (e.g.
|
|
213
|
+
// auth 403) would otherwise spin silently behind a permanent spinner.
|
|
214
|
+
reportConnectionError(`live connection failed after ${reconnectAttempts} attempts`, "connection_failed");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Exponential backoff with jitter (so a fleet of clients doesn't reconnect in lockstep).
|
|
218
|
+
const base = Math.min(500 * 2 ** reconnectAttempts, 10_000);
|
|
219
|
+
const delay = base / 2 + Math.random() * (base / 2);
|
|
163
220
|
reconnectAttempts++;
|
|
164
221
|
reconnectTimer = setTimeout(() => {
|
|
165
222
|
reconnectTimer = null;
|
|
@@ -180,18 +237,22 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
180
237
|
clearTimeout(reconnectTimer);
|
|
181
238
|
reconnectTimer = null;
|
|
182
239
|
}
|
|
240
|
+
if (stableTimer) {
|
|
241
|
+
clearTimeout(stableTimer);
|
|
242
|
+
stableTimer = null;
|
|
243
|
+
}
|
|
183
244
|
reconnectAttempts = 0;
|
|
184
245
|
ensureSocket();
|
|
185
246
|
}
|
|
186
247
|
|
|
187
|
-
const fileUrl = (path: string): string => new URL(path,
|
|
248
|
+
const fileUrl = (path: string): string => new URL(path, baseUrl).toString();
|
|
188
249
|
|
|
189
250
|
async function upload(uploadUrl: string, body: BodyInit, o?: { contentType?: string }): Promise<UploadResult> {
|
|
190
251
|
const headers: Record<string, string> = {};
|
|
191
252
|
if (o?.contentType) headers["content-type"] = o.contentType;
|
|
192
253
|
const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
|
|
193
254
|
const j = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: UploadResult; error?: string; code?: string };
|
|
194
|
-
if (!res.ok || j.ok
|
|
255
|
+
if (!res.ok || j.ok !== true) {
|
|
195
256
|
throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
|
|
196
257
|
}
|
|
197
258
|
return j.result as UploadResult;
|
|
@@ -209,6 +270,7 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
209
270
|
input,
|
|
210
271
|
onData: handlers.onData as (r: unknown) => void,
|
|
211
272
|
onError: handlers.onError,
|
|
273
|
+
onConnectionError: handlers.onConnectionError,
|
|
212
274
|
};
|
|
213
275
|
subs.set(id, sub);
|
|
214
276
|
if (ws && ws.readyState === 1) {
|
|
@@ -229,6 +291,7 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
229
291
|
closed = true;
|
|
230
292
|
subs.clear();
|
|
231
293
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
294
|
+
if (stableTimer) clearTimeout(stableTimer);
|
|
232
295
|
if (ws) {
|
|
233
296
|
try {
|
|
234
297
|
ws.close();
|