wscall-client 0.3.0 → 0.4.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/README.md +33 -12
- package/package.json +1 -1
- package/src/client.js +159 -9
- package/src/index.js +3 -0
- package/src/protocol.js +42 -27
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Works in both **Node.js** (≥18) and modern **browsers** (native WebSocket + We
|
|
|
9
9
|
- **Protocol v3 binary codec** — compact 5-byte frame header, single-letter JSON keys, raw binary attachments (zero Base64 overhead).
|
|
10
10
|
- **Encryption** — ChaCha20-Poly1305 and AES-256-GCM at frame level; no TLS required.
|
|
11
11
|
- **ECDH key agreement** — X25519 dynamic per-connection session key (forward secrecy), no pre-shared key needed.
|
|
12
|
+
- **Connection authentication** — submit a credential (e.g. token) during the handshake; rejected connections fail fast at `connect` time (requires server ≥ 0.6.0).
|
|
12
13
|
- **Bidirectional messaging** — request/response RPC + server-pushed events with ACK correlation.
|
|
13
14
|
- **Automatic reconnect** — exponential backoff with jitter; sticky failover across multiple server URLs.
|
|
14
15
|
- **Zero-copy attachments** — send and receive binary files inline with RPC params or event data.
|
|
@@ -39,9 +40,9 @@ const client = await WscallClient.connect(
|
|
|
39
40
|
WscallClientConfig.ecdh()
|
|
40
41
|
);
|
|
41
42
|
|
|
42
|
-
// RPC call
|
|
43
|
+
// RPC call (resolves directly with the response data)
|
|
43
44
|
const res = await client.call('system.echo', { message: 'hello' });
|
|
44
|
-
console.log(res
|
|
45
|
+
console.log(res); // { message: 'hello' }
|
|
45
46
|
|
|
46
47
|
// Subscribe to server events
|
|
47
48
|
client.onEvent('chat.message', (event) => {
|
|
@@ -49,9 +50,9 @@ client.onEvent('chat.message', (event) => {
|
|
|
49
50
|
return { received: true }; // sent back as ACK receipt
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
// Emit an event (with ACK)
|
|
53
|
-
const
|
|
54
|
-
console.log('Server acknowledged:',
|
|
53
|
+
// Emit an event (resolves with the ACK receipt)
|
|
54
|
+
const receipt = await client.sendEvent('chat.message', { text: 'hi!' });
|
|
55
|
+
console.log('Server acknowledged:', receipt);
|
|
55
56
|
|
|
56
57
|
client.close();
|
|
57
58
|
```
|
|
@@ -79,6 +80,19 @@ const client = await WscallClient.connect('ws://primary:9001/socket', config);
|
|
|
79
80
|
|
|
80
81
|
On disconnect the client iterates through `[primary, ...failover]` starting from the last successfully connected URL (sticky failover).
|
|
81
82
|
|
|
83
|
+
### Authenticated connection (server ≥ 0.6.0)
|
|
84
|
+
|
|
85
|
+
When the server registers an `auth_handler`, submit your credential at connect time. The credential is sent as an encrypted frame right after key agreement — no tokens in URLs or HTTP headers.
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const client = await WscallClient.connect(
|
|
89
|
+
'ws://127.0.0.1:9001/socket',
|
|
90
|
+
WscallClientConfig.ecdh().withCredential('my-token')
|
|
91
|
+
);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If the server rejects the credential, `connect` rejects with a `ClientError` whose `code` is the server error code (e.g. `unauthorized`) and whose `details` carries the full error payload.
|
|
95
|
+
|
|
82
96
|
### File attachments
|
|
83
97
|
|
|
84
98
|
```js
|
|
@@ -108,17 +122,23 @@ Static factory. Connects to a WSCALL server and returns a ready client.
|
|
|
108
122
|
| `WscallClientConfig.pskChaCha20(key)` | PSK with ChaCha20-Poly1305 |
|
|
109
123
|
| `WscallClientConfig.pskAes256(key)` | PSK with AES-256-GCM |
|
|
110
124
|
| `.withAutoReconnect(bool)` | Enable/disable auto-reconnect |
|
|
111
|
-
| `.withTimeout(ms)` | Default request timeout |
|
|
125
|
+
| `.withTimeout(ms)` | Default request timeout (default 10s). Raise it for long-running workloads, or pass a per-call `options.timeout` |
|
|
126
|
+
| `.withHeartbeatInterval(ms)` | Keep-alive ping interval (default 15s) |
|
|
127
|
+
| `.withIdleTimeout(ms)` | Inbound idle timeout (default 45s) |
|
|
128
|
+
| `.withReconnectBaseDelay(ms)` | Reconnect backoff base delay (default 3s) |
|
|
129
|
+
| `.withReconnectMaxDelay(ms)` | Reconnect backoff upper bound (default 30s) |
|
|
130
|
+
| `.withAuthTimeout(ms)` | Auth handshake timeout (default 10s) |
|
|
112
131
|
| `.withMetadata(obj)` | Default metadata sent with requests |
|
|
113
132
|
| `.withFailoverUrl(url)` | Append a failover URL |
|
|
114
133
|
| `.withFailoverUrls(urls)` | Set all failover URLs |
|
|
134
|
+
| `.withCredential(credential)` | Credential (token) for the auth handshake |
|
|
115
135
|
|
|
116
136
|
### `client`
|
|
117
137
|
|
|
118
138
|
| Method | Description |
|
|
119
139
|
|--------|-------------|
|
|
120
|
-
| `call(route, params?, attachments?, opts?)` | RPC call → `Promise<
|
|
121
|
-
| `sendEvent(name, data?, attachments?, opts?)` | Emit event → `Promise<
|
|
140
|
+
| `call(route, params?, attachments?, opts?)` | RPC call → `Promise<data>` (response data directly) |
|
|
141
|
+
| `sendEvent(name, data?, attachments?, opts?)` | Emit event → `Promise<receipt>` (ACK receipt) |
|
|
122
142
|
| `onEvent(name, handler)` | Subscribe to server events |
|
|
123
143
|
| `offEvent(name, handler?)` | Unsubscribe |
|
|
124
144
|
| `onConnected(handler)` | Connection established hook |
|
|
@@ -128,18 +148,19 @@ Static factory. Connects to a WSCALL server and returns a ready client.
|
|
|
128
148
|
### Reconnect behavior
|
|
129
149
|
|
|
130
150
|
1. Unexpected disconnects trigger automatic reconnect (default: enabled).
|
|
131
|
-
2. First retry after 3 s, then exponential backoff (×2), capped at 30 s.
|
|
151
|
+
2. First retry after 3 s (`reconnectBaseDelayMs`), then exponential backoff (×2), capped at 30 s (`reconnectMaxDelayMs`).
|
|
132
152
|
3. Random sub-second jitter prevents thundering-herd storms.
|
|
133
153
|
4. With `failoverUrls`, each cycle tries all URLs before applying backoff.
|
|
134
154
|
5. `close()` stops all reconnect attempts.
|
|
135
155
|
|
|
136
156
|
## Protocol Compatibility
|
|
137
157
|
|
|
138
|
-
This SDK implements **WSCALL Protocol v3** (5-byte frame header, connection-level encryption). It requires a server running `wscall` ≥ 0.5.1.
|
|
158
|
+
This SDK implements **WSCALL Protocol v3** (5-byte frame header, connection-level encryption). It requires a server running `wscall` ≥ 0.5.1; the credential handshake (`withCredential`) requires server ≥ 0.6.0.
|
|
139
159
|
|
|
140
160
|
| SDK version | Protocol | Server compatibility |
|
|
141
161
|
|-------------|----------|---------------------|
|
|
142
|
-
| 0.
|
|
162
|
+
| 0.4.x | v3 | wscall ≥ 0.5.1 (auth: ≥ 0.6.0; timing knobs pair best with server ≥ 0.7.0) |
|
|
163
|
+
| 0.3.x | v3 | wscall ≥ 0.5.1 (auth: ≥ 0.6.0) |
|
|
143
164
|
| 0.2.x | v2 | wscall 0.4.x – 0.5.0 |
|
|
144
165
|
|
|
145
166
|
## Browser Usage
|
|
@@ -150,7 +171,7 @@ This SDK implements **WSCALL Protocol v3** (5-byte frame header, connection-leve
|
|
|
150
171
|
|
|
151
172
|
const client = await WscallClient.connect('ws://localhost:9001/socket', WscallClientConfig.ecdh());
|
|
152
173
|
const res = await client.call('system.echo', { msg: 'from browser' });
|
|
153
|
-
console.log(res
|
|
174
|
+
console.log(res);
|
|
154
175
|
</script>
|
|
155
176
|
```
|
|
156
177
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -18,9 +18,11 @@ import {
|
|
|
18
18
|
K_API_RESPONSE,
|
|
19
19
|
K_EVENT_EMIT,
|
|
20
20
|
K_EVENT_ACK,
|
|
21
|
+
K_AUTH_RESPONSE,
|
|
21
22
|
buildApiRequest,
|
|
22
23
|
buildEventEmit,
|
|
23
24
|
buildEventAck,
|
|
25
|
+
buildAuthRequest,
|
|
24
26
|
ProtocolError,
|
|
25
27
|
generateEcdhKeypair,
|
|
26
28
|
parsePeerPublic,
|
|
@@ -34,6 +36,8 @@ const IDLE_TIMEOUT_MS = 45_000;
|
|
|
34
36
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
35
37
|
const RECONNECT_BASE_DELAY_MS = 3_000;
|
|
36
38
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
39
|
+
/** Timeout for the auth handshake (AuthRequest -> AuthResponse). */
|
|
40
|
+
const AUTH_TIMEOUT_MS = 10_000;
|
|
37
41
|
|
|
38
42
|
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
39
43
|
|
|
@@ -64,6 +68,14 @@ export class ClientError extends Error {
|
|
|
64
68
|
errorPayload
|
|
65
69
|
);
|
|
66
70
|
}
|
|
71
|
+
|
|
72
|
+
static authFailed(errorPayload) {
|
|
73
|
+
return new ClientError(
|
|
74
|
+
errorPayload?.message || 'Authentication failed',
|
|
75
|
+
errorPayload?.code || 'AUTH_FAILED',
|
|
76
|
+
errorPayload
|
|
77
|
+
);
|
|
78
|
+
}
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
// ─── Pending Request Tracker ──────────────────────────────────────────────────
|
|
@@ -159,12 +171,29 @@ export class WscallClientConfig {
|
|
|
159
171
|
useEcdh = true;
|
|
160
172
|
/** @type {boolean} Auto reconnect on unexpected disconnect */
|
|
161
173
|
autoReconnect = true;
|
|
162
|
-
/** @type {number} Default request timeout (ms)
|
|
174
|
+
/** @type {number} Default request timeout (ms). Long-running workloads
|
|
175
|
+
* (large file transfers, heavy computation, big database queries) routinely
|
|
176
|
+
* exceed the 10s default; raise this or pass a per-call `options.timeout`. */
|
|
163
177
|
timeout = DEFAULT_TIMEOUT_MS;
|
|
178
|
+
/** @type {number} Keep-alive ping interval (ms). Must stay well below
|
|
179
|
+
* `idleTimeoutMs` and the server's idle timeout. */
|
|
180
|
+
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS;
|
|
181
|
+
/** @type {number} Drop the connection when no inbound frame arrives within
|
|
182
|
+
* this window (ms). */
|
|
183
|
+
idleTimeoutMs = IDLE_TIMEOUT_MS;
|
|
184
|
+
/** @type {number} Reconnect exponential backoff base delay (ms) */
|
|
185
|
+
reconnectBaseDelayMs = RECONNECT_BASE_DELAY_MS;
|
|
186
|
+
/** @type {number} Reconnect exponential backoff upper bound (ms) */
|
|
187
|
+
reconnectMaxDelayMs = RECONNECT_MAX_DELAY_MS;
|
|
188
|
+
/** @type {number} Auth handshake (AuthRequest -> AuthResponse) timeout (ms) */
|
|
189
|
+
authTimeoutMs = AUTH_TIMEOUT_MS;
|
|
164
190
|
/** @type {object} Default metadata sent with requests */
|
|
165
191
|
metadata = {};
|
|
166
192
|
/** @type {string[]} Failover server URLs tried when the primary is unreachable */
|
|
167
193
|
failoverUrls = [];
|
|
194
|
+
/** @type {string|null} Credential (e.g. token) submitted during the auth
|
|
195
|
+
* handshake. Leave null when the server does not require authentication. */
|
|
196
|
+
credential = null;
|
|
168
197
|
|
|
169
198
|
/** Creates a config with the recommended secure defaults (ECDH + ChaCha20 + auto-reconnect). */
|
|
170
199
|
static default() {
|
|
@@ -205,6 +234,16 @@ export class WscallClientConfig {
|
|
|
205
234
|
withAutoReconnect(v) { this.autoReconnect = v; return this; }
|
|
206
235
|
/** Builder: set default timeout (ms). */
|
|
207
236
|
withTimeout(ms) { this.timeout = ms; return this; }
|
|
237
|
+
/** Builder: set keep-alive ping interval (ms). */
|
|
238
|
+
withHeartbeatInterval(ms) { this.heartbeatIntervalMs = ms; return this; }
|
|
239
|
+
/** Builder: set inbound idle timeout (ms). */
|
|
240
|
+
withIdleTimeout(ms) { this.idleTimeoutMs = ms; return this; }
|
|
241
|
+
/** Builder: set reconnect backoff base delay (ms). */
|
|
242
|
+
withReconnectBaseDelay(ms) { this.reconnectBaseDelayMs = ms; return this; }
|
|
243
|
+
/** Builder: set reconnect backoff upper bound (ms). */
|
|
244
|
+
withReconnectMaxDelay(ms) { this.reconnectMaxDelayMs = ms; return this; }
|
|
245
|
+
/** Builder: set auth handshake timeout (ms). */
|
|
246
|
+
withAuthTimeout(ms) { this.authTimeoutMs = ms; return this; }
|
|
208
247
|
/** Builder: set default metadata. */
|
|
209
248
|
withMetadata(meta) { this.metadata = meta; return this; }
|
|
210
249
|
/** Builder: set a pre-shared ChaCha20 key (disables ECDH). */
|
|
@@ -215,6 +254,9 @@ export class WscallClientConfig {
|
|
|
215
254
|
withFailoverUrl(url) { this.failoverUrls.push(url); return this; }
|
|
216
255
|
/** Builder: set the full failover URL list (replaces any previously added). */
|
|
217
256
|
withFailoverUrls(urls) { this.failoverUrls = [...urls]; return this; }
|
|
257
|
+
/** Builder: set the credential (token) submitted during the auth handshake.
|
|
258
|
+
* Authentication failures surface as AUTH_FAILED errors from `connect`. */
|
|
259
|
+
withCredential(credential) { this.credential = credential; return this; }
|
|
218
260
|
}
|
|
219
261
|
|
|
220
262
|
// ─── WscallClient ─────────────────────────────────────────────────────────────
|
|
@@ -228,8 +270,15 @@ export class WscallClient {
|
|
|
228
270
|
#defaultEncryption;
|
|
229
271
|
#autoReconnect;
|
|
230
272
|
#defaultTimeout;
|
|
273
|
+
#heartbeatIntervalMs;
|
|
274
|
+
#idleTimeoutMs;
|
|
275
|
+
#reconnectBaseDelayMs;
|
|
276
|
+
#reconnectMaxDelayMs;
|
|
277
|
+
#authTimeoutMs;
|
|
231
278
|
#metadata;
|
|
232
279
|
#useEcdh = false;
|
|
280
|
+
/** @type {string|null} Credential submitted during the auth handshake */
|
|
281
|
+
#credential = null;
|
|
233
282
|
|
|
234
283
|
// Connection state
|
|
235
284
|
#ws = null;
|
|
@@ -265,18 +314,51 @@ export class WscallClient {
|
|
|
265
314
|
* @param {boolean} [options.useEcdh=false] - Use ECDH dynamic key agreement
|
|
266
315
|
* @param {boolean} [options.autoReconnect=true] - Auto reconnect on disconnect
|
|
267
316
|
* @param {number} [options.timeout=10000] - Default request timeout (ms)
|
|
317
|
+
* @param {number} [options.heartbeatIntervalMs=15000] - Keep-alive ping interval (ms)
|
|
318
|
+
* @param {number} [options.idleTimeoutMs=45000] - Inbound idle timeout (ms)
|
|
319
|
+
* @param {number} [options.reconnectBaseDelayMs=3000] - Reconnect backoff base delay (ms)
|
|
320
|
+
* @param {number} [options.reconnectMaxDelayMs=30000] - Reconnect backoff upper bound (ms)
|
|
321
|
+
* @param {number} [options.authTimeoutMs=10000] - Auth handshake timeout (ms)
|
|
268
322
|
* @param {object} [options.metadata] - Default metadata sent with requests
|
|
323
|
+
* @param {string} [options.credential] - Credential for the auth handshake
|
|
269
324
|
*/
|
|
270
325
|
constructor(options = {}) {
|
|
271
|
-
const {
|
|
326
|
+
const {
|
|
327
|
+
url,
|
|
328
|
+
failoverUrls = [],
|
|
329
|
+
chacha20Key,
|
|
330
|
+
aes256Key,
|
|
331
|
+
useEcdh = false,
|
|
332
|
+
autoReconnect = true,
|
|
333
|
+
timeout = DEFAULT_TIMEOUT_MS,
|
|
334
|
+
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS,
|
|
335
|
+
idleTimeoutMs = IDLE_TIMEOUT_MS,
|
|
336
|
+
reconnectBaseDelayMs = RECONNECT_BASE_DELAY_MS,
|
|
337
|
+
reconnectMaxDelayMs = RECONNECT_MAX_DELAY_MS,
|
|
338
|
+
authTimeoutMs = AUTH_TIMEOUT_MS,
|
|
339
|
+
metadata = {},
|
|
340
|
+
credential = null,
|
|
341
|
+
} = options;
|
|
272
342
|
|
|
273
343
|
if (!url) throw new Error('url is required');
|
|
274
344
|
|
|
345
|
+
// Normalize timing knobs: non-finite values fall back to the defaults,
|
|
346
|
+
// non-positive values are clamped to 1ms so timers fire and reconnect
|
|
347
|
+
// backoff never degenerates into a busy loop.
|
|
348
|
+
const positiveMs = (v, fallback) =>
|
|
349
|
+
Number.isFinite(v) ? Math.max(1, v) : fallback;
|
|
350
|
+
|
|
275
351
|
this.#urls = [url, ...failoverUrls];
|
|
276
352
|
this.#autoReconnect = autoReconnect;
|
|
277
|
-
this.#defaultTimeout = timeout;
|
|
353
|
+
this.#defaultTimeout = positiveMs(timeout, DEFAULT_TIMEOUT_MS);
|
|
354
|
+
this.#heartbeatIntervalMs = positiveMs(heartbeatIntervalMs, HEARTBEAT_INTERVAL_MS);
|
|
355
|
+
this.#idleTimeoutMs = positiveMs(idleTimeoutMs, IDLE_TIMEOUT_MS);
|
|
356
|
+
this.#reconnectBaseDelayMs = positiveMs(reconnectBaseDelayMs, RECONNECT_BASE_DELAY_MS);
|
|
357
|
+
this.#reconnectMaxDelayMs = positiveMs(reconnectMaxDelayMs, RECONNECT_MAX_DELAY_MS);
|
|
358
|
+
this.#authTimeoutMs = positiveMs(authTimeoutMs, AUTH_TIMEOUT_MS);
|
|
278
359
|
this.#metadata = metadata;
|
|
279
360
|
this.#useEcdh = useEcdh;
|
|
361
|
+
this.#credential = credential;
|
|
280
362
|
|
|
281
363
|
this.#codec = new FrameCodec();
|
|
282
364
|
if (useEcdh) {
|
|
@@ -316,7 +398,13 @@ export class WscallClient {
|
|
|
316
398
|
useEcdh: config.useEcdh,
|
|
317
399
|
autoReconnect: config.autoReconnect,
|
|
318
400
|
timeout: config.timeout,
|
|
401
|
+
heartbeatIntervalMs: config.heartbeatIntervalMs,
|
|
402
|
+
idleTimeoutMs: config.idleTimeoutMs,
|
|
403
|
+
reconnectBaseDelayMs: config.reconnectBaseDelayMs,
|
|
404
|
+
reconnectMaxDelayMs: config.reconnectMaxDelayMs,
|
|
405
|
+
authTimeoutMs: config.authTimeoutMs,
|
|
319
406
|
metadata: config.metadata,
|
|
407
|
+
credential: config.credential,
|
|
320
408
|
};
|
|
321
409
|
} else {
|
|
322
410
|
options = { url, ...config };
|
|
@@ -555,6 +643,10 @@ export class WscallClient {
|
|
|
555
643
|
// buffered here and replayed once the session key is ready.
|
|
556
644
|
let handshakeBuffer = [];
|
|
557
645
|
|
|
646
|
+
// Auth handshake state (optional, only when a credential is configured).
|
|
647
|
+
let authPending = false;
|
|
648
|
+
let authTimer = null;
|
|
649
|
+
|
|
558
650
|
const completeConnection = () => {
|
|
559
651
|
this.#connected = true;
|
|
560
652
|
this.#reconnectAttempt = 0;
|
|
@@ -573,6 +665,32 @@ export class WscallClient {
|
|
|
573
665
|
}
|
|
574
666
|
};
|
|
575
667
|
|
|
668
|
+
// Auth phase: if a credential is configured, send an AuthRequest frame
|
|
669
|
+
// (encrypted with the session key in ECDH mode) and wait for the
|
|
670
|
+
// AuthResponse before marking the connection ready.
|
|
671
|
+
const startAuthOrComplete = () => {
|
|
672
|
+
if (!this.#credential) {
|
|
673
|
+
completeConnection();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
authPending = true;
|
|
677
|
+
authTimer = setTimeout(() => {
|
|
678
|
+
if (!authPending) return;
|
|
679
|
+
authPending = false;
|
|
680
|
+
reject(new ClientError('Auth handshake timed out', 'AUTH_TIMEOUT'));
|
|
681
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
682
|
+
}, this.#authTimeoutMs);
|
|
683
|
+
if (authTimer.unref) authTimer.unref();
|
|
684
|
+
this.#codec
|
|
685
|
+
.encode(buildAuthRequest(this.#credential))
|
|
686
|
+
.then((frame) => ws.send(frame))
|
|
687
|
+
.catch((err) => {
|
|
688
|
+
authPending = false;
|
|
689
|
+
clearTimeout(authTimer);
|
|
690
|
+
reject(new ClientError(`Failed to send auth frame: ${err.message}`, 'AUTH_ERROR'));
|
|
691
|
+
});
|
|
692
|
+
};
|
|
693
|
+
|
|
576
694
|
const onOpenAsync = async () => {
|
|
577
695
|
if (this.#generation !== generation) return;
|
|
578
696
|
|
|
@@ -588,7 +706,7 @@ export class WscallClient {
|
|
|
588
706
|
reject(new ClientError(`ECDH keypair generation failed: ${err.message}`, 'ECDH_ERROR'));
|
|
589
707
|
}
|
|
590
708
|
} else {
|
|
591
|
-
|
|
709
|
+
startAuthOrComplete();
|
|
592
710
|
}
|
|
593
711
|
};
|
|
594
712
|
|
|
@@ -633,7 +751,7 @@ export class WscallClient {
|
|
|
633
751
|
this.#codec.withChaCha20Key(sessionKey);
|
|
634
752
|
this.#defaultEncryption = EncryptionKind.ChaCha20;
|
|
635
753
|
handshakePending = false;
|
|
636
|
-
|
|
754
|
+
startAuthOrComplete();
|
|
637
755
|
})
|
|
638
756
|
.catch((err) => {
|
|
639
757
|
reject(new ClientError(
|
|
@@ -652,6 +770,35 @@ export class WscallClient {
|
|
|
652
770
|
return;
|
|
653
771
|
}
|
|
654
772
|
|
|
773
|
+
if (authPending) {
|
|
774
|
+
// Expect an AuthResponse frame; any other frame is buffered and
|
|
775
|
+
// replayed after the auth handshake completes.
|
|
776
|
+
this.#codec
|
|
777
|
+
.decode(data)
|
|
778
|
+
.then(({ body }) => {
|
|
779
|
+
if (!authPending) return;
|
|
780
|
+
if (body.k !== K_AUTH_RESPONSE) {
|
|
781
|
+
handshakeBuffer.push(data);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
authPending = false;
|
|
785
|
+
clearTimeout(authTimer);
|
|
786
|
+
if (body.o) {
|
|
787
|
+
completeConnection();
|
|
788
|
+
} else {
|
|
789
|
+
reject(ClientError.authFailed(body.er));
|
|
790
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
791
|
+
}
|
|
792
|
+
})
|
|
793
|
+
.catch((err) => {
|
|
794
|
+
if (!authPending) return;
|
|
795
|
+
authPending = false;
|
|
796
|
+
clearTimeout(authTimer);
|
|
797
|
+
reject(new ClientError(`Auth handshake failed: ${err.message}`, 'AUTH_ERROR'));
|
|
798
|
+
});
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
655
802
|
this.#resetIdleTimer();
|
|
656
803
|
this.#handleInbound(data);
|
|
657
804
|
};
|
|
@@ -725,11 +872,14 @@ export class WscallClient {
|
|
|
725
872
|
|
|
726
873
|
#reconnectDelay(attempt) {
|
|
727
874
|
const exp = Math.min(Math.max(attempt, 1) - 1, 6);
|
|
728
|
-
return Math.min(
|
|
875
|
+
return Math.min(
|
|
876
|
+
this.#reconnectBaseDelayMs * (1 << exp),
|
|
877
|
+
this.#reconnectMaxDelayMs,
|
|
878
|
+
);
|
|
729
879
|
}
|
|
730
880
|
|
|
731
881
|
#reconnectJitter() {
|
|
732
|
-
return Math.random() * (
|
|
882
|
+
return Math.random() * (this.#reconnectBaseDelayMs / 2);
|
|
733
883
|
}
|
|
734
884
|
|
|
735
885
|
// ─── Internal: Heartbeat & Idle ─────────────────────────────────────────────
|
|
@@ -747,7 +897,7 @@ export class WscallClient {
|
|
|
747
897
|
// For browser, we rely on the idle timeout mechanism.
|
|
748
898
|
}
|
|
749
899
|
} catch { /* connection may have closed */ }
|
|
750
|
-
},
|
|
900
|
+
}, this.#heartbeatIntervalMs);
|
|
751
901
|
|
|
752
902
|
if (this.#heartbeatTimer.unref) this.#heartbeatTimer.unref();
|
|
753
903
|
}
|
|
@@ -761,7 +911,7 @@ export class WscallClient {
|
|
|
761
911
|
try { this.#ws.close(4000, 'idle timeout'); } catch { /* ignore */ }
|
|
762
912
|
}
|
|
763
913
|
}
|
|
764
|
-
},
|
|
914
|
+
}, this.#idleTimeoutMs);
|
|
765
915
|
|
|
766
916
|
if (this.#idleTimer.unref) this.#idleTimer.unref();
|
|
767
917
|
}
|
package/src/index.js
CHANGED
|
@@ -34,11 +34,14 @@ export {
|
|
|
34
34
|
K_EVENT_EMIT,
|
|
35
35
|
K_API_RESPONSE,
|
|
36
36
|
K_EVENT_ACK,
|
|
37
|
+
K_AUTH_REQUEST,
|
|
38
|
+
K_AUTH_RESPONSE,
|
|
37
39
|
ECDH_DOMAIN_TAG,
|
|
38
40
|
ECDH_KEY_LEN,
|
|
39
41
|
buildApiRequest,
|
|
40
42
|
buildEventEmit,
|
|
41
43
|
buildEventAck,
|
|
44
|
+
buildAuthRequest,
|
|
42
45
|
createAttachment,
|
|
43
46
|
createTextAttachment,
|
|
44
47
|
createBytesAttachment,
|
package/src/protocol.js
CHANGED
|
@@ -19,21 +19,25 @@
|
|
|
19
19
|
* │ id_len:u8 │ id │ name_len:u8 │ name │ ct_len:u8 │ content_type │ data_len:u32(be) │ raw_data │
|
|
20
20
|
*
|
|
21
21
|
* JSON body uses compact single-letter keys with a numeric `k` discriminator:
|
|
22
|
-
* k=0 ApiRequest:
|
|
23
|
-
* k=1 EventEmit:
|
|
24
|
-
* k=2 ApiResponse:
|
|
25
|
-
* k=3 EventAck:
|
|
22
|
+
* k=0 ApiRequest: { k, i, r, p, m }
|
|
23
|
+
* k=1 EventEmit: { k, i, n, d, m, e }
|
|
24
|
+
* k=2 ApiResponse: { k, i, o, s, d, er?, m }
|
|
25
|
+
* k=3 EventAck: { k, i, o, rc, er? }
|
|
26
|
+
* k=4 AuthRequest: { k, c }
|
|
27
|
+
* k=5 AuthResponse: { k, o, st?, er? }
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
29
31
|
|
|
30
|
-
export const MessageType = Object.freeze({ Api: 0x00, Event: 0x01 });
|
|
32
|
+
export const MessageType = Object.freeze({ Api: 0x00, Event: 0x01, Auth: 0x02 });
|
|
31
33
|
export const EncryptionKind = Object.freeze({ None: 0x00, ChaCha20: 0x01, Aes256: 0x02 });
|
|
32
34
|
|
|
33
35
|
export const K_API_REQUEST = 0;
|
|
34
36
|
export const K_EVENT_EMIT = 1;
|
|
35
37
|
export const K_API_RESPONSE = 2;
|
|
36
38
|
export const K_EVENT_ACK = 3;
|
|
39
|
+
export const K_AUTH_REQUEST = 4;
|
|
40
|
+
export const K_AUTH_RESPONSE = 5;
|
|
37
41
|
|
|
38
42
|
// ─── ECDH Constants ──────────────────────────────────────────────────────────
|
|
39
43
|
|
|
@@ -179,7 +183,7 @@ export class FrameCodec {
|
|
|
179
183
|
let payload;
|
|
180
184
|
switch (encryption) {
|
|
181
185
|
case EncryptionKind.ChaCha20:
|
|
182
|
-
payload = this.#encryptChaCha20(composite);
|
|
186
|
+
payload = await this.#encryptChaCha20(composite);
|
|
183
187
|
break;
|
|
184
188
|
case EncryptionKind.Aes256:
|
|
185
189
|
payload = await this.#encryptAes256(composite);
|
|
@@ -234,7 +238,7 @@ export class FrameCodec {
|
|
|
234
238
|
composite = encryptedPayload;
|
|
235
239
|
break;
|
|
236
240
|
case EncryptionKind.ChaCha20:
|
|
237
|
-
composite = this.#decryptChaCha20(encryptedPayload);
|
|
241
|
+
composite = await this.#decryptChaCha20(encryptedPayload);
|
|
238
242
|
break;
|
|
239
243
|
case EncryptionKind.Aes256:
|
|
240
244
|
composite = await this.#decryptAes256(encryptedPayload);
|
|
@@ -274,12 +278,12 @@ export class FrameCodec {
|
|
|
274
278
|
|
|
275
279
|
// ─── ChaCha20-Poly1305 (uses @noble/ciphers) ──────────────────────────────
|
|
276
280
|
|
|
277
|
-
#encryptChaCha20(plaintext) {
|
|
281
|
+
async #encryptChaCha20(plaintext) {
|
|
278
282
|
// NOTE: chacha20poly1305(key, nonce) is constructed per frame by design —
|
|
279
283
|
// the AEAD requires a fresh nonce each call and @noble/ciphers exposes no
|
|
280
284
|
// safe cross-frame key-schedule cache, so there is nothing to hoist here.
|
|
281
285
|
if (!this.#chacha20Key) throw new ProtocolError('Missing ChaCha20 key');
|
|
282
|
-
const { chacha20poly1305 } = getCiphers();
|
|
286
|
+
const { chacha20poly1305 } = await getCiphers();
|
|
283
287
|
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
|
|
284
288
|
const cipher = chacha20poly1305(this.#chacha20Key, nonce);
|
|
285
289
|
const ciphertext = cipher.encrypt(plaintext);
|
|
@@ -290,10 +294,10 @@ export class FrameCodec {
|
|
|
290
294
|
return result;
|
|
291
295
|
}
|
|
292
296
|
|
|
293
|
-
#decryptChaCha20(payload) {
|
|
297
|
+
async #decryptChaCha20(payload) {
|
|
294
298
|
if (!this.#chacha20Key) throw new ProtocolError('Missing ChaCha20 key');
|
|
295
299
|
if (payload.length < NONCE_LEN) throw new ProtocolError('Encrypted payload too short for ChaCha20');
|
|
296
|
-
const { chacha20poly1305 } = getCiphers();
|
|
300
|
+
const { chacha20poly1305 } = await getCiphers();
|
|
297
301
|
const nonce = payload.subarray(0, NONCE_LEN);
|
|
298
302
|
const ciphertext = payload.subarray(NONCE_LEN);
|
|
299
303
|
const cipher = chacha20poly1305(this.#chacha20Key, nonce);
|
|
@@ -332,6 +336,7 @@ function messageTypeForBody(body) {
|
|
|
332
336
|
const k = body.k;
|
|
333
337
|
if (k === K_API_REQUEST || k === K_API_RESPONSE) return MessageType.Api;
|
|
334
338
|
if (k === K_EVENT_EMIT || k === K_EVENT_ACK) return MessageType.Event;
|
|
339
|
+
if (k === K_AUTH_REQUEST || k === K_AUTH_RESPONSE) return MessageType.Auth;
|
|
335
340
|
throw new ProtocolError(`Unknown packet kind: ${k}`);
|
|
336
341
|
}
|
|
337
342
|
|
|
@@ -387,26 +392,31 @@ function decodeAttachmentWire(buf, offset) {
|
|
|
387
392
|
return [{ id, name, content_type, data }, pos];
|
|
388
393
|
}
|
|
389
394
|
|
|
390
|
-
// Lazy-load @noble/ciphers
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
395
|
+
// Lazy-load @noble/ciphers' ChaCha20 backend.
|
|
396
|
+
// A backend explicitly injected via `initCiphers()` takes precedence;
|
|
397
|
+
// otherwise the module is imported dynamically on first use (it is a regular
|
|
398
|
+
// dependency of this package, so this succeeds out of the box in Node and
|
|
399
|
+
// bundlers — no manual initCiphers call required).
|
|
400
|
+
let _ciphersPromise = null;
|
|
401
|
+
async function getCiphers() {
|
|
402
|
+
if (globalThis.__wscall_ciphers) return globalThis.__wscall_ciphers;
|
|
403
|
+
if (!_ciphersPromise) {
|
|
404
|
+
// NOTE: @noble/ciphers' root entry throws on import ("import submodules
|
|
405
|
+
// instead"), so the `chacha` submodule is imported explicitly.
|
|
406
|
+
_ciphersPromise = import('@noble/ciphers/chacha').catch((err) => {
|
|
407
|
+
_ciphersPromise = null;
|
|
408
|
+
throw new ProtocolError(
|
|
409
|
+
`ChaCha20-Poly1305 requires @noble/ciphers. Install it: npm i @noble/ciphers (${err.message})`
|
|
410
|
+
);
|
|
411
|
+
});
|
|
404
412
|
}
|
|
405
|
-
return
|
|
413
|
+
return _ciphersPromise;
|
|
406
414
|
}
|
|
407
415
|
|
|
408
416
|
/**
|
|
409
|
-
* Initialize the cipher backend.
|
|
417
|
+
* Initialize the cipher backend explicitly (e.g. to use a custom or bundled
|
|
418
|
+
* implementation). Optional: by default @noble/ciphers is loaded
|
|
419
|
+
* automatically on first encrypted frame.
|
|
410
420
|
* @param {{ chacha20poly1305: Function }} ciphers
|
|
411
421
|
*/
|
|
412
422
|
export function initCiphers(ciphers) {
|
|
@@ -438,6 +448,11 @@ export function buildEventAck(eventId, ok = true, receipt = {}, error = undefine
|
|
|
438
448
|
return body;
|
|
439
449
|
}
|
|
440
450
|
|
|
451
|
+
/** Build an AuthRequest packet body (sent during the connection handshake). */
|
|
452
|
+
export function buildAuthRequest(credential) {
|
|
453
|
+
return { k: K_AUTH_REQUEST, c: credential };
|
|
454
|
+
}
|
|
455
|
+
|
|
441
456
|
/**
|
|
442
457
|
* Create a file attachment object (raw binary, protocol v3).
|
|
443
458
|
* @param {string} id - Attachment identifier
|