wscall-client 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/package.json +58 -0
- package/src/client.js +874 -0
- package/src/index.js +50 -0
- package/src/protocol.js +548 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WscallClient - High-performance WebSocket RPC client for the WSCALL framework.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Binary frame protocol with optional ChaCha20/AES-256-GCM encryption
|
|
6
|
+
* - Automatic reconnection with exponential backoff + jitter
|
|
7
|
+
* - Heartbeat keep-alive (ping/pong)
|
|
8
|
+
* - Concurrent request/response correlation via pending maps
|
|
9
|
+
* - Event subscription with concurrent handler dispatch
|
|
10
|
+
* - Event ACK support
|
|
11
|
+
* - Connection lifecycle hooks
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
FrameCodec,
|
|
16
|
+
EncryptionKind,
|
|
17
|
+
MessageType,
|
|
18
|
+
K_API_RESPONSE,
|
|
19
|
+
K_EVENT_EMIT,
|
|
20
|
+
K_EVENT_ACK,
|
|
21
|
+
buildApiRequest,
|
|
22
|
+
buildEventEmit,
|
|
23
|
+
buildEventAck,
|
|
24
|
+
ProtocolError,
|
|
25
|
+
generateEcdhKeypair,
|
|
26
|
+
parsePeerPublic,
|
|
27
|
+
deriveEcdhSessionKey,
|
|
28
|
+
} from './protocol.js';
|
|
29
|
+
|
|
30
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
33
|
+
const IDLE_TIMEOUT_MS = 45_000;
|
|
34
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
35
|
+
const RECONNECT_BASE_DELAY_MS = 3_000;
|
|
36
|
+
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export class ClientError extends Error {
|
|
41
|
+
constructor(message, code = 'CLIENT_ERROR', details = undefined) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'ClientError';
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.details = details;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static disconnected() {
|
|
49
|
+
return new ClientError('Client disconnected', 'DISCONNECTED');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static timeout() {
|
|
53
|
+
return new ClientError('Request timed out', 'TIMEOUT');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static connectionClosed(reason) {
|
|
57
|
+
return new ClientError(`Connection closed: ${reason}`, 'CONNECTION_CLOSED');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static remote(errorPayload) {
|
|
61
|
+
return new ClientError(
|
|
62
|
+
errorPayload?.message || 'Remote error',
|
|
63
|
+
errorPayload?.code || 'REMOTE_ERROR',
|
|
64
|
+
errorPayload
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Pending Request Tracker ──────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Lock-free pending map using native Map for O(1) insert/delete/lookup.
|
|
73
|
+
* Each entry holds { resolve, reject, timer } for timeout management.
|
|
74
|
+
*/
|
|
75
|
+
class PendingMap {
|
|
76
|
+
#map = new Map();
|
|
77
|
+
|
|
78
|
+
get size() { return this.#map.size; }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register a pending request. Returns a Promise that resolves/rejects
|
|
82
|
+
* when the response arrives or times out.
|
|
83
|
+
*/
|
|
84
|
+
register(id, timeoutMs) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
this.#map.delete(id);
|
|
88
|
+
reject(ClientError.timeout());
|
|
89
|
+
}, timeoutMs);
|
|
90
|
+
// Unref timer in Node.js so it doesn't keep the process alive
|
|
91
|
+
if (timer.unref) timer.unref();
|
|
92
|
+
this.#map.set(id, { resolve, reject, timer });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Resolve a pending request with a value. */
|
|
97
|
+
resolve(id, value) {
|
|
98
|
+
const entry = this.#map.get(id);
|
|
99
|
+
if (entry) {
|
|
100
|
+
clearTimeout(entry.timer);
|
|
101
|
+
this.#map.delete(id);
|
|
102
|
+
entry.resolve(value);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Reject a pending request with an error. */
|
|
109
|
+
reject(id, error) {
|
|
110
|
+
const entry = this.#map.get(id);
|
|
111
|
+
if (entry) {
|
|
112
|
+
clearTimeout(entry.timer);
|
|
113
|
+
this.#map.delete(id);
|
|
114
|
+
entry.reject(error);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Drain all pending requests with an error (used on disconnect). */
|
|
121
|
+
drainAll(error) {
|
|
122
|
+
for (const [id, entry] of this.#map) {
|
|
123
|
+
clearTimeout(entry.timer);
|
|
124
|
+
entry.reject(error);
|
|
125
|
+
}
|
|
126
|
+
this.#map.clear();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── WscallClientConfig ───────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Configuration for establishing a WscallClient connection.
|
|
134
|
+
*
|
|
135
|
+
* Bundles all user-tunable connection parameters into a single value object.
|
|
136
|
+
* Use the static factory methods for common presets, or construct manually
|
|
137
|
+
* with the builder-style `with*` methods.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```js
|
|
141
|
+
* // ECDH dynamic key agreement (recommended default)
|
|
142
|
+
* const config = WscallClientConfig.ecdh();
|
|
143
|
+
*
|
|
144
|
+
* // Pre-shared ChaCha20 key
|
|
145
|
+
* const config = WscallClientConfig.pskChaCha20(key);
|
|
146
|
+
*
|
|
147
|
+
* // Plaintext (development only)
|
|
148
|
+
* const config = WscallClientConfig.plaintext();
|
|
149
|
+
*
|
|
150
|
+
* const client = await WscallClient.connect('ws://127.0.0.1:9001/socket', config);
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export class WscallClientConfig {
|
|
154
|
+
/** @type {Uint8Array|null} 32-byte ChaCha20-Poly1305 pre-shared key */
|
|
155
|
+
chacha20Key = null;
|
|
156
|
+
/** @type {Uint8Array|null} 32-byte AES-256-GCM pre-shared key */
|
|
157
|
+
aes256Key = null;
|
|
158
|
+
/** @type {boolean} Use ECDH dynamic key agreement */
|
|
159
|
+
useEcdh = true;
|
|
160
|
+
/** @type {boolean} Auto reconnect on unexpected disconnect */
|
|
161
|
+
autoReconnect = true;
|
|
162
|
+
/** @type {number} Default request timeout (ms) */
|
|
163
|
+
timeout = DEFAULT_TIMEOUT_MS;
|
|
164
|
+
/** @type {object} Default metadata sent with requests */
|
|
165
|
+
metadata = {};
|
|
166
|
+
/** @type {string[]} Failover server URLs tried when the primary is unreachable */
|
|
167
|
+
failoverUrls = [];
|
|
168
|
+
|
|
169
|
+
/** Creates a config with the recommended secure defaults (ECDH + ChaCha20 + auto-reconnect). */
|
|
170
|
+
static default() {
|
|
171
|
+
return new WscallClientConfig();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Convenience: ECDH dynamic key agreement (the default). */
|
|
175
|
+
static ecdh() {
|
|
176
|
+
return new WscallClientConfig();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Convenience: plaintext transport with no encryption (development only). */
|
|
180
|
+
static plaintext() {
|
|
181
|
+
const c = new WscallClientConfig();
|
|
182
|
+
c.useEcdh = false;
|
|
183
|
+
return c;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Convenience: pre-shared ChaCha20-Poly1305 key (no ECDH handshake). */
|
|
187
|
+
static pskChaCha20(key) {
|
|
188
|
+
const c = new WscallClientConfig();
|
|
189
|
+
c.chacha20Key = key;
|
|
190
|
+
c.useEcdh = false;
|
|
191
|
+
return c;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Convenience: pre-shared AES-256-GCM key (no ECDH handshake). */
|
|
195
|
+
static pskAes256(key) {
|
|
196
|
+
const c = new WscallClientConfig();
|
|
197
|
+
c.aes256Key = key;
|
|
198
|
+
c.useEcdh = false;
|
|
199
|
+
return c;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Builder: enable or disable ECDH. */
|
|
203
|
+
withUseEcdh(v) { this.useEcdh = v; return this; }
|
|
204
|
+
/** Builder: enable or disable auto-reconnect. */
|
|
205
|
+
withAutoReconnect(v) { this.autoReconnect = v; return this; }
|
|
206
|
+
/** Builder: set default timeout (ms). */
|
|
207
|
+
withTimeout(ms) { this.timeout = ms; return this; }
|
|
208
|
+
/** Builder: set default metadata. */
|
|
209
|
+
withMetadata(meta) { this.metadata = meta; return this; }
|
|
210
|
+
/** Builder: set a pre-shared ChaCha20 key (disables ECDH). */
|
|
211
|
+
withChaCha20Key(key) { this.chacha20Key = key; this.useEcdh = false; return this; }
|
|
212
|
+
/** Builder: set a pre-shared AES-256 key (disables ECDH). */
|
|
213
|
+
withAes256Key(key) { this.aes256Key = key; this.useEcdh = false; return this; }
|
|
214
|
+
/** Builder: append a single failover URL (can be called multiple times). */
|
|
215
|
+
withFailoverUrl(url) { this.failoverUrls.push(url); return this; }
|
|
216
|
+
/** Builder: set the full failover URL list (replaces any previously added). */
|
|
217
|
+
withFailoverUrls(urls) { this.failoverUrls = [...urls]; return this; }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── WscallClient ─────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
export class WscallClient {
|
|
223
|
+
/** @type {string[]} All candidate server URLs: [primary, ...failover] */
|
|
224
|
+
#urls;
|
|
225
|
+
/** @type {number} Index of the last successfully connected URL */
|
|
226
|
+
#lastUrlIdx = 0;
|
|
227
|
+
#codec;
|
|
228
|
+
#defaultEncryption;
|
|
229
|
+
#autoReconnect;
|
|
230
|
+
#defaultTimeout;
|
|
231
|
+
#metadata;
|
|
232
|
+
#useEcdh = false;
|
|
233
|
+
|
|
234
|
+
// Connection state
|
|
235
|
+
#ws = null;
|
|
236
|
+
#connected = false;
|
|
237
|
+
#shutdown = false;
|
|
238
|
+
#generation = 0;
|
|
239
|
+
#reconnectAttempt = 0;
|
|
240
|
+
#reconnectTimer = null;
|
|
241
|
+
#heartbeatTimer = null;
|
|
242
|
+
#idleTimer = null;
|
|
243
|
+
|
|
244
|
+
// Correlation
|
|
245
|
+
#pendingApi = new PendingMap();
|
|
246
|
+
#pendingEvent = new PendingMap();
|
|
247
|
+
#nextRequestId = 0;
|
|
248
|
+
#nextEventId = 0;
|
|
249
|
+
|
|
250
|
+
// Handlers
|
|
251
|
+
#eventHandlers = new Map(); // name -> [handler, ...]
|
|
252
|
+
#connectedHandlers = [];
|
|
253
|
+
#disconnectedHandlers = [];
|
|
254
|
+
|
|
255
|
+
// Write queue for batching sends
|
|
256
|
+
#sendQueue = [];
|
|
257
|
+
#flushScheduled = false;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @param {object} options
|
|
261
|
+
* @param {string} options.url - Primary WebSocket URL (ws:// or wss://)
|
|
262
|
+
* @param {string[]} [options.failoverUrls] - Backup URLs for failover
|
|
263
|
+
* @param {Uint8Array} [options.chacha20Key] - 32-byte ChaCha20-Poly1305 key
|
|
264
|
+
* @param {Uint8Array} [options.aes256Key] - 32-byte AES-256-GCM key
|
|
265
|
+
* @param {boolean} [options.useEcdh=false] - Use ECDH dynamic key agreement
|
|
266
|
+
* @param {boolean} [options.autoReconnect=true] - Auto reconnect on disconnect
|
|
267
|
+
* @param {number} [options.timeout=10000] - Default request timeout (ms)
|
|
268
|
+
* @param {object} [options.metadata] - Default metadata sent with requests
|
|
269
|
+
*/
|
|
270
|
+
constructor(options = {}) {
|
|
271
|
+
const { url, failoverUrls = [], chacha20Key, aes256Key, useEcdh = false, autoReconnect = true, timeout = DEFAULT_TIMEOUT_MS, metadata = {} } = options;
|
|
272
|
+
|
|
273
|
+
if (!url) throw new Error('url is required');
|
|
274
|
+
|
|
275
|
+
this.#urls = [url, ...failoverUrls];
|
|
276
|
+
this.#autoReconnect = autoReconnect;
|
|
277
|
+
this.#defaultTimeout = timeout;
|
|
278
|
+
this.#metadata = metadata;
|
|
279
|
+
this.#useEcdh = useEcdh;
|
|
280
|
+
|
|
281
|
+
this.#codec = new FrameCodec();
|
|
282
|
+
if (useEcdh) {
|
|
283
|
+
// Codec will be configured with the session key after the handshake.
|
|
284
|
+
this.#defaultEncryption = EncryptionKind.ChaCha20;
|
|
285
|
+
} else if (chacha20Key) {
|
|
286
|
+
this.#codec.withChaCha20Key(chacha20Key);
|
|
287
|
+
this.#defaultEncryption = EncryptionKind.ChaCha20;
|
|
288
|
+
} else if (aes256Key) {
|
|
289
|
+
this.#codec.withAes256Key(aes256Key);
|
|
290
|
+
this.#defaultEncryption = EncryptionKind.Aes256;
|
|
291
|
+
} else {
|
|
292
|
+
this.#defaultEncryption = EncryptionKind.None;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── Static Connectors ──────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Connect to a WSCALL server with the given configuration.
|
|
300
|
+
*
|
|
301
|
+
* This is the unified entry point. Pass a {@link WscallClientConfig} for
|
|
302
|
+
* full control, or a plain options object for backward compatibility.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} url - WebSocket URL (ws:// or wss://)
|
|
305
|
+
* @param {WscallClientConfig|object} [config] - Connection configuration
|
|
306
|
+
* @returns {Promise<WscallClient>}
|
|
307
|
+
*/
|
|
308
|
+
static async connect(url, config = {}) {
|
|
309
|
+
let options;
|
|
310
|
+
if (config instanceof WscallClientConfig) {
|
|
311
|
+
options = {
|
|
312
|
+
url,
|
|
313
|
+
failoverUrls: config.failoverUrls,
|
|
314
|
+
chacha20Key: config.chacha20Key,
|
|
315
|
+
aes256Key: config.aes256Key,
|
|
316
|
+
useEcdh: config.useEcdh,
|
|
317
|
+
autoReconnect: config.autoReconnect,
|
|
318
|
+
timeout: config.timeout,
|
|
319
|
+
metadata: config.metadata,
|
|
320
|
+
};
|
|
321
|
+
} else {
|
|
322
|
+
options = { url, ...config };
|
|
323
|
+
}
|
|
324
|
+
const client = new WscallClient(options);
|
|
325
|
+
await client.connect();
|
|
326
|
+
return client;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Connect with ChaCha20-Poly1305 encryption.
|
|
331
|
+
* @deprecated Use WscallClient.connect(url, WscallClientConfig.pskChaCha20(key))
|
|
332
|
+
*/
|
|
333
|
+
static async connectWithChaCha20(url, key, options = {}) {
|
|
334
|
+
return WscallClient.connect(url, WscallClientConfig.pskChaCha20(key).withMetadata(options.metadata || {}));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Connect with AES-256-GCM encryption.
|
|
339
|
+
* @deprecated Use WscallClient.connect(url, WscallClientConfig.pskAes256(key))
|
|
340
|
+
*/
|
|
341
|
+
static async connectWithAes256(url, key, options = {}) {
|
|
342
|
+
return WscallClient.connect(url, WscallClientConfig.pskAes256(key).withMetadata(options.metadata || {}));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Connect using ECDH dynamic key agreement (no pre-shared key needed).
|
|
347
|
+
* @deprecated Use WscallClient.connect(url, WscallClientConfig.ecdh())
|
|
348
|
+
*/
|
|
349
|
+
static async connectWithEcdh(url, options = {}) {
|
|
350
|
+
return WscallClient.connect(url, WscallClientConfig.ecdh().withMetadata(options.metadata || {}));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── Connection Management ──────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/** Establish the WebSocket connection. Resolves when connected. */
|
|
356
|
+
connect() {
|
|
357
|
+
if (this.#connected) return Promise.resolve();
|
|
358
|
+
this.#shutdown = false;
|
|
359
|
+
return this.#establishConnection();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Gracefully close the connection. */
|
|
363
|
+
close() {
|
|
364
|
+
this.#shutdown = true;
|
|
365
|
+
this.#clearTimers();
|
|
366
|
+
if (this.#reconnectTimer) {
|
|
367
|
+
clearTimeout(this.#reconnectTimer);
|
|
368
|
+
this.#reconnectTimer = null;
|
|
369
|
+
}
|
|
370
|
+
if (this.#ws) {
|
|
371
|
+
try { this.#ws.close(1000, 'client close'); } catch { /* ignore */ }
|
|
372
|
+
this.#ws = null;
|
|
373
|
+
}
|
|
374
|
+
this.#handleDisconnect('client closed', false);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Whether the client is currently connected. */
|
|
378
|
+
get isConnected() { return this.#connected; }
|
|
379
|
+
|
|
380
|
+
/** The URL of the last successfully connected server. */
|
|
381
|
+
get #currentUrl() { return this.#urls[this.#lastUrlIdx]; }
|
|
382
|
+
|
|
383
|
+
// ─── Event Registration ─────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Register an event handler. Multiple handlers per event name are supported.
|
|
387
|
+
* Handler receives an EventMessage and should return a receipt object.
|
|
388
|
+
* @param {string} name - Event name
|
|
389
|
+
* @param {(event: EventMessage) => object|Promise<object>} handler
|
|
390
|
+
*/
|
|
391
|
+
onEvent(name, handler) {
|
|
392
|
+
if (!this.#eventHandlers.has(name)) {
|
|
393
|
+
this.#eventHandlers.set(name, []);
|
|
394
|
+
}
|
|
395
|
+
this.#eventHandlers.get(name).push(handler);
|
|
396
|
+
return this;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Remove an event handler. */
|
|
400
|
+
offEvent(name, handler) {
|
|
401
|
+
const handlers = this.#eventHandlers.get(name);
|
|
402
|
+
if (handlers) {
|
|
403
|
+
const idx = handlers.indexOf(handler);
|
|
404
|
+
if (idx !== -1) handlers.splice(idx, 1);
|
|
405
|
+
if (handlers.length === 0) this.#eventHandlers.delete(name);
|
|
406
|
+
}
|
|
407
|
+
return this;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Register a connected lifecycle handler. */
|
|
411
|
+
onConnected(handler) {
|
|
412
|
+
this.#connectedHandlers.push(handler);
|
|
413
|
+
// If already connected, fire immediately
|
|
414
|
+
if (this.#connected) {
|
|
415
|
+
handler({ url: this.#currentUrl });
|
|
416
|
+
}
|
|
417
|
+
return this;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Register a disconnected lifecycle handler. */
|
|
421
|
+
onDisconnected(handler) {
|
|
422
|
+
this.#disconnectedHandlers.push(handler);
|
|
423
|
+
return this;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ─── RPC Calls ──────────────────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Make an API call (request/response).
|
|
430
|
+
* @param {string} route - API route name
|
|
431
|
+
* @param {object} [params={}] - Request parameters
|
|
432
|
+
* @param {Array} [attachments=[]] - File attachments
|
|
433
|
+
* @param {object} [options] - Per-call options
|
|
434
|
+
* @param {number} [options.timeout] - Override default timeout (ms)
|
|
435
|
+
* @param {object} [options.metadata] - Override default metadata
|
|
436
|
+
* @returns {Promise<object>} Response data
|
|
437
|
+
*/
|
|
438
|
+
async call(route, params = {}, attachments = [], options = {}) {
|
|
439
|
+
if (!this.#connected) throw ClientError.disconnected();
|
|
440
|
+
|
|
441
|
+
const requestId = ++this.#nextRequestId;
|
|
442
|
+
const timeout = options.timeout ?? this.#defaultTimeout;
|
|
443
|
+
const metadata = options.metadata ?? this.#metadata;
|
|
444
|
+
|
|
445
|
+
const body = buildApiRequest(requestId, route, params, attachments, metadata);
|
|
446
|
+
const promise = this.#pendingApi.register(requestId, timeout);
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
await this.#sendPacket(body);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
this.#pendingApi.reject(requestId, ClientError.disconnected());
|
|
452
|
+
throw err;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return promise;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Send an event and wait for ACK.
|
|
460
|
+
* @param {string} name - Event name
|
|
461
|
+
* @param {object} [data={}] - Event data
|
|
462
|
+
* @param {Array} [attachments=[]] - File attachments
|
|
463
|
+
* @param {object} [options] - Per-call options
|
|
464
|
+
* @returns {Promise<object>} ACK receipt
|
|
465
|
+
*/
|
|
466
|
+
async sendEvent(name, data = {}, attachments = [], options = {}) {
|
|
467
|
+
if (!this.#connected) throw ClientError.disconnected();
|
|
468
|
+
|
|
469
|
+
const eventId = ++this.#nextEventId;
|
|
470
|
+
const timeout = options.timeout ?? this.#defaultTimeout;
|
|
471
|
+
const metadata = options.metadata ?? this.#metadata;
|
|
472
|
+
|
|
473
|
+
const body = buildEventEmit(eventId, name, data, attachments, metadata, true);
|
|
474
|
+
const promise = this.#pendingEvent.register(eventId, timeout);
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
await this.#sendPacket(body);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
this.#pendingEvent.reject(eventId, ClientError.disconnected());
|
|
480
|
+
throw err;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return promise;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Internal: Connection ───────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
async #establishConnection() {
|
|
489
|
+
const generation = ++this.#generation;
|
|
490
|
+
const WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : (await import('ws')).default;
|
|
491
|
+
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
// Failover: iterate through candidate URLs starting from the last
|
|
494
|
+
// successful index (sticky failover).
|
|
495
|
+
const start = this.#lastUrlIdx;
|
|
496
|
+
const count = this.#urls.length;
|
|
497
|
+
let offset = 0;
|
|
498
|
+
let lastError = null;
|
|
499
|
+
|
|
500
|
+
const tryNext = () => {
|
|
501
|
+
if (this.#generation !== generation || this.#shutdown) {
|
|
502
|
+
reject(new ClientError('Connection cancelled', 'CANCELLED'));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (offset >= count) {
|
|
506
|
+
reject(lastError || new ClientError('All server URLs unreachable', 'ALL_UNREACHABLE'));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const idx = (start + offset) % count;
|
|
511
|
+
offset++;
|
|
512
|
+
const ws = new WebSocketImpl(this.#urls[idx]);
|
|
513
|
+
ws.binaryType = 'arraybuffer';
|
|
514
|
+
|
|
515
|
+
const onFail = (err) => {
|
|
516
|
+
ws.removeEventListener?.('open', onOpen);
|
|
517
|
+
ws.removeEventListener?.('error', onFail);
|
|
518
|
+
ws.onopen = null;
|
|
519
|
+
ws.onerror = null;
|
|
520
|
+
lastError = new ClientError(
|
|
521
|
+
`${this.#urls[idx]}: ${err?.message || 'connection failed'}`,
|
|
522
|
+
'CONNECTION_FAILED'
|
|
523
|
+
);
|
|
524
|
+
tryNext();
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const onOpen = () => {
|
|
528
|
+
ws.removeEventListener?.('open', onOpen);
|
|
529
|
+
ws.removeEventListener?.('error', onFail);
|
|
530
|
+
ws.onopen = null;
|
|
531
|
+
ws.onerror = null;
|
|
532
|
+
this.#lastUrlIdx = idx;
|
|
533
|
+
this.#setupConnection(ws, generation, resolve, reject);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
ws.addEventListener ? ws.addEventListener('open', onOpen) : (ws.onopen = onOpen);
|
|
537
|
+
ws.addEventListener ? ws.addEventListener('error', onFail) : (ws.onerror = onFail);
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
tryNext();
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Set up message/close/heartbeat handlers on an already-open WebSocket.
|
|
546
|
+
* Handles the optional ECDH handshake before marking the connection ready.
|
|
547
|
+
*/
|
|
548
|
+
#setupConnection(ws, generation, resolve, reject) {
|
|
549
|
+
this.#ws = ws;
|
|
550
|
+
|
|
551
|
+
// ECDH handshake state
|
|
552
|
+
let handshakePending = this.#useEcdh;
|
|
553
|
+
let handshakePrivateKey = null;
|
|
554
|
+
// Messages that arrive while the ECDH derivation is in-flight are
|
|
555
|
+
// buffered here and replayed once the session key is ready.
|
|
556
|
+
let handshakeBuffer = [];
|
|
557
|
+
|
|
558
|
+
const completeConnection = () => {
|
|
559
|
+
this.#connected = true;
|
|
560
|
+
this.#reconnectAttempt = 0;
|
|
561
|
+
this.#startHeartbeat();
|
|
562
|
+
this.#resetIdleTimer();
|
|
563
|
+
this.#emitConnected();
|
|
564
|
+
resolve();
|
|
565
|
+
// Replay any messages that arrived during the async derivation.
|
|
566
|
+
if (handshakeBuffer.length > 0) {
|
|
567
|
+
const buffered = handshakeBuffer;
|
|
568
|
+
handshakeBuffer = [];
|
|
569
|
+
for (const msg of buffered) {
|
|
570
|
+
this.#resetIdleTimer();
|
|
571
|
+
this.#handleInbound(msg);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const onOpenAsync = async () => {
|
|
577
|
+
if (this.#generation !== generation) return;
|
|
578
|
+
|
|
579
|
+
if (this.#useEcdh) {
|
|
580
|
+
// Generate X25519 keypair and send the client's public key.
|
|
581
|
+
try {
|
|
582
|
+
const { privateKey, publicKey } = await generateEcdhKeypair();
|
|
583
|
+
handshakePrivateKey = privateKey;
|
|
584
|
+
ws.send(publicKey);
|
|
585
|
+
// The server's public key will arrive as the first binary message.
|
|
586
|
+
// handshakePending stays true; onMessage will complete the handshake.
|
|
587
|
+
} catch (err) {
|
|
588
|
+
reject(new ClientError(`ECDH keypair generation failed: ${err.message}`, 'ECDH_ERROR'));
|
|
589
|
+
}
|
|
590
|
+
} else {
|
|
591
|
+
completeConnection();
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// The socket is already open (failover loop resolved on 'open'),
|
|
596
|
+
// so kick off the handshake / completion immediately.
|
|
597
|
+
onOpenAsync();
|
|
598
|
+
|
|
599
|
+
const onClose = (event) => {
|
|
600
|
+
// Clean up all listeners on close
|
|
601
|
+
ws.removeEventListener?.('close', onClose);
|
|
602
|
+
ws.removeEventListener?.('message', onMessage);
|
|
603
|
+
ws.removeEventListener?.('pong', onPong);
|
|
604
|
+
ws.onclose = null;
|
|
605
|
+
ws.onmessage = null;
|
|
606
|
+
if (this.#generation !== generation) return;
|
|
607
|
+
const wasConnected = this.#connected;
|
|
608
|
+
this.#handleDisconnect(
|
|
609
|
+
event?.reason || 'connection closed',
|
|
610
|
+
true
|
|
611
|
+
);
|
|
612
|
+
if (!wasConnected) {
|
|
613
|
+
reject(ClientError.connectionClosed(event?.reason || 'connection refused'));
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const onMessage = (event) => {
|
|
618
|
+
if (this.#generation !== generation) return;
|
|
619
|
+
// ws library: event may be the data directly or a MessageEvent-like object
|
|
620
|
+
const data = event?.data !== undefined ? event.data : event;
|
|
621
|
+
|
|
622
|
+
if (handshakePending === true) {
|
|
623
|
+
// The first 32-byte binary message is the server's X25519 public key.
|
|
624
|
+
// Start the async session key derivation. Any messages that arrive
|
|
625
|
+
// while the derivation is in-flight are buffered for replay.
|
|
626
|
+
const raw = new Uint8Array(data);
|
|
627
|
+
try {
|
|
628
|
+
const serverPublic = parsePeerPublic(raw);
|
|
629
|
+
handshakePending = 'deriving';
|
|
630
|
+
deriveEcdhSessionKey(handshakePrivateKey, serverPublic)
|
|
631
|
+
.then((sessionKey) => {
|
|
632
|
+
this.#codec = new FrameCodec();
|
|
633
|
+
this.#codec.withChaCha20Key(sessionKey);
|
|
634
|
+
this.#defaultEncryption = EncryptionKind.ChaCha20;
|
|
635
|
+
handshakePending = false;
|
|
636
|
+
completeConnection();
|
|
637
|
+
})
|
|
638
|
+
.catch((err) => {
|
|
639
|
+
reject(new ClientError(
|
|
640
|
+
`ECDH session key derivation failed: ${err.message}`,
|
|
641
|
+
'ECDH_ERROR'
|
|
642
|
+
));
|
|
643
|
+
});
|
|
644
|
+
} catch (err) {
|
|
645
|
+
reject(new ClientError(`ECDH handshake failed: ${err.message}`, 'ECDH_ERROR'));
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (handshakePending === 'deriving') {
|
|
651
|
+
handshakeBuffer.push(data);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
this.#resetIdleTimer();
|
|
656
|
+
this.#handleInbound(data);
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const onPong = () => {
|
|
660
|
+
this.#resetIdleTimer();
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
// Support both addEventListener (browser) and on* (Node ws)
|
|
664
|
+
if (ws.addEventListener) {
|
|
665
|
+
ws.addEventListener('close', onClose);
|
|
666
|
+
ws.addEventListener('message', onMessage);
|
|
667
|
+
ws.addEventListener('pong', onPong);
|
|
668
|
+
} else {
|
|
669
|
+
ws.onclose = onClose;
|
|
670
|
+
ws.onmessage = onMessage;
|
|
671
|
+
ws.onpong = onPong;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
#handleDisconnect(reason, willReconnect) {
|
|
676
|
+
if (!this.#connected && !this.#ws) return;
|
|
677
|
+
|
|
678
|
+
this.#connected = false;
|
|
679
|
+
this.#ws = null;
|
|
680
|
+
this.#clearTimers();
|
|
681
|
+
|
|
682
|
+
// Drain pending requests
|
|
683
|
+
const error = ClientError.connectionClosed(reason);
|
|
684
|
+
this.#pendingApi.drainAll(error);
|
|
685
|
+
this.#pendingEvent.drainAll(error);
|
|
686
|
+
|
|
687
|
+
// Notify disconnect handlers
|
|
688
|
+
const shouldReconnect = willReconnect && !this.#shutdown && this.#autoReconnect;
|
|
689
|
+
const event = {
|
|
690
|
+
url: this.#currentUrl,
|
|
691
|
+
reason,
|
|
692
|
+
willReconnect: shouldReconnect,
|
|
693
|
+
retryAfter: shouldReconnect ? this.#reconnectDelay(1) : null,
|
|
694
|
+
};
|
|
695
|
+
for (const handler of this.#disconnectedHandlers) {
|
|
696
|
+
try { handler(event); } catch { /* ignore handler errors */ }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Schedule reconnect
|
|
700
|
+
if (shouldReconnect) {
|
|
701
|
+
this.#scheduleReconnect();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
#scheduleReconnect() {
|
|
706
|
+
this.#reconnectAttempt++;
|
|
707
|
+
const delay = this.#reconnectDelay(this.#reconnectAttempt) + this.#reconnectJitter();
|
|
708
|
+
|
|
709
|
+
this.#reconnectTimer = setTimeout(async () => {
|
|
710
|
+
this.#reconnectTimer = null;
|
|
711
|
+
if (this.#shutdown) return;
|
|
712
|
+
try {
|
|
713
|
+
await this.#establishConnection();
|
|
714
|
+
} catch {
|
|
715
|
+
// Will be retried via handleDisconnect -> scheduleReconnect
|
|
716
|
+
if (!this.#shutdown && this.#autoReconnect) {
|
|
717
|
+
this.#scheduleReconnect();
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}, delay);
|
|
721
|
+
|
|
722
|
+
// Unref in Node.js
|
|
723
|
+
if (this.#reconnectTimer.unref) this.#reconnectTimer.unref();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
#reconnectDelay(attempt) {
|
|
727
|
+
const exp = Math.min(Math.max(attempt, 1) - 1, 6);
|
|
728
|
+
return Math.min(RECONNECT_BASE_DELAY_MS * (1 << exp), RECONNECT_MAX_DELAY_MS);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
#reconnectJitter() {
|
|
732
|
+
return Math.random() * (RECONNECT_BASE_DELAY_MS / 2);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ─── Internal: Heartbeat & Idle ─────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
#startHeartbeat() {
|
|
738
|
+
this.#clearTimers();
|
|
739
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
740
|
+
if (!this.#connected || !this.#ws) return;
|
|
741
|
+
try {
|
|
742
|
+
if (this.#ws.ping) {
|
|
743
|
+
this.#ws.ping(); // Node.js ws
|
|
744
|
+
} else {
|
|
745
|
+
// Browser: send a lightweight protocol-level ping isn't possible,
|
|
746
|
+
// but the server will handle WebSocket protocol pings.
|
|
747
|
+
// For browser, we rely on the idle timeout mechanism.
|
|
748
|
+
}
|
|
749
|
+
} catch { /* connection may have closed */ }
|
|
750
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
751
|
+
|
|
752
|
+
if (this.#heartbeatTimer.unref) this.#heartbeatTimer.unref();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
#resetIdleTimer() {
|
|
756
|
+
if (this.#idleTimer) clearTimeout(this.#idleTimer);
|
|
757
|
+
this.#idleTimer = setTimeout(() => {
|
|
758
|
+
if (this.#connected) {
|
|
759
|
+
this.#handleDisconnect('idle timeout', true);
|
|
760
|
+
if (this.#ws) {
|
|
761
|
+
try { this.#ws.close(4000, 'idle timeout'); } catch { /* ignore */ }
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}, IDLE_TIMEOUT_MS);
|
|
765
|
+
|
|
766
|
+
if (this.#idleTimer.unref) this.#idleTimer.unref();
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
#clearTimers() {
|
|
770
|
+
if (this.#heartbeatTimer) { clearInterval(this.#heartbeatTimer); this.#heartbeatTimer = null; }
|
|
771
|
+
if (this.#idleTimer) { clearTimeout(this.#idleTimer); this.#idleTimer = null; }
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ─── Internal: Send ─────────────────────────────────────────────────────────
|
|
775
|
+
|
|
776
|
+
async #sendPacket(body) {
|
|
777
|
+
if (!this.#ws || !this.#connected) throw ClientError.disconnected();
|
|
778
|
+
const frame = await this.#codec.encode(body);
|
|
779
|
+
this.#ws.send(frame);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ─── Internal: Inbound Processing ──────────────────────────────────────────
|
|
783
|
+
|
|
784
|
+
async #handleInbound(data) {
|
|
785
|
+
let packet;
|
|
786
|
+
try {
|
|
787
|
+
packet = await this.#codec.decode(data);
|
|
788
|
+
} catch (err) {
|
|
789
|
+
console.warn('[wscall] Failed to decode inbound frame:', err.message);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const { body } = packet;
|
|
794
|
+
const k = body.k;
|
|
795
|
+
|
|
796
|
+
switch (k) {
|
|
797
|
+
case K_API_RESPONSE:
|
|
798
|
+
this.#handleApiResponse(body);
|
|
799
|
+
break;
|
|
800
|
+
case K_EVENT_EMIT:
|
|
801
|
+
await this.#handleEventEmit(body);
|
|
802
|
+
break;
|
|
803
|
+
case K_EVENT_ACK:
|
|
804
|
+
this.#handleEventAck(body);
|
|
805
|
+
break;
|
|
806
|
+
default:
|
|
807
|
+
// Ignore unknown packet types (e.g., ApiRequest from server)
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
#handleApiResponse(body) {
|
|
813
|
+
const { i: requestId, o: ok, d: data, er: error } = body;
|
|
814
|
+
if (ok) {
|
|
815
|
+
this.#pendingApi.resolve(requestId, data);
|
|
816
|
+
} else {
|
|
817
|
+
this.#pendingApi.reject(requestId, ClientError.remote(error));
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
#handleEventAck(body) {
|
|
822
|
+
const { i: eventId, o: ok, rc: receipt, er: error } = body;
|
|
823
|
+
if (ok) {
|
|
824
|
+
this.#pendingEvent.resolve(eventId, receipt);
|
|
825
|
+
} else {
|
|
826
|
+
this.#pendingEvent.reject(eventId, ClientError.remote(error));
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async #handleEventEmit(body) {
|
|
831
|
+
const { i: eventId, n: name, d: data, a: attachments, m: metadata, e: expectAck } = body;
|
|
832
|
+
|
|
833
|
+
const event = {
|
|
834
|
+
eventId,
|
|
835
|
+
name,
|
|
836
|
+
data,
|
|
837
|
+
attachments: attachments || [],
|
|
838
|
+
metadata: metadata || {},
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
// Dispatch to all handlers concurrently
|
|
842
|
+
const handlers = this.#eventHandlers.get(name) || [];
|
|
843
|
+
let receipt = { handled: false };
|
|
844
|
+
|
|
845
|
+
if (handlers.length > 0) {
|
|
846
|
+
const results = await Promise.allSettled(
|
|
847
|
+
handlers.map(h => h(event))
|
|
848
|
+
);
|
|
849
|
+
// Last successful non-default receipt wins (matches Rust semantics)
|
|
850
|
+
for (const result of results) {
|
|
851
|
+
if (result.status === 'fulfilled' && result.value != null) {
|
|
852
|
+
receipt = result.value;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Send ACK if expected
|
|
858
|
+
if (expectAck) {
|
|
859
|
+
const ackBody = buildEventAck(eventId, true, receipt);
|
|
860
|
+
try {
|
|
861
|
+
await this.#sendPacket(ackBody);
|
|
862
|
+
} catch { /* best-effort ACK */ }
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ─── Internal: Lifecycle Events ─────────────────────────────────────────────
|
|
867
|
+
|
|
868
|
+
#emitConnected() {
|
|
869
|
+
const event = { url: this.#currentUrl };
|
|
870
|
+
for (const handler of this.#connectedHandlers) {
|
|
871
|
+
try { handler(event); } catch { /* ignore handler errors */ }
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|