@metaflux-dex/client 0.0.1

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/client.d.ts +32 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +344 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/http.d.ts +17 -0
  8. package/dist/http.d.ts.map +1 -0
  9. package/dist/http.js +106 -0
  10. package/dist/http.js.map +1 -0
  11. package/dist/index.d.ts +9 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +26 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/info-types.d.ts +380 -0
  16. package/dist/info-types.d.ts.map +1 -0
  17. package/dist/info-types.js +16 -0
  18. package/dist/info-types.js.map +1 -0
  19. package/dist/info.d.ts +65 -0
  20. package/dist/info.d.ts.map +1 -0
  21. package/dist/info.js +252 -0
  22. package/dist/info.js.map +1 -0
  23. package/dist/native.d.ts +10 -0
  24. package/dist/native.d.ts.map +1 -0
  25. package/dist/native.js +252 -0
  26. package/dist/native.js.map +1 -0
  27. package/dist/types.d.ts +143 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +13 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/wasm.d.ts +28 -0
  32. package/dist/wasm.d.ts.map +1 -0
  33. package/dist/wasm.js +279 -0
  34. package/dist/wasm.js.map +1 -0
  35. package/dist/ws.d.ts +43 -0
  36. package/dist/ws.d.ts.map +1 -0
  37. package/dist/ws.js +221 -0
  38. package/dist/ws.js.map +1 -0
  39. package/package.json +65 -0
  40. package/src/client.ts +454 -0
  41. package/src/http.ts +153 -0
  42. package/src/index.ts +144 -0
  43. package/src/info-types.ts +783 -0
  44. package/src/info.ts +355 -0
  45. package/src/native.ts +307 -0
  46. package/src/types.ts +305 -0
  47. package/src/wasm.ts +384 -0
  48. package/src/ws.ts +279 -0
