@temple-digital-group/temple-canton-js 2.0.2 → 2.0.3-beta.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 +457 -457
- package/index.js +15 -15
- package/package.json +49 -49
- package/src/api/config.d.ts +20 -20
- package/src/api/index.ts +322 -322
- package/src/api/tokenStore.ts +30 -30
- package/src/api/types.ts +196 -196
- package/src/auth0/index.d.ts +1 -1
- package/src/auth0/index.js +50 -50
- package/src/canton/deposits.ts +563 -563
- package/src/canton/helpers.ts +266 -266
- package/src/canton/index.d.ts +41 -41
- package/src/canton/index.js +3472 -3472
- package/src/canton/instrumentCatalog.d.ts +7 -7
- package/src/canton/instrumentCatalog.js +283 -283
- package/src/canton/request_schemas/cancel_orders_amulet.json +77 -77
- package/src/canton/request_schemas/cancel_orders_utility.json +68 -68
- package/src/canton/request_schemas/create_order_proposal_amulet.json +94 -94
- package/src/canton/request_schemas/create_order_proposal_utility.json +121 -121
- package/src/canton/request_schemas/create_utility_credential.json +31 -31
- package/src/canton/request_schemas/execute_transfer_factory.json +43 -43
- package/src/canton/request_schemas/get_allocation_factory.json +21 -21
- package/src/canton/request_schemas/get_amulet_holdings.json +21 -21
- package/src/canton/request_schemas/get_instrument_configurations.json +21 -21
- package/src/canton/request_schemas/get_locked_amulet_holdings.json +21 -21
- package/src/canton/request_schemas/get_order_proposals.json +21 -21
- package/src/canton/request_schemas/get_orders.json +21 -21
- package/src/canton/request_schemas/get_sender_credentials.json +22 -22
- package/src/canton/request_schemas/get_transfer_factory.json +28 -28
- package/src/canton/request_schemas/get_utility_holdings.json +21 -21
- package/src/canton/request_schemas/unlock_amulet.json +38 -38
- package/src/canton/walletAdapter.d.ts +7 -7
- package/src/canton/walletAdapter.js +112 -112
- package/src/canton/withdrawals.ts +511 -511
- package/src/config/index.d.ts +63 -63
- package/src/config/index.js +188 -188
- package/src/websocket/index.ts +341 -341
- package/src/websocket/ws.d.ts +24 -24
package/src/websocket/index.ts
CHANGED
|
@@ -1,341 +1,341 @@
|
|
|
1
|
-
import config from "../../src/config/index.js";
|
|
2
|
-
import NodeWebSocket from "ws";
|
|
3
|
-
|
|
4
|
-
// Environment-aware WebSocket constructor
|
|
5
|
-
const WS: typeof globalThis.WebSocket =
|
|
6
|
-
typeof WebSocket !== "undefined" ? WebSocket : (NodeWebSocket as unknown as typeof WebSocket);
|
|
7
|
-
|
|
8
|
-
// Whether we're in Node.js (can set headers on upgrade) vs browser (must auth via message)
|
|
9
|
-
const isNode = typeof process !== "undefined" && typeof process.versions?.node === "string";
|
|
10
|
-
|
|
11
|
-
type MessageCallback = (data: unknown) => void;
|
|
12
|
-
|
|
13
|
-
export type Granularity = 60 | 300 | 900 | 3600 | 14400 | 86400;
|
|
14
|
-
|
|
15
|
-
/** Normalize CC → Amulet in symbol strings */
|
|
16
|
-
function normalizeSymbol(symbol: string): string {
|
|
17
|
-
return symbol.replace(/\bCC\b/g, "Amulet");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export class TempleWebSocket {
|
|
21
|
-
private ws: InstanceType<typeof WS> | null = null;
|
|
22
|
-
/** Channel subscriptions — require a subscribe message to the server */
|
|
23
|
-
private subscriptions: Map<string, Set<MessageCallback>> = new Map();
|
|
24
|
-
/** User event subscriptions — auto-delivered after auth, no subscribe message needed */
|
|
25
|
-
private userEventSubscribers: Map<string, Set<MessageCallback>> = new Map();
|
|
26
|
-
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
-
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
|
28
|
-
private reconnectAttempts = 0;
|
|
29
|
-
private maxReconnectDelay = 30_000;
|
|
30
|
-
private authenticated = false;
|
|
31
|
-
|
|
32
|
-
/** Whether to automatically reconnect on close/error */
|
|
33
|
-
autoReconnect = true;
|
|
34
|
-
|
|
35
|
-
/** Event callbacks */
|
|
36
|
-
onConnect: (() => void) | null = null;
|
|
37
|
-
onDisconnect: ((code: number, reason: string) => void) | null = null;
|
|
38
|
-
onError: ((error: unknown) => void) | null = null;
|
|
39
|
-
onAuth: ((success: boolean, userId?: number) => void) | null = null;
|
|
40
|
-
|
|
41
|
-
get connected(): boolean {
|
|
42
|
-
return this.ws?.readyState === WS.OPEN;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Connect to the Temple WebSocket server.
|
|
47
|
-
* Auth is handled automatically via header (Node.js) or message (browser).
|
|
48
|
-
*/
|
|
49
|
-
connect(): void {
|
|
50
|
-
if (this.ws && (this.ws.readyState === WS.OPEN || this.ws.readyState === WS.CONNECTING)) {
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const url = config.WS_URL;
|
|
55
|
-
const apiKey = config.API_KEY;
|
|
56
|
-
|
|
57
|
-
if (isNode && apiKey) {
|
|
58
|
-
// Node.js: auth via HTTP upgrade header
|
|
59
|
-
this.ws = new WS(url, { headers: { "X-API-Key": apiKey } } as unknown as string[]);
|
|
60
|
-
} else {
|
|
61
|
-
this.ws = new WS(url);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
this.ws.onopen = () => {
|
|
65
|
-
this.reconnectAttempts = 0;
|
|
66
|
-
|
|
67
|
-
// Browser: auth via message after connect
|
|
68
|
-
if (!isNode && apiKey) {
|
|
69
|
-
this.send({ type: "auth", api_key: apiKey });
|
|
70
|
-
} else {
|
|
71
|
-
this.authenticated = true;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Re-subscribe to all active channels (market data only, not user events)
|
|
75
|
-
const channels = [...this.subscriptions.keys()];
|
|
76
|
-
if (channels.length > 0) {
|
|
77
|
-
this.send({ type: "subscribe", channels });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Keepalive ping every 30s
|
|
81
|
-
this.pingInterval = setInterval(() => {
|
|
82
|
-
if (this.connected) {
|
|
83
|
-
this.send({ type: "ping" });
|
|
84
|
-
}
|
|
85
|
-
}, 30_000);
|
|
86
|
-
|
|
87
|
-
this.onConnect?.();
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
this.ws.onmessage = (event: MessageEvent) => {
|
|
91
|
-
this.handleMessage(typeof event.data === "string" ? event.data : String(event.data));
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
this.ws.onclose = (event: CloseEvent) => {
|
|
95
|
-
this.cleanup();
|
|
96
|
-
this.onDisconnect?.(event.code, event.reason);
|
|
97
|
-
if (this.autoReconnect) {
|
|
98
|
-
this.scheduleReconnect();
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
this.ws.onerror = (event: Event) => {
|
|
103
|
-
this.onError?.(event);
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Disconnect and stop auto-reconnect. */
|
|
108
|
-
disconnect(): void {
|
|
109
|
-
this.autoReconnect = false;
|
|
110
|
-
this.cleanup();
|
|
111
|
-
if (this.ws) {
|
|
112
|
-
this.ws.close(1000, "client disconnect");
|
|
113
|
-
this.ws = null;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Subscribe to a market data channel. Sends a subscribe message to the server.
|
|
119
|
-
* Returns an unsubscribe function.
|
|
120
|
-
*
|
|
121
|
-
* @param channel - Channel pattern (e.g. "orderbook:Amulet/USDCx", "trades:Amulet/USDCx")
|
|
122
|
-
* @param callback - Called with parsed message data for this channel
|
|
123
|
-
* @returns Unsubscribe function
|
|
124
|
-
*/
|
|
125
|
-
subscribe(channel: string, callback: MessageCallback): () => void {
|
|
126
|
-
if (!this.subscriptions.has(channel)) {
|
|
127
|
-
this.subscriptions.set(channel, new Set());
|
|
128
|
-
// Send subscribe if already connected
|
|
129
|
-
if (this.connected) {
|
|
130
|
-
this.send({ type: "subscribe", channels: [channel] });
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
this.subscriptions.get(channel)!.add(callback);
|
|
134
|
-
|
|
135
|
-
// Return unsubscribe function
|
|
136
|
-
return () => this.unsubscribe(channel, callback);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Unsubscribe a callback (or all callbacks) from a market data channel.
|
|
141
|
-
*/
|
|
142
|
-
unsubscribe(channel: string, callback?: MessageCallback): void {
|
|
143
|
-
const callbacks = this.subscriptions.get(channel);
|
|
144
|
-
if (!callbacks) return;
|
|
145
|
-
|
|
146
|
-
if (callback) {
|
|
147
|
-
callbacks.delete(callback);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (!callback || callbacks.size === 0) {
|
|
151
|
-
this.subscriptions.delete(channel);
|
|
152
|
-
if (this.connected) {
|
|
153
|
-
this.send({ type: "unsubscribe", channels: [channel] });
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Subscribe to user events (auto-delivered after auth, no subscribe message sent).
|
|
160
|
-
* The server pushes these automatically once authenticated.
|
|
161
|
-
* Returns an unsubscribe function.
|
|
162
|
-
*
|
|
163
|
-
* @param event - Event name (e.g. "trade", "order", "balance")
|
|
164
|
-
* @param callback - Called with the event data
|
|
165
|
-
* @returns Unsubscribe function
|
|
166
|
-
*/
|
|
167
|
-
onUserEvent(event: string, callback: MessageCallback): () => void {
|
|
168
|
-
if (!this.userEventSubscribers.has(event)) {
|
|
169
|
-
this.userEventSubscribers.set(event, new Set());
|
|
170
|
-
}
|
|
171
|
-
this.userEventSubscribers.get(event)!.add(callback);
|
|
172
|
-
|
|
173
|
-
return () => {
|
|
174
|
-
const handlers = this.userEventSubscribers.get(event);
|
|
175
|
-
if (handlers) {
|
|
176
|
-
handlers.delete(callback);
|
|
177
|
-
if (handlers.size === 0) {
|
|
178
|
-
this.userEventSubscribers.delete(event);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
private send(msg: Record<string, unknown>): void {
|
|
185
|
-
if (this.ws && this.ws.readyState === WS.OPEN) {
|
|
186
|
-
this.ws.send(JSON.stringify(msg));
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
private handleMessage(raw: string): void {
|
|
191
|
-
let parsed: Record<string, unknown>;
|
|
192
|
-
try {
|
|
193
|
-
parsed = JSON.parse(raw);
|
|
194
|
-
} catch {
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const type = parsed.type as string | undefined;
|
|
199
|
-
|
|
200
|
-
// Handle auth response
|
|
201
|
-
if (type === "authenticated") {
|
|
202
|
-
this.authenticated = true;
|
|
203
|
-
this.onAuth?.(true, parsed.user_id as number | undefined);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (type === "auth_expired") {
|
|
207
|
-
this.authenticated = false;
|
|
208
|
-
this.onAuth?.(false);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Handle pong
|
|
213
|
-
if (type === "pong") return;
|
|
214
|
-
|
|
215
|
-
// Handle subscribed/unsubscribed confirmations
|
|
216
|
-
if (type === "subscribed" || type === "unsubscribed") return;
|
|
217
|
-
|
|
218
|
-
// Handle server errors
|
|
219
|
-
if (type === "error") {
|
|
220
|
-
this.onError?.(parsed);
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Route user data (auto-pushed after auth, keyed by event name)
|
|
225
|
-
if (type === "user_data") {
|
|
226
|
-
const event = parsed.event as string | undefined;
|
|
227
|
-
if (event && this.userEventSubscribers.has(event)) {
|
|
228
|
-
const handlers = this.userEventSubscribers.get(event)!;
|
|
229
|
-
for (const cb of handlers) {
|
|
230
|
-
try {
|
|
231
|
-
cb(parsed.data ?? parsed);
|
|
232
|
-
} catch {
|
|
233
|
-
// Don't let one bad callback kill the others
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Route channel data (market data from subscriptions)
|
|
241
|
-
if (type === "data") {
|
|
242
|
-
const channel = parsed.channel as string | undefined;
|
|
243
|
-
if (channel && this.subscriptions.has(channel)) {
|
|
244
|
-
const callbacks = this.subscriptions.get(channel)!;
|
|
245
|
-
for (const cb of callbacks) {
|
|
246
|
-
try {
|
|
247
|
-
cb(parsed.data ?? parsed);
|
|
248
|
-
} catch {
|
|
249
|
-
// Don't let one bad callback kill the others
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
private scheduleReconnect(): void {
|
|
258
|
-
if (this.reconnectTimer) return;
|
|
259
|
-
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
|
|
260
|
-
this.reconnectAttempts++;
|
|
261
|
-
this.reconnectTimer = setTimeout(() => {
|
|
262
|
-
this.reconnectTimer = null;
|
|
263
|
-
this.connect();
|
|
264
|
-
}, delay);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
private cleanup(): void {
|
|
268
|
-
if (this.pingInterval) {
|
|
269
|
-
clearInterval(this.pingInterval);
|
|
270
|
-
this.pingInterval = null;
|
|
271
|
-
}
|
|
272
|
-
if (this.reconnectTimer) {
|
|
273
|
-
clearTimeout(this.reconnectTimer);
|
|
274
|
-
this.reconnectTimer = null;
|
|
275
|
-
}
|
|
276
|
-
this.authenticated = false;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ─── Singleton & Convenience API ─────────────────────────────────────────────
|
|
281
|
-
|
|
282
|
-
let instance: TempleWebSocket | null = null;
|
|
283
|
-
|
|
284
|
-
/** Get or create the shared WebSocket instance. Auto-connects if not already. */
|
|
285
|
-
export function createWebSocket(): TempleWebSocket {
|
|
286
|
-
if (!instance) {
|
|
287
|
-
instance = new TempleWebSocket();
|
|
288
|
-
}
|
|
289
|
-
if (!instance.connected) {
|
|
290
|
-
instance.connect();
|
|
291
|
-
}
|
|
292
|
-
return instance;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** Disconnect and destroy the shared WebSocket instance. */
|
|
296
|
-
export function disconnectWebSocket(): void {
|
|
297
|
-
if (instance) {
|
|
298
|
-
instance.disconnect();
|
|
299
|
-
instance = null;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ─── Market Data Subscriptions ───────────────────────────────────────────────
|
|
304
|
-
|
|
305
|
-
export function subscribeOrderbook(symbol: string, cb: MessageCallback): () => void {
|
|
306
|
-
return createWebSocket().subscribe(`orderbook:${normalizeSymbol(symbol)}`, cb);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
export function subscribeTrades(symbol: string, cb: MessageCallback): () => void {
|
|
310
|
-
return createWebSocket().subscribe(`trades:${normalizeSymbol(symbol)}`, cb);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export function subscribeTicker(symbol: string, cb: MessageCallback): () => void {
|
|
314
|
-
return createWebSocket().subscribe(`ticker:${normalizeSymbol(symbol)}`, cb);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
export function subscribeCandles(symbol: string, granularity: Granularity, cb: MessageCallback): () => void {
|
|
318
|
-
return createWebSocket().subscribe(`candles:${normalizeSymbol(symbol)}:${granularity}`, cb);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export function subscribeOracle(symbol: string, cb: MessageCallback): () => void {
|
|
322
|
-
return createWebSocket().subscribe(`oracle:${symbol}`, cb);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
export function subscribeOracleVolume(symbol: string, cb: MessageCallback): () => void {
|
|
326
|
-
return createWebSocket().subscribe(`oracle_volume:${symbol}`, cb);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ─── User Data Subscriptions (auto-delivered after auth) ─────────────────────
|
|
330
|
-
|
|
331
|
-
export function subscribeUserOrders(cb: MessageCallback): () => void {
|
|
332
|
-
return createWebSocket().onUserEvent("user_order", cb);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export function subscribeUserTrades(cb: MessageCallback): () => void {
|
|
336
|
-
return createWebSocket().onUserEvent("user_trade", cb);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
export function subscribeUserBalances(cb: MessageCallback): () => void {
|
|
340
|
-
return createWebSocket().onUserEvent("user_balance", cb);
|
|
341
|
-
}
|
|
1
|
+
import config from "../../src/config/index.js";
|
|
2
|
+
import NodeWebSocket from "ws";
|
|
3
|
+
|
|
4
|
+
// Environment-aware WebSocket constructor
|
|
5
|
+
const WS: typeof globalThis.WebSocket =
|
|
6
|
+
typeof WebSocket !== "undefined" ? WebSocket : (NodeWebSocket as unknown as typeof WebSocket);
|
|
7
|
+
|
|
8
|
+
// Whether we're in Node.js (can set headers on upgrade) vs browser (must auth via message)
|
|
9
|
+
const isNode = typeof process !== "undefined" && typeof process.versions?.node === "string";
|
|
10
|
+
|
|
11
|
+
type MessageCallback = (data: unknown) => void;
|
|
12
|
+
|
|
13
|
+
export type Granularity = 60 | 300 | 900 | 3600 | 14400 | 86400;
|
|
14
|
+
|
|
15
|
+
/** Normalize CC → Amulet in symbol strings */
|
|
16
|
+
function normalizeSymbol(symbol: string): string {
|
|
17
|
+
return symbol.replace(/\bCC\b/g, "Amulet");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class TempleWebSocket {
|
|
21
|
+
private ws: InstanceType<typeof WS> | null = null;
|
|
22
|
+
/** Channel subscriptions — require a subscribe message to the server */
|
|
23
|
+
private subscriptions: Map<string, Set<MessageCallback>> = new Map();
|
|
24
|
+
/** User event subscriptions — auto-delivered after auth, no subscribe message needed */
|
|
25
|
+
private userEventSubscribers: Map<string, Set<MessageCallback>> = new Map();
|
|
26
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
|
28
|
+
private reconnectAttempts = 0;
|
|
29
|
+
private maxReconnectDelay = 30_000;
|
|
30
|
+
private authenticated = false;
|
|
31
|
+
|
|
32
|
+
/** Whether to automatically reconnect on close/error */
|
|
33
|
+
autoReconnect = true;
|
|
34
|
+
|
|
35
|
+
/** Event callbacks */
|
|
36
|
+
onConnect: (() => void) | null = null;
|
|
37
|
+
onDisconnect: ((code: number, reason: string) => void) | null = null;
|
|
38
|
+
onError: ((error: unknown) => void) | null = null;
|
|
39
|
+
onAuth: ((success: boolean, userId?: number) => void) | null = null;
|
|
40
|
+
|
|
41
|
+
get connected(): boolean {
|
|
42
|
+
return this.ws?.readyState === WS.OPEN;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Connect to the Temple WebSocket server.
|
|
47
|
+
* Auth is handled automatically via header (Node.js) or message (browser).
|
|
48
|
+
*/
|
|
49
|
+
connect(): void {
|
|
50
|
+
if (this.ws && (this.ws.readyState === WS.OPEN || this.ws.readyState === WS.CONNECTING)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const url = config.WS_URL;
|
|
55
|
+
const apiKey = config.API_KEY;
|
|
56
|
+
|
|
57
|
+
if (isNode && apiKey) {
|
|
58
|
+
// Node.js: auth via HTTP upgrade header
|
|
59
|
+
this.ws = new WS(url, { headers: { "X-API-Key": apiKey } } as unknown as string[]);
|
|
60
|
+
} else {
|
|
61
|
+
this.ws = new WS(url);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.ws.onopen = () => {
|
|
65
|
+
this.reconnectAttempts = 0;
|
|
66
|
+
|
|
67
|
+
// Browser: auth via message after connect
|
|
68
|
+
if (!isNode && apiKey) {
|
|
69
|
+
this.send({ type: "auth", api_key: apiKey });
|
|
70
|
+
} else {
|
|
71
|
+
this.authenticated = true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Re-subscribe to all active channels (market data only, not user events)
|
|
75
|
+
const channels = [...this.subscriptions.keys()];
|
|
76
|
+
if (channels.length > 0) {
|
|
77
|
+
this.send({ type: "subscribe", channels });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Keepalive ping every 30s
|
|
81
|
+
this.pingInterval = setInterval(() => {
|
|
82
|
+
if (this.connected) {
|
|
83
|
+
this.send({ type: "ping" });
|
|
84
|
+
}
|
|
85
|
+
}, 30_000);
|
|
86
|
+
|
|
87
|
+
this.onConnect?.();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
this.ws.onmessage = (event: MessageEvent) => {
|
|
91
|
+
this.handleMessage(typeof event.data === "string" ? event.data : String(event.data));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
this.ws.onclose = (event: CloseEvent) => {
|
|
95
|
+
this.cleanup();
|
|
96
|
+
this.onDisconnect?.(event.code, event.reason);
|
|
97
|
+
if (this.autoReconnect) {
|
|
98
|
+
this.scheduleReconnect();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
this.ws.onerror = (event: Event) => {
|
|
103
|
+
this.onError?.(event);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Disconnect and stop auto-reconnect. */
|
|
108
|
+
disconnect(): void {
|
|
109
|
+
this.autoReconnect = false;
|
|
110
|
+
this.cleanup();
|
|
111
|
+
if (this.ws) {
|
|
112
|
+
this.ws.close(1000, "client disconnect");
|
|
113
|
+
this.ws = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Subscribe to a market data channel. Sends a subscribe message to the server.
|
|
119
|
+
* Returns an unsubscribe function.
|
|
120
|
+
*
|
|
121
|
+
* @param channel - Channel pattern (e.g. "orderbook:Amulet/USDCx", "trades:Amulet/USDCx")
|
|
122
|
+
* @param callback - Called with parsed message data for this channel
|
|
123
|
+
* @returns Unsubscribe function
|
|
124
|
+
*/
|
|
125
|
+
subscribe(channel: string, callback: MessageCallback): () => void {
|
|
126
|
+
if (!this.subscriptions.has(channel)) {
|
|
127
|
+
this.subscriptions.set(channel, new Set());
|
|
128
|
+
// Send subscribe if already connected
|
|
129
|
+
if (this.connected) {
|
|
130
|
+
this.send({ type: "subscribe", channels: [channel] });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
this.subscriptions.get(channel)!.add(callback);
|
|
134
|
+
|
|
135
|
+
// Return unsubscribe function
|
|
136
|
+
return () => this.unsubscribe(channel, callback);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Unsubscribe a callback (or all callbacks) from a market data channel.
|
|
141
|
+
*/
|
|
142
|
+
unsubscribe(channel: string, callback?: MessageCallback): void {
|
|
143
|
+
const callbacks = this.subscriptions.get(channel);
|
|
144
|
+
if (!callbacks) return;
|
|
145
|
+
|
|
146
|
+
if (callback) {
|
|
147
|
+
callbacks.delete(callback);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!callback || callbacks.size === 0) {
|
|
151
|
+
this.subscriptions.delete(channel);
|
|
152
|
+
if (this.connected) {
|
|
153
|
+
this.send({ type: "unsubscribe", channels: [channel] });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Subscribe to user events (auto-delivered after auth, no subscribe message sent).
|
|
160
|
+
* The server pushes these automatically once authenticated.
|
|
161
|
+
* Returns an unsubscribe function.
|
|
162
|
+
*
|
|
163
|
+
* @param event - Event name (e.g. "trade", "order", "balance")
|
|
164
|
+
* @param callback - Called with the event data
|
|
165
|
+
* @returns Unsubscribe function
|
|
166
|
+
*/
|
|
167
|
+
onUserEvent(event: string, callback: MessageCallback): () => void {
|
|
168
|
+
if (!this.userEventSubscribers.has(event)) {
|
|
169
|
+
this.userEventSubscribers.set(event, new Set());
|
|
170
|
+
}
|
|
171
|
+
this.userEventSubscribers.get(event)!.add(callback);
|
|
172
|
+
|
|
173
|
+
return () => {
|
|
174
|
+
const handlers = this.userEventSubscribers.get(event);
|
|
175
|
+
if (handlers) {
|
|
176
|
+
handlers.delete(callback);
|
|
177
|
+
if (handlers.size === 0) {
|
|
178
|
+
this.userEventSubscribers.delete(event);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private send(msg: Record<string, unknown>): void {
|
|
185
|
+
if (this.ws && this.ws.readyState === WS.OPEN) {
|
|
186
|
+
this.ws.send(JSON.stringify(msg));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private handleMessage(raw: string): void {
|
|
191
|
+
let parsed: Record<string, unknown>;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(raw);
|
|
194
|
+
} catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const type = parsed.type as string | undefined;
|
|
199
|
+
|
|
200
|
+
// Handle auth response
|
|
201
|
+
if (type === "authenticated") {
|
|
202
|
+
this.authenticated = true;
|
|
203
|
+
this.onAuth?.(true, parsed.user_id as number | undefined);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (type === "auth_expired") {
|
|
207
|
+
this.authenticated = false;
|
|
208
|
+
this.onAuth?.(false);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Handle pong
|
|
213
|
+
if (type === "pong") return;
|
|
214
|
+
|
|
215
|
+
// Handle subscribed/unsubscribed confirmations
|
|
216
|
+
if (type === "subscribed" || type === "unsubscribed") return;
|
|
217
|
+
|
|
218
|
+
// Handle server errors
|
|
219
|
+
if (type === "error") {
|
|
220
|
+
this.onError?.(parsed);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Route user data (auto-pushed after auth, keyed by event name)
|
|
225
|
+
if (type === "user_data") {
|
|
226
|
+
const event = parsed.event as string | undefined;
|
|
227
|
+
if (event && this.userEventSubscribers.has(event)) {
|
|
228
|
+
const handlers = this.userEventSubscribers.get(event)!;
|
|
229
|
+
for (const cb of handlers) {
|
|
230
|
+
try {
|
|
231
|
+
cb(parsed.data ?? parsed);
|
|
232
|
+
} catch {
|
|
233
|
+
// Don't let one bad callback kill the others
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Route channel data (market data from subscriptions)
|
|
241
|
+
if (type === "data") {
|
|
242
|
+
const channel = parsed.channel as string | undefined;
|
|
243
|
+
if (channel && this.subscriptions.has(channel)) {
|
|
244
|
+
const callbacks = this.subscriptions.get(channel)!;
|
|
245
|
+
for (const cb of callbacks) {
|
|
246
|
+
try {
|
|
247
|
+
cb(parsed.data ?? parsed);
|
|
248
|
+
} catch {
|
|
249
|
+
// Don't let one bad callback kill the others
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private scheduleReconnect(): void {
|
|
258
|
+
if (this.reconnectTimer) return;
|
|
259
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay);
|
|
260
|
+
this.reconnectAttempts++;
|
|
261
|
+
this.reconnectTimer = setTimeout(() => {
|
|
262
|
+
this.reconnectTimer = null;
|
|
263
|
+
this.connect();
|
|
264
|
+
}, delay);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private cleanup(): void {
|
|
268
|
+
if (this.pingInterval) {
|
|
269
|
+
clearInterval(this.pingInterval);
|
|
270
|
+
this.pingInterval = null;
|
|
271
|
+
}
|
|
272
|
+
if (this.reconnectTimer) {
|
|
273
|
+
clearTimeout(this.reconnectTimer);
|
|
274
|
+
this.reconnectTimer = null;
|
|
275
|
+
}
|
|
276
|
+
this.authenticated = false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Singleton & Convenience API ─────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
let instance: TempleWebSocket | null = null;
|
|
283
|
+
|
|
284
|
+
/** Get or create the shared WebSocket instance. Auto-connects if not already. */
|
|
285
|
+
export function createWebSocket(): TempleWebSocket {
|
|
286
|
+
if (!instance) {
|
|
287
|
+
instance = new TempleWebSocket();
|
|
288
|
+
}
|
|
289
|
+
if (!instance.connected) {
|
|
290
|
+
instance.connect();
|
|
291
|
+
}
|
|
292
|
+
return instance;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Disconnect and destroy the shared WebSocket instance. */
|
|
296
|
+
export function disconnectWebSocket(): void {
|
|
297
|
+
if (instance) {
|
|
298
|
+
instance.disconnect();
|
|
299
|
+
instance = null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Market Data Subscriptions ───────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
export function subscribeOrderbook(symbol: string, cb: MessageCallback): () => void {
|
|
306
|
+
return createWebSocket().subscribe(`orderbook:${normalizeSymbol(symbol)}`, cb);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function subscribeTrades(symbol: string, cb: MessageCallback): () => void {
|
|
310
|
+
return createWebSocket().subscribe(`trades:${normalizeSymbol(symbol)}`, cb);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function subscribeTicker(symbol: string, cb: MessageCallback): () => void {
|
|
314
|
+
return createWebSocket().subscribe(`ticker:${normalizeSymbol(symbol)}`, cb);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function subscribeCandles(symbol: string, granularity: Granularity, cb: MessageCallback): () => void {
|
|
318
|
+
return createWebSocket().subscribe(`candles:${normalizeSymbol(symbol)}:${granularity}`, cb);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function subscribeOracle(symbol: string, cb: MessageCallback): () => void {
|
|
322
|
+
return createWebSocket().subscribe(`oracle:${symbol}`, cb);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function subscribeOracleVolume(symbol: string, cb: MessageCallback): () => void {
|
|
326
|
+
return createWebSocket().subscribe(`oracle_volume:${symbol}`, cb);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── User Data Subscriptions (auto-delivered after auth) ─────────────────────
|
|
330
|
+
|
|
331
|
+
export function subscribeUserOrders(cb: MessageCallback): () => void {
|
|
332
|
+
return createWebSocket().onUserEvent("user_order", cb);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function subscribeUserTrades(cb: MessageCallback): () => void {
|
|
336
|
+
return createWebSocket().onUserEvent("user_trade", cb);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function subscribeUserBalances(cb: MessageCallback): () => void {
|
|
340
|
+
return createWebSocket().onUserEvent("user_balance", cb);
|
|
341
|
+
}
|