@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.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +344 -0
- package/dist/client.js.map +1 -0
- package/dist/http.d.ts +17 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +106 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/info-types.d.ts +380 -0
- package/dist/info-types.d.ts.map +1 -0
- package/dist/info-types.js +16 -0
- package/dist/info-types.js.map +1 -0
- package/dist/info.d.ts +65 -0
- package/dist/info.d.ts.map +1 -0
- package/dist/info.js +252 -0
- package/dist/info.js.map +1 -0
- package/dist/native.d.ts +10 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +252 -0
- package/dist/native.js.map +1 -0
- package/dist/types.d.ts +143 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/dist/wasm.d.ts +28 -0
- package/dist/wasm.d.ts.map +1 -0
- package/dist/wasm.js +279 -0
- package/dist/wasm.js.map +1 -0
- package/dist/ws.d.ts +43 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +221 -0
- package/dist/ws.js.map +1 -0
- package/package.json +65 -0
- package/src/client.ts +454 -0
- package/src/http.ts +153 -0
- package/src/index.ts +144 -0
- package/src/info-types.ts +783 -0
- package/src/info.ts +355 -0
- package/src/native.ts +307 -0
- package/src/types.ts +305 -0
- package/src/wasm.ts +384 -0
- 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
|
+
}
|