@pramen/client 0.0.13 → 0.0.15
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 +74 -8
- package/package.json +1 -1
- package/src/index.ts +82 -8
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,32 +21,58 @@ 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;
|
|
30
|
+
// D1 read-your-writes: when the backend runs on the D1 store it returns an
|
|
31
|
+
// `x-pramen-d1-bookmark` header marking the latest write this client has observed.
|
|
32
|
+
// We stash it and echo it on the next request so a fresh D1 session anchors there and
|
|
33
|
+
// reads our own writes (even off a lagging replica). The DO path never sets the
|
|
34
|
+
// header, so `d1Bookmark` stays undefined there and nothing changes.
|
|
35
|
+
let d1Bookmark;
|
|
25
36
|
const subs = new Map();
|
|
26
37
|
let ws = null;
|
|
27
38
|
let counter = 0;
|
|
28
39
|
let reconnectAttempts = 0;
|
|
29
40
|
let reconnectTimer = null;
|
|
41
|
+
let stableTimer = null;
|
|
30
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;
|
|
31
45
|
async function call(name, input) {
|
|
32
46
|
const headers = { "content-type": "application/json" };
|
|
33
47
|
if (token)
|
|
34
48
|
headers.authorization = `Bearer ${token}`;
|
|
35
49
|
if (opts.tenant)
|
|
36
50
|
headers["x-pramen-tenant"] = opts.tenant;
|
|
37
|
-
|
|
51
|
+
if (d1Bookmark)
|
|
52
|
+
headers["x-pramen-d1-bookmark"] = d1Bookmark;
|
|
53
|
+
const res = await doFetch(`${baseUrl}/rpc/${name}`, {
|
|
38
54
|
method: "POST",
|
|
39
55
|
headers,
|
|
40
56
|
body: JSON.stringify(input ?? {}),
|
|
41
57
|
});
|
|
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.
|
|
62
|
+
const bookmark = res.headers.get("x-pramen-d1-bookmark");
|
|
63
|
+
if (bookmark && (!d1Bookmark || bookmark > d1Bookmark))
|
|
64
|
+
d1Bookmark = bookmark;
|
|
42
65
|
const body = (await res.json().catch(() => ({})));
|
|
43
|
-
|
|
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) {
|
|
44
70
|
throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
|
|
45
71
|
}
|
|
46
72
|
return body.result;
|
|
47
73
|
}
|
|
48
74
|
function liveUrl() {
|
|
49
|
-
const u = new URL(
|
|
75
|
+
const u = new URL(baseUrl);
|
|
50
76
|
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
51
77
|
u.pathname = "/live";
|
|
52
78
|
if (opts.tenant)
|
|
@@ -55,13 +81,34 @@ export function createClient(opts) {
|
|
|
55
81
|
u.searchParams.set("token", token);
|
|
56
82
|
return u.toString();
|
|
57
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
|
+
}
|
|
58
90
|
function ensureSocket() {
|
|
59
|
-
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");
|
|
60
97
|
return;
|
|
98
|
+
}
|
|
61
99
|
const socket = new WS(liveUrl());
|
|
62
100
|
ws = socket;
|
|
63
101
|
socket.addEventListener("open", () => {
|
|
64
|
-
|
|
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);
|
|
65
112
|
for (const sub of subs.values()) {
|
|
66
113
|
socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
|
|
67
114
|
}
|
|
@@ -85,6 +132,10 @@ export function createClient(opts) {
|
|
|
85
132
|
socket.addEventListener("close", () => {
|
|
86
133
|
if (ws === socket)
|
|
87
134
|
ws = null;
|
|
135
|
+
if (stableTimer) {
|
|
136
|
+
clearTimeout(stableTimer);
|
|
137
|
+
stableTimer = null;
|
|
138
|
+
}
|
|
88
139
|
scheduleReconnect();
|
|
89
140
|
});
|
|
90
141
|
socket.addEventListener("error", () => {
|
|
@@ -99,7 +150,15 @@ export function createClient(opts) {
|
|
|
99
150
|
function scheduleReconnect() {
|
|
100
151
|
if (closed || reconnectTimer || subs.size === 0)
|
|
101
152
|
return;
|
|
102
|
-
|
|
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);
|
|
103
162
|
reconnectAttempts++;
|
|
104
163
|
reconnectTimer = setTimeout(() => {
|
|
105
164
|
reconnectTimer = null;
|
|
@@ -120,17 +179,21 @@ export function createClient(opts) {
|
|
|
120
179
|
clearTimeout(reconnectTimer);
|
|
121
180
|
reconnectTimer = null;
|
|
122
181
|
}
|
|
182
|
+
if (stableTimer) {
|
|
183
|
+
clearTimeout(stableTimer);
|
|
184
|
+
stableTimer = null;
|
|
185
|
+
}
|
|
123
186
|
reconnectAttempts = 0;
|
|
124
187
|
ensureSocket();
|
|
125
188
|
}
|
|
126
|
-
const fileUrl = (path) => new URL(path,
|
|
189
|
+
const fileUrl = (path) => new URL(path, baseUrl).toString();
|
|
127
190
|
async function upload(uploadUrl, body, o) {
|
|
128
191
|
const headers = {};
|
|
129
192
|
if (o?.contentType)
|
|
130
193
|
headers["content-type"] = o.contentType;
|
|
131
194
|
const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
|
|
132
195
|
const j = (await res.json().catch(() => ({})));
|
|
133
|
-
if (!res.ok || j.ok
|
|
196
|
+
if (!res.ok || j.ok !== true) {
|
|
134
197
|
throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
|
|
135
198
|
}
|
|
136
199
|
return j.result;
|
|
@@ -147,6 +210,7 @@ export function createClient(opts) {
|
|
|
147
210
|
input,
|
|
148
211
|
onData: handlers.onData,
|
|
149
212
|
onError: handlers.onError,
|
|
213
|
+
onConnectionError: handlers.onConnectionError,
|
|
150
214
|
};
|
|
151
215
|
subs.set(id, sub);
|
|
152
216
|
if (ws && ws.readyState === 1) {
|
|
@@ -170,6 +234,8 @@ export function createClient(opts) {
|
|
|
170
234
|
subs.clear();
|
|
171
235
|
if (reconnectTimer)
|
|
172
236
|
clearTimeout(reconnectTimer);
|
|
237
|
+
if (stableTimer)
|
|
238
|
+
clearTimeout(stableTimer);
|
|
173
239
|
if (ws) {
|
|
174
240
|
try {
|
|
175
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,38 +83,64 @@ 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
|
|
|
99
|
+
// D1 read-your-writes: when the backend runs on the D1 store it returns an
|
|
100
|
+
// `x-pramen-d1-bookmark` header marking the latest write this client has observed.
|
|
101
|
+
// We stash it and echo it on the next request so a fresh D1 session anchors there and
|
|
102
|
+
// reads our own writes (even off a lagging replica). The DO path never sets the
|
|
103
|
+
// header, so `d1Bookmark` stays undefined there and nothing changes.
|
|
104
|
+
let d1Bookmark: string | undefined;
|
|
105
|
+
|
|
82
106
|
const subs = new Map<string, Sub>();
|
|
83
107
|
let ws: WebSocket | null = null;
|
|
84
108
|
let counter = 0;
|
|
85
109
|
let reconnectAttempts = 0;
|
|
86
110
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
|
+
let stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
87
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;
|
|
88
115
|
|
|
89
116
|
async function call(name: string, input?: unknown): Promise<unknown> {
|
|
90
117
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
91
118
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
92
119
|
if (opts.tenant) headers["x-pramen-tenant"] = opts.tenant;
|
|
93
|
-
|
|
120
|
+
if (d1Bookmark) headers["x-pramen-d1-bookmark"] = d1Bookmark;
|
|
121
|
+
const res = await doFetch(`${baseUrl}/rpc/${name}`, {
|
|
94
122
|
method: "POST",
|
|
95
123
|
headers,
|
|
96
124
|
body: JSON.stringify(input ?? {}),
|
|
97
125
|
});
|
|
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.
|
|
130
|
+
const bookmark = res.headers.get("x-pramen-d1-bookmark");
|
|
131
|
+
if (bookmark && (!d1Bookmark || bookmark > d1Bookmark)) d1Bookmark = bookmark;
|
|
98
132
|
const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string; code?: string };
|
|
99
|
-
|
|
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) {
|
|
100
137
|
throw new PramenError(body.error ?? `request failed (${res.status})`, body.code ?? "error", res.status);
|
|
101
138
|
}
|
|
102
139
|
return body.result;
|
|
103
140
|
}
|
|
104
141
|
|
|
105
142
|
function liveUrl(): string {
|
|
106
|
-
const u = new URL(
|
|
143
|
+
const u = new URL(baseUrl);
|
|
107
144
|
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
108
145
|
u.pathname = "/live";
|
|
109
146
|
if (opts.tenant) u.searchParams.set("tenant", opts.tenant);
|
|
@@ -111,12 +148,31 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
111
148
|
return u.toString();
|
|
112
149
|
}
|
|
113
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
|
+
|
|
114
157
|
function ensureSocket(): void {
|
|
115
|
-
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
|
+
}
|
|
116
165
|
const socket = new WS(liveUrl());
|
|
117
166
|
ws = socket;
|
|
118
167
|
socket.addEventListener("open", () => {
|
|
119
|
-
|
|
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);
|
|
120
176
|
for (const sub of subs.values()) {
|
|
121
177
|
socket.send(JSON.stringify({ type: "subscribe", id: sub.id, name: sub.name, input: sub.input }));
|
|
122
178
|
}
|
|
@@ -135,6 +191,10 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
135
191
|
});
|
|
136
192
|
socket.addEventListener("close", () => {
|
|
137
193
|
if (ws === socket) ws = null;
|
|
194
|
+
if (stableTimer) {
|
|
195
|
+
clearTimeout(stableTimer);
|
|
196
|
+
stableTimer = null;
|
|
197
|
+
}
|
|
138
198
|
scheduleReconnect();
|
|
139
199
|
});
|
|
140
200
|
socket.addEventListener("error", () => {
|
|
@@ -148,7 +208,15 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
148
208
|
|
|
149
209
|
function scheduleReconnect(): void {
|
|
150
210
|
if (closed || reconnectTimer || subs.size === 0) return;
|
|
151
|
-
|
|
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);
|
|
152
220
|
reconnectAttempts++;
|
|
153
221
|
reconnectTimer = setTimeout(() => {
|
|
154
222
|
reconnectTimer = null;
|
|
@@ -169,18 +237,22 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
169
237
|
clearTimeout(reconnectTimer);
|
|
170
238
|
reconnectTimer = null;
|
|
171
239
|
}
|
|
240
|
+
if (stableTimer) {
|
|
241
|
+
clearTimeout(stableTimer);
|
|
242
|
+
stableTimer = null;
|
|
243
|
+
}
|
|
172
244
|
reconnectAttempts = 0;
|
|
173
245
|
ensureSocket();
|
|
174
246
|
}
|
|
175
247
|
|
|
176
|
-
const fileUrl = (path: string): string => new URL(path,
|
|
248
|
+
const fileUrl = (path: string): string => new URL(path, baseUrl).toString();
|
|
177
249
|
|
|
178
250
|
async function upload(uploadUrl: string, body: BodyInit, o?: { contentType?: string }): Promise<UploadResult> {
|
|
179
251
|
const headers: Record<string, string> = {};
|
|
180
252
|
if (o?.contentType) headers["content-type"] = o.contentType;
|
|
181
253
|
const res = await doFetch(fileUrl(uploadUrl), { method: "PUT", headers, body });
|
|
182
254
|
const j = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: UploadResult; error?: string; code?: string };
|
|
183
|
-
if (!res.ok || j.ok
|
|
255
|
+
if (!res.ok || j.ok !== true) {
|
|
184
256
|
throw new PramenError(j.error ?? `upload failed (${res.status})`, j.code ?? "error", res.status);
|
|
185
257
|
}
|
|
186
258
|
return j.result as UploadResult;
|
|
@@ -198,6 +270,7 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
198
270
|
input,
|
|
199
271
|
onData: handlers.onData as (r: unknown) => void,
|
|
200
272
|
onError: handlers.onError,
|
|
273
|
+
onConnectionError: handlers.onConnectionError,
|
|
201
274
|
};
|
|
202
275
|
subs.set(id, sub);
|
|
203
276
|
if (ws && ws.readyState === 1) {
|
|
@@ -218,6 +291,7 @@ export function createClient<Api = Record<string, never>>(opts: ClientOptions):
|
|
|
218
291
|
closed = true;
|
|
219
292
|
subs.clear();
|
|
220
293
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
294
|
+
if (stableTimer) clearTimeout(stableTimer);
|
|
221
295
|
if (ws) {
|
|
222
296
|
try {
|
|
223
297
|
ws.close();
|