package/src/ws.ts ADDED
@@ -0,0 +1,279 @@
1
+ // MTF-native WebSocket client — connect, subscribe/unsubscribe, typed frames.
2
+ //
3
+ // Mirrors the Rust SDK's `WsClient` behavior (reconnect with backoff, replay of
4
+ // active subscriptions, heartbeat ping) and the SERVER wire protocol
5
+ // (`metaflux/crates/api-node/src/ws/subscribe.rs` + `fanout.rs`):
6
+ //
7
+ // client → server:
8
+ // {"method":"subscribe","subscription":{"type":"l2Book","coin":"BTC"}}
9
+ // {"method":"unsubscribe","subscription":{"type":"trades"}}
10
+ // {"method":"ping"}
11
+ // server → client:
12
+ // {"channel":"subscriptionResponse","data":{"method":"subscribe","subscription":{...}}}
13
+ // {"channel":"l2Book","data":{...}} | {"channel":"error","data":{"error":"..."}}
14
+ //
15
+ // Channel names are the EXACT server `Channel::wire_name()` strings (camelCase:
16
+ // `l2Book`, `userEvents`). `coin` is the market symbol string and is optional
17
+ // (e.g. `userEvents` carries none).
18
+ //
19
+ // Transport: the standard `WebSocket` global (browser-native; Node ≥ 22 ships
20
+ // it globally, which is the SDK's floor). No `ws` npm dependency — keeping the
21
+ // SDK dependency-free for both runtimes.
22
+
23
+ /// Channel names exactly as the server's `Channel::from_wire` accepts them.
24
+ export type WsChannel =
25
+ | 'l2Book'
26
+ | 'trades'
27
+ | 'bbo'
28
+ | 'fills'
29
+ | 'candles'
30
+ | 'userEvents';
31
+
32
+ /// All known channels — handy for callers that want to subscribe broadly.
33
+ export const WS_CHANNELS: readonly WsChannel[] = [
34
+ 'l2Book',
35
+ 'trades',
36
+ 'bbo',
37
+ 'fills',
38
+ 'candles',
39
+ 'userEvents',
40
+ ] as const;
41
+
42
+ /// A subscription request body — the inner `subscription` object of a
43
+ /// subscribe / unsubscribe frame. `coin` is the market symbol (e.g. `"BTC"`)
44
+ /// and is optional per the server (`userEvents` carries none).
45
+ export interface WsSubscription {
46
+ type: WsChannel;
47
+ coin?: string;
48
+ }
49
+
50
+ /// A typed inbound frame `{channel, data}`. `data` is left as `unknown` because
51
+ /// the node currently ships string-JSON payloads whose concrete shapes are
52
+ /// mid-flight server-side (see `ws/subscribe.rs` — empty snapshots today); the
53
+ /// caller narrows per channel. `subscriptionResponse` and `error` are the two
54
+ /// control frames with stable shapes.
55
+ export interface WsFrame {
56
+ channel: string;
57
+ data: unknown;
58
+ }
59
+
60
+ /// Handler invoked for every inbound channel frame.
61
+ export type WsMessageHandler = (frame: WsFrame) => void;
62
+
63
+ /// Tunable WS configuration — mirrors the Rust `WsConfig` defaults.
64
+ export interface WsConfig {
65
+ /// Heartbeat interval (ms). Default: 30_000.
66
+ pingIntervalMs: number;
67
+ /// Initial reconnect backoff (ms). Default: 250.
68
+ initialBackoffMs: number;
69
+ /// Max reconnect backoff (ms). Default: 30_000.
70
+ maxBackoffMs: number;
71
+ /// Auto-reconnect on unexpected close. Default: true.
72
+ autoReconnect: boolean;
73
+ }
74
+
75
+ const DEFAULT_CONFIG: WsConfig = {
76
+ pingIntervalMs: 30_000,
77
+ initialBackoffMs: 250,
78
+ maxBackoffMs: 30_000,
79
+ autoReconnect: true,
80
+ };
81
+
82
+ /// Subscription set equality key — `(channel, coin)` is the server's routing
83
+ /// key, so two subscriptions are identical iff both match.
84
+ function subKey(s: WsSubscription): string {
85
+ return `${s.type}:${s.coin ?? ''}`;
86
+ }
87
+
88
+ /// MTF-native WebSocket client.
89
+ ///
90
+ /// Usage:
91
+ /// ```ts
92
+ /// const ws = new WsClient('wss://api.mtf.exchange/ws');
93
+ /// ws.onMessage((f) => { if (f.channel === 'l2Book') handleBook(f.data); });
94
+ /// await ws.connect();
95
+ /// await ws.subscribe({ type: 'l2Book', coin: 'BTC' });
96
+ /// // ... later
97
+ /// ws.close();
98
+ /// ```
99
+ ///
100
+ /// `connect()` resolves once the socket is OPEN. Active subscriptions are
101
+ /// re-issued automatically after a reconnect. Drop with `close()`.
102
+ export class WsClient {
103
+ private readonly url: string;
104
+ private readonly config: WsConfig;
105
+ private socket: WebSocket | undefined;
106
+ /// Active subscriptions, replayed on (re)connect. Keyed for dedupe.
107
+ private readonly active = new Map<string, WsSubscription>();
108
+ private readonly handlers: WsMessageHandler[] = [];
109
+ private pingTimer: ReturnType<typeof setInterval> | undefined;
110
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
111
+ private backoffMs: number;
112
+ /// True once `close()` is called — suppresses auto-reconnect.
113
+ private closed = false;
114
+
115
+ constructor(url: string, config: Partial<WsConfig> = {}) {
116
+ if (url.length === 0) {
117
+ throw new RangeError('WsClient url must be non-empty');
118
+ }
119
+ this.url = url;
120
+ this.config = { ...DEFAULT_CONFIG, ...config };
121
+ this.backoffMs = this.config.initialBackoffMs;
122
+ }
123
+
124
+ /// Register an inbound-frame handler. Multiple handlers fan out; each
125
+ /// receives every frame. Returns an unsubscribe function.
126
+ onMessage(handler: WsMessageHandler): () => void {
127
+ this.handlers.push(handler);
128
+ return () => {
129
+ const i = this.handlers.indexOf(handler);
130
+ if (i >= 0) this.handlers.splice(i, 1);
131
+ };
132
+ }
133
+
134
+ /// Open the connection. Resolves when the socket reaches OPEN; rejects if the
135
+ /// initial connect errors. Subsequent reconnects (if `autoReconnect`) happen
136
+ /// transparently in the background.
137
+ async connect(): Promise<void> {
138
+ this.closed = false;
139
+ await this.openOnce();
140
+ }
141
+
142
+ /// Subscribe to a channel. The subscription is recorded and replayed on
143
+ /// reconnect. Idempotent — a duplicate `(channel, coin)` is a no-op (matching
144
+ /// the server, which silently ignores duplicate subscribes).
145
+ async subscribe(sub: WsSubscription): Promise<void> {
146
+ const key = subKey(sub);
147
+ if (!this.active.has(key)) {
148
+ this.active.set(key, sub);
149
+ }
150
+ this.send({ method: 'subscribe', subscription: sub });
151
+ }
152
+
153
+ /// Unsubscribe from a channel.
154
+ async unsubscribe(sub: WsSubscription): Promise<void> {
155
+ this.active.delete(subKey(sub));
156
+ this.send({ method: 'unsubscribe', subscription: sub });
157
+ }
158
+
159
+ /// Whether the socket is currently OPEN.
160
+ get isOpen(): boolean {
161
+ return this.socket?.readyState === 1; // WebSocket.OPEN
162
+ }
163
+
164
+ /// Close the connection and cancel auto-reconnect. After `close()` the client
165
+ /// is inert until `connect()` is called again.
166
+ close(): void {
167
+ this.closed = true;
168
+ this.clearTimers();
169
+ if (this.socket !== undefined) {
170
+ try {
171
+ this.socket.close();
172
+ } catch {
173
+ // Already closing / closed.
174
+ }
175
+ this.socket = undefined;
176
+ }
177
+ }
178
+
179
+ // -------------------------------------------------------------------------
180
+
181
+ private openOnce(): Promise<void> {
182
+ return new Promise<void>((resolve, reject) => {
183
+ let settled = false;
184
+ const sock = new WebSocket(this.url);
185
+ this.socket = sock;
186
+
187
+ sock.onopen = () => {
188
+ this.backoffMs = this.config.initialBackoffMs;
189
+ // Replay active subscriptions on (re)connect.
190
+ for (const sub of this.active.values()) {
191
+ this.send({ method: 'subscribe', subscription: sub });
192
+ }
193
+ this.startPing();
194
+ settled = true;
195
+ resolve();
196
+ };
197
+
198
+ sock.onmessage = (ev: MessageEvent) => {
199
+ this.dispatch(typeof ev.data === 'string' ? ev.data : String(ev.data));
200
+ };
201
+
202
+ sock.onerror = () => {
203
+ if (!settled) {
204
+ settled = true;
205
+ reject(new Error(`WsClient failed to connect to ${this.url}`));
206
+ }
207
+ // Post-open errors are handled by onclose → reconnect.
208
+ };
209
+
210
+ sock.onclose = () => {
211
+ this.clearTimers();
212
+ this.socket = undefined;
213
+ if (!this.closed && this.config.autoReconnect) {
214
+ this.scheduleReconnect();
215
+ }
216
+ };
217
+ });
218
+ }
219
+
220
+ private scheduleReconnect(): void {
221
+ if (this.reconnectTimer !== undefined) return;
222
+ const delay = this.backoffMs;
223
+ this.backoffMs = Math.min(this.backoffMs * 2, this.config.maxBackoffMs);
224
+ this.reconnectTimer = setTimeout(() => {
225
+ this.reconnectTimer = undefined;
226
+ if (this.closed) return;
227
+ // Best-effort reconnect; failures retry via the next onclose.
228
+ void this.openOnce().catch(() => {
229
+ if (!this.closed && this.config.autoReconnect) {
230
+ this.scheduleReconnect();
231
+ }
232
+ });
233
+ }, delay);
234
+ }
235
+
236
+ private startPing(): void {
237
+ this.clearPing();
238
+ this.pingTimer = setInterval(() => {
239
+ this.send({ method: 'ping' });
240
+ }, this.config.pingIntervalMs);
241
+ }
242
+
243
+ private send(obj: unknown): void {
244
+ if (this.socket?.readyState === 1) {
245
+ this.socket.send(JSON.stringify(obj));
246
+ }
247
+ // If not open, the frame is dropped; subscribe state is replayed on the
248
+ // next open, so a dropped subscribe self-heals. A dropped ping is benign.
249
+ }
250
+
251
+ private dispatch(raw: string): void {
252
+ let frame: WsFrame;
253
+ try {
254
+ const parsed = JSON.parse(raw) as Partial<WsFrame>;
255
+ if (typeof parsed.channel !== 'string') return; // ignore malformed
256
+ frame = { channel: parsed.channel, data: parsed.data };
257
+ } catch {
258
+ return; // ignore non-JSON frames
259
+ }
260
+ for (const h of this.handlers) {
261
+ h(frame);
262
+ }
263
+ }
264
+
265
+ private clearTimers(): void {
266
+ this.clearPing();
267
+ if (this.reconnectTimer !== undefined) {
268
+ clearTimeout(this.reconnectTimer);
269
+ this.reconnectTimer = undefined;
270
+ }
271
+ }
272
+
273
+ private clearPing(): void {
274
+ if (this.pingTimer !== undefined) {
275
+ clearInterval(this.pingTimer);
276
+ this.pingTimer = undefined;
277
+ }
278
+ }
279
+ }