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 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.data); // { message: 'hello' }
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 ack = await client.sendEvent('chat.message', { text: 'hi!' });
54
- console.log('Server acknowledged:', ack.receipt);
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<ApiResponse>` |
121
- | `sendEvent(name, data?, attachments?, opts?)` | Emit event → `Promise<EventAck>` |
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.3.x | v3 | wscall ≥ 0.5.1 |
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.data);
174
+ console.log(res);
154
175
  </script>
155
176
  ```
156
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wscall-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "High-performance JavaScript client SDK for the WSCALL WebSocket RPC framework",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
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 { url, failoverUrls = [], chacha20Key, aes256Key, useEcdh = false, autoReconnect = true, timeout = DEFAULT_TIMEOUT_MS, metadata = {} } = options;
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
- completeConnection();
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
- completeConnection();
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(RECONNECT_BASE_DELAY_MS * (1 << exp), RECONNECT_MAX_DELAY_MS);
875
+ return Math.min(
876
+ this.#reconnectBaseDelayMs * (1 << exp),
877
+ this.#reconnectMaxDelayMs,
878
+ );
729
879
  }
730
880
 
731
881
  #reconnectJitter() {
732
- return Math.random() * (RECONNECT_BASE_DELAY_MS / 2);
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
- }, HEARTBEAT_INTERVAL_MS);
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
- }, IDLE_TIMEOUT_MS);
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: { 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? }
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 to keep it optional
391
- let _ciphers = null;
392
- function getCiphers() {
393
- if (!_ciphers) {
394
- try {
395
- // Dynamic import won't work synchronously; use createRequire pattern
396
- // In practice this is loaded at module init via the top-level import
397
- _ciphers = globalThis.__wscall_ciphers;
398
- } catch { /* ignore */ }
399
- }
400
- if (!_ciphers) {
401
- throw new ProtocolError(
402
- 'ChaCha20-Poly1305 requires @noble/ciphers. Install it: npm i @noble/ciphers'
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 _ciphers;
413
+ return _ciphersPromise;
406
414
  }
407
415
 
408
416
  /**
409
- * Initialize the cipher backend. Called automatically by WscallClient.
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