pmxtjs 2.35.29 → 2.35.32
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/esm/generated/src/apis/DefaultApi.d.ts +33 -1
- package/dist/esm/generated/src/apis/DefaultApi.js +48 -1
- package/dist/esm/generated/src/models/WatchOrderBooks200Response.d.ts +48 -0
- package/dist/esm/generated/src/models/WatchOrderBooks200Response.js +48 -0
- package/dist/esm/generated/src/models/WatchOrderBooksRequest.d.ts +40 -0
- package/dist/esm/generated/src/models/WatchOrderBooksRequest.js +47 -0
- package/dist/esm/generated/src/models/WatchOrderBooksRequestArgsInner.d.ts +21 -0
- package/dist/esm/generated/src/models/WatchOrderBooksRequestArgsInner.js +47 -0
- package/dist/esm/generated/src/models/index.d.ts +3 -0
- package/dist/esm/generated/src/models/index.js +3 -0
- package/dist/esm/pmxt/client.d.ts +42 -0
- package/dist/esm/pmxt/client.js +149 -4
- package/dist/esm/pmxt/ws-client.d.ts +37 -0
- package/dist/esm/pmxt/ws-client.js +272 -0
- package/dist/generated/src/apis/DefaultApi.d.ts +33 -1
- package/dist/generated/src/apis/DefaultApi.js +48 -1
- package/dist/generated/src/models/WatchOrderBooks200Response.d.ts +48 -0
- package/dist/generated/src/models/WatchOrderBooks200Response.js +55 -0
- package/dist/generated/src/models/WatchOrderBooksRequest.d.ts +40 -0
- package/dist/generated/src/models/WatchOrderBooksRequest.js +54 -0
- package/dist/generated/src/models/WatchOrderBooksRequestArgsInner.d.ts +21 -0
- package/dist/generated/src/models/WatchOrderBooksRequestArgsInner.js +53 -0
- package/dist/generated/src/models/index.d.ts +3 -0
- package/dist/generated/src/models/index.js +3 -0
- package/dist/pmxt/client.d.ts +42 -0
- package/dist/pmxt/client.js +149 -4
- package/dist/pmxt/ws-client.d.ts +37 -0
- package/dist/pmxt/ws-client.js +276 -0
- package/generated/.openapi-generator/FILES +6 -0
- package/generated/docs/DefaultApi.md +71 -0
- package/generated/docs/WatchOrderBooks200Response.md +38 -0
- package/generated/docs/WatchOrderBooksRequest.md +36 -0
- package/generated/docs/WatchOrderBooksRequestArgsInner.md +32 -0
- package/generated/package.json +1 -1
- package/generated/src/apis/DefaultApi.ts +71 -0
- package/generated/src/models/WatchOrderBooks200Response.ts +96 -0
- package/generated/src/models/WatchOrderBooksRequest.ts +89 -0
- package/generated/src/models/WatchOrderBooksRequestArgsInner.ts +59 -0
- package/generated/src/models/index.ts +3 -0
- package/package.json +2 -2
- package/pmxt/client.ts +181 -8
- package/pmxt/ws-client.ts +347 -0
package/pmxt/client.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { ServerManager } from "./server-manager.js";
|
|
|
44
44
|
import { buildArgsWithOptionalOptions } from "./args.js";
|
|
45
45
|
import { PmxtError, fromServerError } from "./errors.js";
|
|
46
46
|
import { LOCAL_URL, resolvePmxtBaseUrl } from "./constants.js";
|
|
47
|
+
import { SidecarWsClient } from "./ws-client.js";
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* Resolve a MarketOutcome shorthand to a plain outcome ID string.
|
|
@@ -348,6 +349,11 @@ export abstract class Exchange {
|
|
|
348
349
|
*/
|
|
349
350
|
private _getReadsUnsupported: boolean = false;
|
|
350
351
|
|
|
352
|
+
/** Shared WebSocket client for streaming methods (lazy). */
|
|
353
|
+
private _wsClient: SidecarWsClient | null = null;
|
|
354
|
+
/** Sticky flag: true if the sidecar /ws endpoint is unavailable. */
|
|
355
|
+
private _wsUnsupported: boolean = false;
|
|
356
|
+
|
|
351
357
|
constructor(exchangeName: string, options: ExchangeOptions = {}) {
|
|
352
358
|
this.exchangeName = exchangeName.toLowerCase();
|
|
353
359
|
this.apiKey = options.apiKey;
|
|
@@ -502,6 +508,73 @@ export abstract class Exchange {
|
|
|
502
508
|
throw lastError;
|
|
503
509
|
}
|
|
504
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Return the shared WebSocket client, creating it on first use.
|
|
513
|
+
*
|
|
514
|
+
* Returns `null` if the sidecar /ws endpoint was previously found
|
|
515
|
+
* to be unavailable, letting callers fall back to HTTP.
|
|
516
|
+
*/
|
|
517
|
+
private async getOrCreateWs(): Promise<SidecarWsClient | null> {
|
|
518
|
+
if (this._wsUnsupported) return null;
|
|
519
|
+
if (this._wsClient?.connected) return this._wsClient;
|
|
520
|
+
|
|
521
|
+
const host = this.resolveBaseUrl();
|
|
522
|
+
const accessToken = this.serverManager.getAccessToken();
|
|
523
|
+
|
|
524
|
+
const client = new SidecarWsClient(host, accessToken || undefined);
|
|
525
|
+
try {
|
|
526
|
+
// Trigger connection to validate the endpoint exists.
|
|
527
|
+
// subscribe() calls ensureConnected internally, but we want
|
|
528
|
+
// to detect failure eagerly so we can set _wsUnsupported.
|
|
529
|
+
await client.subscribe(
|
|
530
|
+
this.exchangeName,
|
|
531
|
+
"_ping",
|
|
532
|
+
[],
|
|
533
|
+
undefined,
|
|
534
|
+
3000,
|
|
535
|
+
).catch(() => {
|
|
536
|
+
// Expected -- no _ping method. The connection itself
|
|
537
|
+
// succeeded if we got a WS error frame back. If the
|
|
538
|
+
// connection itself failed, we'll catch below.
|
|
539
|
+
});
|
|
540
|
+
// If we got here without the connect promise rejecting,
|
|
541
|
+
// the WS endpoint exists.
|
|
542
|
+
if (!client.connected) {
|
|
543
|
+
throw new Error("WS handshake failed");
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
this._wsUnsupported = true;
|
|
547
|
+
client.close();
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
this._wsClient = client;
|
|
552
|
+
return this._wsClient;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Attempt to use the WS transport for a watch method.
|
|
557
|
+
* Returns the raw data on success, or `null` if WS is unavailable.
|
|
558
|
+
*/
|
|
559
|
+
private async watchViaWs(
|
|
560
|
+
method: string,
|
|
561
|
+
args: any[],
|
|
562
|
+
): Promise<any | null> {
|
|
563
|
+
const ws = await this.getOrCreateWs();
|
|
564
|
+
if (!ws) return null;
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
return await ws.subscribe(
|
|
568
|
+
this.exchangeName,
|
|
569
|
+
method,
|
|
570
|
+
args,
|
|
571
|
+
this.getCredentials() as Record<string, any> | undefined,
|
|
572
|
+
);
|
|
573
|
+
} catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
505
578
|
// Low-Level API Access
|
|
506
579
|
|
|
507
580
|
/**
|
|
@@ -1432,12 +1505,19 @@ export abstract class Exchange {
|
|
|
1432
1505
|
async watchOrderBook(outcomeId: string | MarketOutcome, limit?: number): Promise<OrderBook> {
|
|
1433
1506
|
await this.initPromise;
|
|
1434
1507
|
const resolvedOutcomeId = resolveOutcomeId(outcomeId);
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
}
|
|
1508
|
+
const args: any[] = [resolvedOutcomeId];
|
|
1509
|
+
if (limit !== undefined) {
|
|
1510
|
+
args.push(limit);
|
|
1511
|
+
}
|
|
1440
1512
|
|
|
1513
|
+
// Try WebSocket transport first
|
|
1514
|
+
const wsData = await this.watchViaWs("watchOrderBook", args);
|
|
1515
|
+
if (wsData !== null) {
|
|
1516
|
+
return convertOrderBook(wsData);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// HTTP fallback
|
|
1520
|
+
try {
|
|
1441
1521
|
const response = await this.fetchWithRetry(`${this.resolveBaseUrl()}/api/${this.exchangeName}/watchOrderBook`, {
|
|
1442
1522
|
method: 'POST',
|
|
1443
1523
|
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
|
@@ -1459,18 +1539,111 @@ export abstract class Exchange {
|
|
|
1459
1539
|
}
|
|
1460
1540
|
}
|
|
1461
1541
|
|
|
1542
|
+
/**
|
|
1543
|
+
* Watch real-time order book updates for multiple outcomes at once.
|
|
1544
|
+
*
|
|
1545
|
+
* Returns a record mapping each outcome ID (ticker) to its latest
|
|
1546
|
+
* order book snapshot. Call repeatedly in a loop to stream updates
|
|
1547
|
+
* (CCXT Pro pattern).
|
|
1548
|
+
*
|
|
1549
|
+
* Prefers the sidecar WebSocket transport when available, falling
|
|
1550
|
+
* back to HTTP POST for older sidecars.
|
|
1551
|
+
*
|
|
1552
|
+
* @param outcomeIds - Array of outcome IDs (or MarketOutcome objects)
|
|
1553
|
+
* @param limit - Optional depth limit for each order book
|
|
1554
|
+
* @returns Record mapping ticker to OrderBook
|
|
1555
|
+
*
|
|
1556
|
+
* @example
|
|
1557
|
+
* ```typescript
|
|
1558
|
+
* const ids = markets.slice(0, 3).map(m => m.outcomes[0].outcomeId);
|
|
1559
|
+
* while (true) {
|
|
1560
|
+
* const books = await exchange.watchOrderBooks(ids);
|
|
1561
|
+
* for (const [ticker, ob] of Object.entries(books)) {
|
|
1562
|
+
* console.log(`${ticker}: bid=${ob.bids[0]?.price}`);
|
|
1563
|
+
* }
|
|
1564
|
+
* }
|
|
1565
|
+
* ```
|
|
1566
|
+
*/
|
|
1567
|
+
async watchOrderBooks(
|
|
1568
|
+
outcomeIds: (string | MarketOutcome)[],
|
|
1569
|
+
limit?: number,
|
|
1570
|
+
): Promise<Record<string, OrderBook>> {
|
|
1571
|
+
await this.initPromise;
|
|
1572
|
+
const resolvedIds = outcomeIds.map(resolveOutcomeId);
|
|
1573
|
+
const args: any[] = [resolvedIds];
|
|
1574
|
+
if (limit !== undefined) {
|
|
1575
|
+
args.push(limit);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Try WebSocket transport first
|
|
1579
|
+
const ws = await this.getOrCreateWs();
|
|
1580
|
+
if (ws) {
|
|
1581
|
+
try {
|
|
1582
|
+
const rawResult = await ws.subscribeBatch(
|
|
1583
|
+
this.exchangeName,
|
|
1584
|
+
"watchOrderBooks",
|
|
1585
|
+
args,
|
|
1586
|
+
this.getCredentials() as Record<string, any> | undefined,
|
|
1587
|
+
);
|
|
1588
|
+
if (rawResult && typeof rawResult === "object") {
|
|
1589
|
+
const result: Record<string, OrderBook> = {};
|
|
1590
|
+
for (const [k, v] of Object.entries(rawResult)) {
|
|
1591
|
+
if (v && typeof v === "object") {
|
|
1592
|
+
result[k] = convertOrderBook(v);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
return result;
|
|
1596
|
+
}
|
|
1597
|
+
} catch {
|
|
1598
|
+
// fall through to HTTP
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// HTTP fallback
|
|
1603
|
+
try {
|
|
1604
|
+
const response = await this.fetchWithRetry(
|
|
1605
|
+
`${this.resolveBaseUrl()}/api/${this.exchangeName}/watchOrderBooks`,
|
|
1606
|
+
{
|
|
1607
|
+
method: 'POST',
|
|
1608
|
+
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
|
1609
|
+
body: JSON.stringify({ args, credentials: this.getCredentials() }),
|
|
1610
|
+
},
|
|
1611
|
+
);
|
|
1612
|
+
if (!response.ok) {
|
|
1613
|
+
const body = await response.json().catch(() => ({}));
|
|
1614
|
+
if (body.error && typeof body.error === "object") {
|
|
1615
|
+
throw fromServerError(body.error);
|
|
1616
|
+
}
|
|
1617
|
+
throw new PmxtError(body.error?.message || response.statusText);
|
|
1618
|
+
}
|
|
1619
|
+
const json = await response.json();
|
|
1620
|
+
const data = this.handleResponse(json);
|
|
1621
|
+
if (data && typeof data === "object") {
|
|
1622
|
+
const result: Record<string, OrderBook> = {};
|
|
1623
|
+
for (const [k, v] of Object.entries(data as Record<string, any>)) {
|
|
1624
|
+
result[k] = convertOrderBook(v);
|
|
1625
|
+
}
|
|
1626
|
+
return result;
|
|
1627
|
+
}
|
|
1628
|
+
return {};
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
if (error instanceof PmxtError) throw error;
|
|
1631
|
+
throw new PmxtError(`Failed to watch order books: ${error}`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1462
1635
|
/**
|
|
1463
1636
|
* Watch real-time trade updates via WebSocket.
|
|
1464
|
-
*
|
|
1637
|
+
*
|
|
1465
1638
|
* Returns a promise that resolves with the next trade(s).
|
|
1466
1639
|
* Call repeatedly in a loop to stream updates (CCXT Pro pattern).
|
|
1467
|
-
*
|
|
1640
|
+
*
|
|
1468
1641
|
* @param outcomeId - Outcome ID to watch
|
|
1469
1642
|
* @param address - Public wallet to be watched
|
|
1470
1643
|
* @param since - Optional timestamp to filter trades from
|
|
1471
1644
|
* @param limit - Optional limit for number of trades
|
|
1472
1645
|
* @returns Next trade update(s)
|
|
1473
|
-
*
|
|
1646
|
+
*
|
|
1474
1647
|
* @example
|
|
1475
1648
|
* ```typescript
|
|
1476
1649
|
* // Stream trade updates
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket client for streaming methods.
|
|
3
|
+
*
|
|
4
|
+
* Provides a multiplexed WebSocket connection to the sidecar server,
|
|
5
|
+
* used by watchOrderBook and watchOrderBooks as an alternative to
|
|
6
|
+
* HTTP long-polling. Falls back to HTTP transparently when the sidecar
|
|
7
|
+
* does not support the /ws endpoint.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { PmxtError } from "./errors.js";
|
|
11
|
+
|
|
12
|
+
interface WsSubscription {
|
|
13
|
+
readonly requestId: string;
|
|
14
|
+
readonly method: string;
|
|
15
|
+
readonly symbols: string[];
|
|
16
|
+
resolve: ((data: any) => void) | null;
|
|
17
|
+
reject: ((error: Error) => void) | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface WsMessage {
|
|
21
|
+
id?: string;
|
|
22
|
+
event?: string;
|
|
23
|
+
method?: string;
|
|
24
|
+
symbol?: string;
|
|
25
|
+
data?: any;
|
|
26
|
+
error?: { message?: string; code?: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Multiplexed WebSocket client for the pmxt sidecar.
|
|
31
|
+
*
|
|
32
|
+
* Lazily connects to ws://{host}/ws?token={accessToken}. A single
|
|
33
|
+
* WebSocket connection is shared across all streaming subscriptions.
|
|
34
|
+
*/
|
|
35
|
+
export class SidecarWsClient {
|
|
36
|
+
private ws: WebSocket | null = null;
|
|
37
|
+
private host: string;
|
|
38
|
+
private accessToken: string | undefined;
|
|
39
|
+
private closed = false;
|
|
40
|
+
|
|
41
|
+
/** requestId -> latest data payload */
|
|
42
|
+
private dataStore: Map<string, any> = new Map();
|
|
43
|
+
/** requestId -> subscription metadata */
|
|
44
|
+
private subscriptions: Map<string, WsSubscription> = new Map();
|
|
45
|
+
/** (method:symbolKey) -> requestId -- avoids duplicate subscribes */
|
|
46
|
+
private activeSubs: Map<string, string> = new Map();
|
|
47
|
+
|
|
48
|
+
private connectPromise: Promise<void> | null = null;
|
|
49
|
+
|
|
50
|
+
constructor(host: string, accessToken?: string) {
|
|
51
|
+
this.host = host;
|
|
52
|
+
this.accessToken = accessToken;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------------
|
|
56
|
+
// Connection lifecycle
|
|
57
|
+
// ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
private async ensureConnected(): Promise<void> {
|
|
60
|
+
if (this.ws && !this.closed) return;
|
|
61
|
+
if (this.connectPromise) return this.connectPromise;
|
|
62
|
+
|
|
63
|
+
this.connectPromise = this.connect();
|
|
64
|
+
try {
|
|
65
|
+
await this.connectPromise;
|
|
66
|
+
} finally {
|
|
67
|
+
this.connectPromise = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private connect(): Promise<void> {
|
|
72
|
+
return new Promise<void>((resolve, reject) => {
|
|
73
|
+
let hostPart = this.host;
|
|
74
|
+
let scheme = "ws";
|
|
75
|
+
if (hostPart.startsWith("https://")) {
|
|
76
|
+
hostPart = hostPart.slice("https://".length);
|
|
77
|
+
scheme = "wss";
|
|
78
|
+
} else if (hostPart.startsWith("http://")) {
|
|
79
|
+
hostPart = hostPart.slice("http://".length);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let url = `${scheme}://${hostPart}/ws`;
|
|
83
|
+
if (this.accessToken) {
|
|
84
|
+
url = `${url}?token=${this.accessToken}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Use the ws package in Node.js, native WebSocket in browsers
|
|
88
|
+
const WsConstructor = this.getWebSocketConstructor();
|
|
89
|
+
if (!WsConstructor) {
|
|
90
|
+
reject(new PmxtError("No WebSocket implementation available"));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ws = new WsConstructor(url);
|
|
95
|
+
this.closed = false;
|
|
96
|
+
|
|
97
|
+
ws.onopen = () => {
|
|
98
|
+
this.ws = ws;
|
|
99
|
+
resolve();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
ws.onerror = (err: any) => {
|
|
103
|
+
if (!this.ws) {
|
|
104
|
+
// Connection failed during handshake
|
|
105
|
+
reject(new PmxtError(`WebSocket connection failed: ${err.message || err}`));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
ws.onclose = () => {
|
|
110
|
+
this.closed = true;
|
|
111
|
+
this.ws = null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
ws.onmessage = (event: any) => {
|
|
115
|
+
try {
|
|
116
|
+
const data = typeof event.data === "string"
|
|
117
|
+
? event.data
|
|
118
|
+
: event.data.toString();
|
|
119
|
+
const msg: WsMessage = JSON.parse(data);
|
|
120
|
+
this.dispatch(msg);
|
|
121
|
+
} catch {
|
|
122
|
+
// Ignore unparseable frames
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private getWebSocketConstructor(): (new (url: string) => WebSocket) | null {
|
|
129
|
+
// Browser / Deno / Bun
|
|
130
|
+
if (typeof globalThis !== "undefined" && (globalThis as any).WebSocket) {
|
|
131
|
+
return (globalThis as any).WebSocket;
|
|
132
|
+
}
|
|
133
|
+
// Node.js -- try to require ws
|
|
134
|
+
try {
|
|
135
|
+
// Dynamic require to avoid bundler issues
|
|
136
|
+
const wsModule = require("ws");
|
|
137
|
+
return wsModule.default || wsModule;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private dispatch(msg: WsMessage): void {
|
|
144
|
+
const eventType = msg.event;
|
|
145
|
+
const requestId = msg.id;
|
|
146
|
+
|
|
147
|
+
if (eventType === "error" && requestId) {
|
|
148
|
+
const sub = this.subscriptions.get(requestId);
|
|
149
|
+
if (sub?.reject) {
|
|
150
|
+
sub.reject(new PmxtError(
|
|
151
|
+
msg.error?.message || "WebSocket subscription error"
|
|
152
|
+
));
|
|
153
|
+
sub.reject = null;
|
|
154
|
+
sub.resolve = null;
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (eventType === "subscribed") {
|
|
160
|
+
// Acknowledgement -- nothing to do
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (eventType === "data" && requestId) {
|
|
165
|
+
const symbol = msg.symbol || "";
|
|
166
|
+
const data = msg.data || {};
|
|
167
|
+
|
|
168
|
+
// Store by (requestId:symbol) for batch methods
|
|
169
|
+
this.dataStore.set(`${requestId}:${symbol}`, data);
|
|
170
|
+
// Store by requestId alone for single-symbol methods
|
|
171
|
+
this.dataStore.set(requestId, data);
|
|
172
|
+
|
|
173
|
+
const sub = this.subscriptions.get(requestId);
|
|
174
|
+
if (sub?.resolve) {
|
|
175
|
+
sub.resolve(data);
|
|
176
|
+
sub.resolve = null;
|
|
177
|
+
sub.reject = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ------------------------------------------------------------------
|
|
183
|
+
// Public API
|
|
184
|
+
// ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
async subscribe(
|
|
187
|
+
exchange: string,
|
|
188
|
+
method: string,
|
|
189
|
+
args: any[],
|
|
190
|
+
credentials?: Record<string, any>,
|
|
191
|
+
timeoutMs = 30000,
|
|
192
|
+
): Promise<any> {
|
|
193
|
+
const firstArg = args[0] ?? "";
|
|
194
|
+
const subKey = Array.isArray(firstArg)
|
|
195
|
+
? `${method}:${[...firstArg].sort().join(",")}`
|
|
196
|
+
: `${method}:${firstArg}`;
|
|
197
|
+
|
|
198
|
+
// Reuse existing subscription
|
|
199
|
+
const existingId = this.activeSubs.get(subKey);
|
|
200
|
+
if (existingId && this.subscriptions.has(existingId)) {
|
|
201
|
+
return this.waitForData(existingId, timeoutMs);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await this.ensureConnected();
|
|
205
|
+
|
|
206
|
+
const requestId = `req-${Math.random().toString(36).slice(2, 14)}`;
|
|
207
|
+
const symbols = Array.isArray(firstArg) ? firstArg : firstArg ? [firstArg] : [];
|
|
208
|
+
|
|
209
|
+
const sub: WsSubscription = {
|
|
210
|
+
requestId,
|
|
211
|
+
method,
|
|
212
|
+
symbols,
|
|
213
|
+
resolve: null,
|
|
214
|
+
reject: null,
|
|
215
|
+
};
|
|
216
|
+
this.subscriptions.set(requestId, sub);
|
|
217
|
+
this.activeSubs.set(subKey, requestId);
|
|
218
|
+
|
|
219
|
+
const message: Record<string, any> = {
|
|
220
|
+
id: requestId,
|
|
221
|
+
action: "subscribe",
|
|
222
|
+
exchange,
|
|
223
|
+
method,
|
|
224
|
+
args,
|
|
225
|
+
};
|
|
226
|
+
if (credentials) {
|
|
227
|
+
message.credentials = credentials;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.ws!.send(JSON.stringify(message));
|
|
231
|
+
|
|
232
|
+
return this.waitForData(requestId, timeoutMs);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async subscribeBatch(
|
|
236
|
+
exchange: string,
|
|
237
|
+
method: string,
|
|
238
|
+
args: any[],
|
|
239
|
+
credentials?: Record<string, any>,
|
|
240
|
+
timeoutMs = 30000,
|
|
241
|
+
): Promise<Record<string, any>> {
|
|
242
|
+
const symbols: string[] = Array.isArray(args[0]) ? args[0] : [];
|
|
243
|
+
|
|
244
|
+
await this.ensureConnected();
|
|
245
|
+
|
|
246
|
+
const requestId = `req-${Math.random().toString(36).slice(2, 14)}`;
|
|
247
|
+
|
|
248
|
+
const sub: WsSubscription = {
|
|
249
|
+
requestId,
|
|
250
|
+
method,
|
|
251
|
+
symbols,
|
|
252
|
+
resolve: null,
|
|
253
|
+
reject: null,
|
|
254
|
+
};
|
|
255
|
+
this.subscriptions.set(requestId, sub);
|
|
256
|
+
|
|
257
|
+
const message: Record<string, any> = {
|
|
258
|
+
id: requestId,
|
|
259
|
+
action: "subscribe",
|
|
260
|
+
exchange,
|
|
261
|
+
method,
|
|
262
|
+
args,
|
|
263
|
+
};
|
|
264
|
+
if (credentials) {
|
|
265
|
+
message.credentials = credentials;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.ws!.send(JSON.stringify(message));
|
|
269
|
+
|
|
270
|
+
// Wait for first data event
|
|
271
|
+
await this.waitForData(requestId, timeoutMs);
|
|
272
|
+
|
|
273
|
+
// Collect per-symbol data
|
|
274
|
+
const result: Record<string, any> = {};
|
|
275
|
+
for (const symbol of symbols) {
|
|
276
|
+
const storeKey = `${requestId}:${symbol}`;
|
|
277
|
+
const data = this.dataStore.get(storeKey);
|
|
278
|
+
if (data !== undefined) {
|
|
279
|
+
result[symbol] = data;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// If no per-symbol data, return the single data event as-is
|
|
284
|
+
if (Object.keys(result).length === 0) {
|
|
285
|
+
const data = this.dataStore.get(requestId);
|
|
286
|
+
if (data && typeof data === "object") {
|
|
287
|
+
return data;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
close(): void {
|
|
295
|
+
this.closed = true;
|
|
296
|
+
if (this.ws) {
|
|
297
|
+
try {
|
|
298
|
+
this.ws.close();
|
|
299
|
+
} catch {
|
|
300
|
+
// ignore
|
|
301
|
+
}
|
|
302
|
+
this.ws = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
get connected(): boolean {
|
|
307
|
+
return this.ws !== null && !this.closed;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ------------------------------------------------------------------
|
|
311
|
+
// Internal
|
|
312
|
+
// ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
private waitForData(requestId: string, timeoutMs: number): Promise<any> {
|
|
315
|
+
// Check if data is already available
|
|
316
|
+
const existing = this.dataStore.get(requestId);
|
|
317
|
+
if (existing !== undefined) {
|
|
318
|
+
this.dataStore.delete(requestId);
|
|
319
|
+
return Promise.resolve(existing);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return new Promise<any>((resolve, reject) => {
|
|
323
|
+
const sub = this.subscriptions.get(requestId);
|
|
324
|
+
if (!sub) {
|
|
325
|
+
reject(new PmxtError("Subscription not found"));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const timer = setTimeout(() => {
|
|
330
|
+
sub.resolve = null;
|
|
331
|
+
sub.reject = null;
|
|
332
|
+
reject(new PmxtError(
|
|
333
|
+
`Timeout waiting for WebSocket data (method=${sub.method})`
|
|
334
|
+
));
|
|
335
|
+
}, timeoutMs);
|
|
336
|
+
|
|
337
|
+
sub.resolve = (data: any) => {
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
resolve(data);
|
|
340
|
+
};
|
|
341
|
+
sub.reject = (err: Error) => {
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
reject(err);
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|