@metaflux-dex/client 0.0.6 → 0.1.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/dist/client.d.ts +33 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +74 -3
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/native/actions.d.ts +7 -1
- package/dist/native/actions.d.ts.map +1 -1
- package/dist/native/actions.js +113 -1
- package/dist/native/actions.js.map +1 -1
- package/dist/native/digest.d.ts +1 -0
- package/dist/native/digest.d.ts.map +1 -1
- package/dist/native/digest.js +11 -0
- package/dist/native/digest.js.map +1 -1
- package/dist/types/cross-chain.d.ts +8 -0
- package/dist/types/cross-chain.d.ts.map +1 -0
- package/dist/types/cross-chain.js +9 -0
- package/dist/types/cross-chain.js.map +1 -0
- package/dist/types/encrypted.d.ts +5 -0
- package/dist/types/encrypted.d.ts.map +1 -1
- package/dist/types/fba.d.ts +9 -0
- package/dist/types/fba.d.ts.map +1 -0
- package/dist/types/fba.js +8 -0
- package/dist/types/fba.js.map +1 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/info/core.d.ts +5 -1
- package/dist/types/info/core.d.ts.map +1 -1
- package/dist/types/info/index.d.ts +1 -1
- package/dist/types/info/index.d.ts.map +1 -1
- package/dist/types/info/reads.d.ts.map +1 -1
- package/dist/types/rfq.d.ts +15 -0
- package/dist/types/rfq.d.ts.map +1 -0
- package/dist/types/rfq.js +9 -0
- package/dist/types/rfq.js.map +1 -0
- package/dist/types/vault.d.ts +4 -0
- package/dist/types/vault.d.ts.map +1 -1
- package/dist/ws/ws.d.ts +33 -2
- package/dist/ws/ws.d.ts.map +1 -1
- package/dist/ws/ws.js +221 -2
- package/dist/ws/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +120 -2
- package/src/index.ts +17 -0
- package/src/native/actions.ts +135 -0
- package/src/native/digest.ts +12 -0
- package/src/types/cross-chain.ts +32 -0
- package/src/types/encrypted.ts +23 -0
- package/src/types/fba.ts +32 -0
- package/src/types/index.ts +5 -1
- package/src/types/info/core.ts +28 -13
- package/src/types/info/index.ts +1 -0
- package/src/types/info/reads.ts +17 -5
- package/src/types/rfq.ts +55 -0
- package/src/types/vault.ts +21 -0
- package/src/ws/ws.ts +313 -6
package/src/ws/ws.ts
CHANGED
|
@@ -22,9 +22,22 @@
|
|
|
22
22
|
// SDK dependency-free for both runtimes.
|
|
23
23
|
|
|
24
24
|
import type { Funding } from '../types/info/core.js';
|
|
25
|
+
import {
|
|
26
|
+
buildNativeCancelAction,
|
|
27
|
+
buildNativeOrderAction,
|
|
28
|
+
} from '../native/actions.js';
|
|
29
|
+
import { nextNonce, recoverNativeSigner, signNativeAction } from '../native/digest.js';
|
|
30
|
+
import type {
|
|
31
|
+
NativeCancel,
|
|
32
|
+
NativeExchangeAck,
|
|
33
|
+
NativeOrder,
|
|
34
|
+
} from '../types/index.js';
|
|
25
35
|
|
|
26
|
-
/// Channel names exactly as the
|
|
27
|
-
/// (snake_case MTF-native).
|
|
36
|
+
/// Channel names exactly as the gateway's native `/ws` surface accepts them
|
|
37
|
+
/// (snake_case MTF-native). These are the 17 channels the gateway serves
|
|
38
|
+
/// natively (per `api-gateway::ws::subscriptions::native_name`); SDK-only
|
|
39
|
+
/// channels the gateway never serves (`user_fills`, `user_state`, `vault_nav`,
|
|
40
|
+
/// `rfq`, `mark`, `order_events`, …) are deliberately absent.
|
|
28
41
|
export type WsChannel =
|
|
29
42
|
// per-market (require `coin`)
|
|
30
43
|
| 'l2_book'
|
|
@@ -44,10 +57,13 @@ export type WsChannel =
|
|
|
44
57
|
| 'user_fundings'
|
|
45
58
|
| 'user_twap_slice_fills'
|
|
46
59
|
| 'user_twap_history'
|
|
60
|
+
| 'account_state'
|
|
61
|
+
| 'spot_state'
|
|
47
62
|
// per-account + market (`active_asset_data` needs `user` + `coin`)
|
|
48
63
|
| 'active_asset_data';
|
|
49
64
|
|
|
50
|
-
/// All known channels — handy for callers that want to subscribe broadly.
|
|
65
|
+
/// All known channels — handy for callers that want to subscribe broadly. The
|
|
66
|
+
/// exact 17 native gateway channels.
|
|
51
67
|
export const WS_CHANNELS: readonly WsChannel[] = [
|
|
52
68
|
'l2_book',
|
|
53
69
|
'bbo',
|
|
@@ -63,6 +79,8 @@ export const WS_CHANNELS: readonly WsChannel[] = [
|
|
|
63
79
|
'user_fundings',
|
|
64
80
|
'user_twap_slice_fills',
|
|
65
81
|
'user_twap_history',
|
|
82
|
+
'account_state',
|
|
83
|
+
'spot_state',
|
|
66
84
|
'active_asset_data',
|
|
67
85
|
] as const;
|
|
68
86
|
|
|
@@ -70,15 +88,23 @@ export const WS_CHANNELS: readonly WsChannel[] = [
|
|
|
70
88
|
/// subscribe / unsubscribe frame. The routing key is the combination of the
|
|
71
89
|
/// fields a channel uses:
|
|
72
90
|
/// - `coin` — per-market channels (`l2_book`, `bbo`, `trades`,
|
|
73
|
-
/// `active_asset_ctx`, `candles`, `active_asset_data`)
|
|
91
|
+
/// `active_asset_ctx`, `candles`, `active_asset_data`). A
|
|
92
|
+
/// **decimal asset-id STRING** (`"1"`), NOT a number: the node
|
|
93
|
+
/// resolves it via `str::parse::<u32>`, so a bare JSON number
|
|
94
|
+
/// is "unknown market" (symbol resolution like `"BTC"` is not
|
|
95
|
+
/// wired on the native `/ws` surface). Use the `subscribe*`
|
|
96
|
+
/// helpers to format a numeric market id into the right shape.
|
|
74
97
|
/// - `user` — per-account channels (`fills`, `user_events`,
|
|
75
|
-
/// `order_updates`, `active_asset_data`, …); the 0x address
|
|
98
|
+
/// `order_updates`, `active_asset_data`, …); the 0x address.
|
|
76
99
|
/// - `interval` — `candles` only (`1m`/`5m`/`15m`/`1h`/`4h`/`1d`)
|
|
77
100
|
/// Global channels (`all_mids`) take none.
|
|
78
101
|
export interface WsSubscription {
|
|
79
102
|
type: WsChannel;
|
|
103
|
+
/// Market asset-id as a decimal STRING (`"1"`) — see the interface note.
|
|
80
104
|
coin?: string;
|
|
105
|
+
/// User `0x`-hex address (per-account channels).
|
|
81
106
|
user?: string;
|
|
107
|
+
/// Bar interval token (`candles` only).
|
|
82
108
|
interval?: string;
|
|
83
109
|
}
|
|
84
110
|
|
|
@@ -121,6 +147,9 @@ export interface WsConfig {
|
|
|
121
147
|
maxBackoffMs: number;
|
|
122
148
|
/// Auto-reconnect on unexpected close. Default: true.
|
|
123
149
|
autoReconnect: boolean;
|
|
150
|
+
/// How long a `post` request waits for its correlated response before failing
|
|
151
|
+
/// (ms). Mirrors the Rust `post_timeout` (10 s). Default: 10_000.
|
|
152
|
+
postTimeoutMs: number;
|
|
124
153
|
}
|
|
125
154
|
|
|
126
155
|
const DEFAULT_CONFIG: WsConfig = {
|
|
@@ -128,8 +157,20 @@ const DEFAULT_CONFIG: WsConfig = {
|
|
|
128
157
|
initialBackoffMs: 250,
|
|
129
158
|
maxBackoffMs: 30_000,
|
|
130
159
|
autoReconnect: true,
|
|
160
|
+
postTimeoutMs: 10_000,
|
|
131
161
|
};
|
|
132
162
|
|
|
163
|
+
/// Signing context for the WS `post` exchange path — a 32-byte private key and
|
|
164
|
+
/// the EIP-712 chain id to sign against. When absent, `postAction` / `submitOrder`
|
|
165
|
+
/// / `cancelOrder` throw; `postInfo` (an unsigned read) still works.
|
|
166
|
+
export interface WsSigner {
|
|
167
|
+
/// 32-byte ECDSA private key.
|
|
168
|
+
privateKey: Uint8Array;
|
|
169
|
+
/// EIP-712 domain chain id. Defaults to `MTF_CHAIN_ID` (testnet 114514) when
|
|
170
|
+
/// omitted, matching the REST `/exchange` path.
|
|
171
|
+
chainId?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
133
174
|
/// Subscription set equality key — `(channel, coin, user, interval)` is the
|
|
134
175
|
/// server's routing key, so two subscriptions are identical iff all match
|
|
135
176
|
/// (e.g. `candles` `1m` vs `5m`, or `fills` for two different users, are
|
|
@@ -155,6 +196,7 @@ function subKey(s: WsSubscription): string {
|
|
|
155
196
|
export class WsClient {
|
|
156
197
|
private readonly url: string;
|
|
157
198
|
private readonly config: WsConfig;
|
|
199
|
+
private readonly signer: WsSigner | undefined;
|
|
158
200
|
private socket: WebSocket | undefined;
|
|
159
201
|
/// Active subscriptions, replayed on (re)connect. Keyed for dedupe.
|
|
160
202
|
private readonly active = new Map<string, WsSubscription>();
|
|
@@ -164,13 +206,31 @@ export class WsClient {
|
|
|
164
206
|
private backoffMs: number;
|
|
165
207
|
/// True once `close()` is called — suppresses auto-reconnect.
|
|
166
208
|
private closed = false;
|
|
209
|
+
/// Monotonic id source for `post` request/response correlation.
|
|
210
|
+
private postIdSeq = 1;
|
|
211
|
+
/// In-flight `post` requests keyed by correlation id. Resolved when the
|
|
212
|
+
/// `{channel:"post"}` frame with the matching `data.id` arrives, or rejected
|
|
213
|
+
/// on timeout. A connection drop leaves them pending; the per-request timeout
|
|
214
|
+
/// is the backstop (a signed action is one-shot, so we never auto-retry).
|
|
215
|
+
private readonly pendingPosts = new Map<
|
|
216
|
+
number,
|
|
217
|
+
{
|
|
218
|
+
resolve: (response: unknown) => void;
|
|
219
|
+
reject: (err: Error) => void;
|
|
220
|
+
timer: ReturnType<typeof setTimeout>;
|
|
221
|
+
}
|
|
222
|
+
>();
|
|
167
223
|
|
|
168
|
-
constructor(url: string, config: Partial<WsConfig> = {}) {
|
|
224
|
+
constructor(url: string, config: Partial<WsConfig> = {}, signer?: WsSigner) {
|
|
169
225
|
if (url.length === 0) {
|
|
170
226
|
throw new RangeError('WsClient url must be non-empty');
|
|
171
227
|
}
|
|
228
|
+
if (signer !== undefined && signer.privateKey.length !== 32) {
|
|
229
|
+
throw new RangeError('WsClient signer privateKey must be exactly 32 bytes');
|
|
230
|
+
}
|
|
172
231
|
this.url = url;
|
|
173
232
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
233
|
+
this.signer = signer;
|
|
174
234
|
this.backoffMs = this.config.initialBackoffMs;
|
|
175
235
|
}
|
|
176
236
|
|
|
@@ -209,6 +269,224 @@ export class WsClient {
|
|
|
209
269
|
this.send({ method: 'unsubscribe', subscription: sub });
|
|
210
270
|
}
|
|
211
271
|
|
|
272
|
+
// ── convenience subscribe helpers ─────────────────────────────────────────
|
|
273
|
+
//
|
|
274
|
+
// Format a numeric market id into the `coin` decimal-string the node parses
|
|
275
|
+
// (`str::parse::<u32>`), so callers never accidentally send a JSON number that
|
|
276
|
+
// the node drops as "unknown market". Mirrors the Rust `subscribe_*` helpers.
|
|
277
|
+
|
|
278
|
+
/// Subscribe to L2 book updates for a market id.
|
|
279
|
+
async subscribeL2Book(marketId: number): Promise<void> {
|
|
280
|
+
return this.subscribe({ type: 'l2_book', coin: `${marketId}` });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Subscribe to public trades for a market id.
|
|
284
|
+
async subscribeTrades(marketId: number): Promise<void> {
|
|
285
|
+
return this.subscribe({ type: 'trades', coin: `${marketId}` });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Subscribe to best-bid-best-offer ticks for a market id.
|
|
289
|
+
async subscribeBbo(marketId: number): Promise<void> {
|
|
290
|
+
return this.subscribe({ type: 'bbo', coin: `${marketId}` });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// Subscribe to per-market mark / oracle / funding / OI context.
|
|
294
|
+
async subscribeActiveAssetCtx(marketId: number): Promise<void> {
|
|
295
|
+
return this.subscribe({ type: 'active_asset_ctx', coin: `${marketId}` });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// Subscribe to OHLCV candles for a market id + interval token.
|
|
299
|
+
async subscribeCandles(marketId: number, interval: string): Promise<void> {
|
|
300
|
+
return this.subscribe({ type: 'candles', coin: `${marketId}`, interval });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/// Subscribe to the global all-market mids stream.
|
|
304
|
+
async subscribeAllMids(): Promise<void> {
|
|
305
|
+
return this.subscribe({ type: 'all_mids' });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// Subscribe to per-user fills (0x address).
|
|
309
|
+
async subscribeFills(user: string): Promise<void> {
|
|
310
|
+
return this.subscribe({ type: 'fills', user });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// Subscribe to per-user order lifecycle updates (0x address).
|
|
314
|
+
async subscribeOrderUpdates(user: string): Promise<void> {
|
|
315
|
+
return this.subscribe({ type: 'order_updates', user });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// Subscribe to per-user account / margin events (0x address).
|
|
319
|
+
async subscribeUserEvents(user: string): Promise<void> {
|
|
320
|
+
return this.subscribe({ type: 'user_events', user });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// Subscribe to the per-user live PERP account-state stream (0x address).
|
|
324
|
+
async subscribeAccountState(user: string): Promise<void> {
|
|
325
|
+
return this.subscribe({ type: 'account_state', user });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// Subscribe to the per-user live SPOT clearinghouse-state stream (0x address).
|
|
329
|
+
async subscribeSpotState(user: string): Promise<void> {
|
|
330
|
+
return this.subscribe({ type: 'spot_state', user });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// Subscribe to per-(user, market) leverage / margin-mode context.
|
|
334
|
+
async subscribeActiveAssetData(user: string, marketId: number): Promise<void> {
|
|
335
|
+
return this.subscribe({
|
|
336
|
+
type: 'active_asset_data',
|
|
337
|
+
coin: `${marketId}`,
|
|
338
|
+
user,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── `post` request/response (signed exchange actions + info reads) ─────────
|
|
343
|
+
//
|
|
344
|
+
// The WS analogue of `POST /exchange` and `POST /info`: multiplex one-off
|
|
345
|
+
// writes / reads over the existing socket instead of opening a REST request.
|
|
346
|
+
//
|
|
347
|
+
// client → server:
|
|
348
|
+
// {"method":"post","id":N,"request":{"type":"action"|"info","payload":{...}}}
|
|
349
|
+
// server → client:
|
|
350
|
+
// {"channel":"post","data":{"id":N,"response":{"type":...,"payload":{...}}}}
|
|
351
|
+
//
|
|
352
|
+
// For an `action`, payload is the signed envelope `{signature, nonce, action}`
|
|
353
|
+
// — signed with the SAME EIP-712 digest the REST `/exchange` path uses (the
|
|
354
|
+
// node recovers the signer over the raw `action` bytes). Correlated by `id`;
|
|
355
|
+
// a `{type:"error"}` response surfaces as an error; each request has a timeout.
|
|
356
|
+
|
|
357
|
+
/// Issue a signed exchange action over the WS `post` channel, returning the
|
|
358
|
+
/// node's action response payload. Requires a `WsSigner` (passed to the
|
|
359
|
+
/// constructor, or via `Client.connectWs` with a keyed client).
|
|
360
|
+
async postAction(actionJson: string): Promise<unknown> {
|
|
361
|
+
if (this.signer === undefined) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
'postAction requires a WsSigner (this WsClient was opened read-only)',
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const nonce = nextNonce();
|
|
367
|
+
const signed = await signNativeAction(
|
|
368
|
+
this.signer.privateKey,
|
|
369
|
+
actionJson,
|
|
370
|
+
nonce,
|
|
371
|
+
this.signer.chainId,
|
|
372
|
+
);
|
|
373
|
+
// The signed envelope mirrors the REST body shape, but the `action` rides as
|
|
374
|
+
// a parsed object inside the JSON `request.payload`. The server still
|
|
375
|
+
// verifies over the raw `action` bytes; since the bytes we signed are valid
|
|
376
|
+
// JSON, re-embedding them as `JSON.parse(actionJson)` is byte-equivalent to
|
|
377
|
+
// the canonical form the server re-serializes for the digest.
|
|
378
|
+
const payload = {
|
|
379
|
+
signature: signed.signature,
|
|
380
|
+
nonce: Number(signed.nonce),
|
|
381
|
+
action: JSON.parse(actionJson) as unknown,
|
|
382
|
+
};
|
|
383
|
+
return this.postRequest('action', payload);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/// Issue an `info` read over the WS `post` channel, returning the info response
|
|
387
|
+
/// payload. `payload` is the usual `{"type":"<info>",...}` body. No signing.
|
|
388
|
+
async postInfo(payload: { type: string; [k: string]: unknown }): Promise<unknown> {
|
|
389
|
+
return this.postRequest('info', payload);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/// Submit a limit / market / trigger order over the WS `post` channel.
|
|
393
|
+
/// Mirrors `Client.submitOrderNative`: `order.owner` MUST equal the signing
|
|
394
|
+
/// wallet (recovered locally and rejected on mismatch).
|
|
395
|
+
async submitOrder(order: NativeOrder): Promise<NativeExchangeAck> {
|
|
396
|
+
if (this.signer === undefined) {
|
|
397
|
+
throw new Error('submitOrder requires a WsSigner (read-only WsClient)');
|
|
398
|
+
}
|
|
399
|
+
const actionJson = buildNativeOrderAction(order);
|
|
400
|
+
await this.assertOwner(actionJson, order.owner, 'order.owner');
|
|
401
|
+
return (await this.postAction(actionJson)) as NativeExchangeAck;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/// Cancel an order over the WS `post` channel. Mirrors
|
|
405
|
+
/// `Client.cancelOrderNative`: `cancel.owner` MUST equal the signing wallet.
|
|
406
|
+
async cancelOrder(cancel: NativeCancel): Promise<NativeExchangeAck> {
|
|
407
|
+
if (this.signer === undefined) {
|
|
408
|
+
throw new Error('cancelOrder requires a WsSigner (read-only WsClient)');
|
|
409
|
+
}
|
|
410
|
+
const actionJson = buildNativeCancelAction(cancel);
|
|
411
|
+
await this.assertOwner(actionJson, cancel.owner, 'cancel.owner');
|
|
412
|
+
return (await this.postAction(actionJson)) as NativeExchangeAck;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/// Recover the signer over the action's own digest and reject unless it equals
|
|
416
|
+
/// `owner`. Saves a round-trip on an obvious key/owner mismatch (the server
|
|
417
|
+
/// enforces the same). Shares the nonce-agnostic recover path with the REST
|
|
418
|
+
/// client.
|
|
419
|
+
private async assertOwner(
|
|
420
|
+
actionJson: string,
|
|
421
|
+
owner: string,
|
|
422
|
+
field: string,
|
|
423
|
+
): Promise<void> {
|
|
424
|
+
// recoverNativeSigner is nonce-agnostic for the address it yields; use a
|
|
425
|
+
// throwaway nonce of 0 just to drive the digest+recover.
|
|
426
|
+
const signed = await signNativeAction(
|
|
427
|
+
this.signer!.privateKey,
|
|
428
|
+
actionJson,
|
|
429
|
+
0n,
|
|
430
|
+
this.signer!.chainId,
|
|
431
|
+
);
|
|
432
|
+
const signer = await recoverNativeSigner(signed, this.signer!.chainId);
|
|
433
|
+
if (signer.toLowerCase() !== owner.toLowerCase()) {
|
|
434
|
+
throw new Error(`${field} ${owner} != recovered signer ${signer}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/// Core `post` machinery: assign a correlation id, ship the frame, and await
|
|
439
|
+
/// the matching response. Rejects on a `{type:"error"}` response, on timeout,
|
|
440
|
+
/// or if the socket is not open. Returns the inner `payload` on success.
|
|
441
|
+
private postRequest(
|
|
442
|
+
requestType: 'action' | 'info',
|
|
443
|
+
payload: unknown,
|
|
444
|
+
): Promise<unknown> {
|
|
445
|
+
if (this.socket?.readyState !== 1) {
|
|
446
|
+
return Promise.reject(new Error('ws post: socket is not open'));
|
|
447
|
+
}
|
|
448
|
+
const id = this.postIdSeq++;
|
|
449
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
450
|
+
const timer = setTimeout(() => {
|
|
451
|
+
this.pendingPosts.delete(id);
|
|
452
|
+
reject(new Error('ws post: timed out'));
|
|
453
|
+
}, this.config.postTimeoutMs);
|
|
454
|
+
|
|
455
|
+
this.pendingPosts.set(id, {
|
|
456
|
+
resolve: (response: unknown) => {
|
|
457
|
+
// The node wraps every reply as `{type, payload}`; an error reply
|
|
458
|
+
// carries the message as a string `payload`.
|
|
459
|
+
if (
|
|
460
|
+
response !== null &&
|
|
461
|
+
typeof response === 'object' &&
|
|
462
|
+
(response as { type?: unknown }).type === 'error'
|
|
463
|
+
) {
|
|
464
|
+
const msg = (response as { payload?: unknown }).payload;
|
|
465
|
+
reject(
|
|
466
|
+
new Error(
|
|
467
|
+
`ws post error: ${typeof msg === 'string' ? msg : 'unknown post error'}`,
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const inner =
|
|
473
|
+
response !== null && typeof response === 'object'
|
|
474
|
+
? (response as { payload?: unknown }).payload
|
|
475
|
+
: undefined;
|
|
476
|
+
resolve(inner);
|
|
477
|
+
},
|
|
478
|
+
reject,
|
|
479
|
+
timer,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
this.send({
|
|
483
|
+
method: 'post',
|
|
484
|
+
id,
|
|
485
|
+
request: { type: requestType, payload },
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
212
490
|
/// Whether the socket is currently OPEN.
|
|
213
491
|
get isOpen(): boolean {
|
|
214
492
|
return this.socket?.readyState === 1; // WebSocket.OPEN
|
|
@@ -219,6 +497,13 @@ export class WsClient {
|
|
|
219
497
|
close(): void {
|
|
220
498
|
this.closed = true;
|
|
221
499
|
this.clearTimers();
|
|
500
|
+
// Fail any in-flight `post` so a caller awaiting a response on a socket we
|
|
501
|
+
// just closed unblocks with an error rather than hanging until timeout.
|
|
502
|
+
for (const [, pending] of this.pendingPosts) {
|
|
503
|
+
clearTimeout(pending.timer);
|
|
504
|
+
pending.reject(new Error('ws post: client closed'));
|
|
505
|
+
}
|
|
506
|
+
this.pendingPosts.clear();
|
|
222
507
|
if (this.socket !== undefined) {
|
|
223
508
|
try {
|
|
224
509
|
this.socket.close();
|
|
@@ -310,11 +595,33 @@ export class WsClient {
|
|
|
310
595
|
} catch {
|
|
311
596
|
return; // ignore non-JSON frames
|
|
312
597
|
}
|
|
598
|
+
// A `{channel:"post"}` frame correlates by id back to the waiting caller and
|
|
599
|
+
// is consumed here — it does NOT fan out to subscription handlers. Every
|
|
600
|
+
// other frame (data channels, subscriptionResponse ack, error, bare pong)
|
|
601
|
+
// is passed through to the registered handlers unchanged.
|
|
602
|
+
if (frame.channel === 'post') {
|
|
603
|
+
this.resolvePost(frame.data);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
313
606
|
for (const h of this.handlers) {
|
|
314
607
|
h(frame);
|
|
315
608
|
}
|
|
316
609
|
}
|
|
317
610
|
|
|
611
|
+
/// Resolve the pending `post` whose id matches the frame's `data.id`. The node
|
|
612
|
+
/// wraps every reply as `data.response = {type, payload}`; a `{type:"error"}`
|
|
613
|
+
/// response surfaces as a rejection.
|
|
614
|
+
private resolvePost(data: unknown): void {
|
|
615
|
+
if (data === null || typeof data !== 'object') return;
|
|
616
|
+
const { id, response } = data as { id?: unknown; response?: unknown };
|
|
617
|
+
if (typeof id !== 'number') return;
|
|
618
|
+
const pending = this.pendingPosts.get(id);
|
|
619
|
+
if (pending === undefined) return;
|
|
620
|
+
this.pendingPosts.delete(id);
|
|
621
|
+
clearTimeout(pending.timer);
|
|
622
|
+
pending.resolve(response);
|
|
623
|
+
}
|
|
624
|
+
|
|
318
625
|
private clearTimers(): void {
|
|
319
626
|
this.clearPing();
|
|
320
627
|
if (this.reconnectTimer !== undefined) {
|