@cexyio/cexy 0.1.0-dev.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/index.js ADDED
@@ -0,0 +1,1752 @@
1
+ // src/errors.ts
2
+ var CexyError = class extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = new.target.name;
6
+ }
7
+ };
8
+ var CexyConfigError = class extends CexyError {
9
+ };
10
+ var InvalidAmountError = class extends CexyError {
11
+ field;
12
+ constructor(field, message) {
13
+ super(message);
14
+ this.field = field;
15
+ }
16
+ };
17
+ var CexyConnectionError = class extends CexyError {
18
+ retryable = true;
19
+ };
20
+ var CexyTimeoutError = class extends CexyConnectionError {
21
+ };
22
+ var OrderStateUnknownError = class extends CexyError {
23
+ clientOrderId;
24
+ constructor(clientOrderId, cause) {
25
+ super(
26
+ `order state unknown for client_order_id ${clientOrderId}; check trading.orderByClientId() before retrying`,
27
+ { cause }
28
+ );
29
+ this.clientOrderId = clientOrderId;
30
+ }
31
+ };
32
+ var CexyApiError = class extends CexyError {
33
+ status;
34
+ code;
35
+ details;
36
+ fields;
37
+ requestId;
38
+ retryable;
39
+ constructor(init) {
40
+ super(init.message);
41
+ this.status = init.status;
42
+ this.code = init.code;
43
+ this.details = init.details ?? {};
44
+ this.fields = init.fields ?? {};
45
+ this.requestId = init.requestId ?? null;
46
+ this.retryable = init.retryable;
47
+ }
48
+ toString() {
49
+ const rid = this.requestId ? ` (request_id ${this.requestId})` : "";
50
+ return `${this.name}: [${this.status} ${this.code}] ${this.message}${rid}`;
51
+ }
52
+ };
53
+ var AuthenticationError = class extends CexyApiError {
54
+ };
55
+ var ForbiddenError = class extends CexyApiError {
56
+ };
57
+ var NotFoundError = class extends CexyApiError {
58
+ };
59
+ var ValidationError = class extends CexyApiError {
60
+ };
61
+ var ConflictError = class extends CexyApiError {
62
+ };
63
+ var UnprocessableError = class extends CexyApiError {
64
+ };
65
+ var RateLimitError = class extends CexyApiError {
66
+ retryAfterMs;
67
+ constructor(init, retryAfterMs2) {
68
+ super(init);
69
+ this.retryAfterMs = retryAfterMs2;
70
+ }
71
+ };
72
+ var ServerError = class extends CexyApiError {
73
+ };
74
+ var STATUS_CLASSES = {
75
+ 400: ValidationError,
76
+ 401: AuthenticationError,
77
+ 403: ForbiddenError,
78
+ 404: NotFoundError,
79
+ 451: ForbiddenError,
80
+ // unavailable for legal reasons (JURISDICTION_BLOCKED)
81
+ 409: ConflictError,
82
+ 422: UnprocessableError
83
+ };
84
+ var KNOWN_CODES = /* @__PURE__ */ new Set([
85
+ "VALIDATION_FAILED",
86
+ "MALFORMED_REQUEST",
87
+ "INVALID_CURSOR",
88
+ "PRECISION_EXCEEDED",
89
+ "BELOW_MINIMUM",
90
+ "ABOVE_MAXIMUM",
91
+ "INVALID_ADDRESS",
92
+ "MEMO_REQUIRED",
93
+ "UNAUTHENTICATED",
94
+ "INVALID_CREDENTIALS",
95
+ "TOKEN_EXPIRED",
96
+ "SESSION_REVOKED",
97
+ "TWO_FACTOR_REQUIRED",
98
+ "TWO_FACTOR_INVALID",
99
+ "FRESH_TWO_FACTOR_REQUIRED",
100
+ "FORBIDDEN",
101
+ "API_KEY_NOT_ALLOWED",
102
+ "FUTURES_RESTRICTED",
103
+ "ACCOUNT_FROZEN",
104
+ "ACCOUNT_ON_HOLD",
105
+ "EMAIL_NOT_VERIFIED",
106
+ "REGION_BLOCKED",
107
+ "JURISDICTION_BLOCKED",
108
+ "NOT_FOUND",
109
+ "METHOD_NOT_ALLOWED",
110
+ "ALREADY_EXISTS",
111
+ "INVALID_STATE",
112
+ "IDEMPOTENCY_KEY_CONFLICT",
113
+ "WINDOW_OPEN",
114
+ "EVIDENCE_CONTRADICTS",
115
+ "AMOUNT_MISMATCH",
116
+ "CONCURRENT_MODIFICATION",
117
+ "INSUFFICIENT_FUNDS",
118
+ "INSUFFICIENT_FEE_FUNDS",
119
+ "MARKET_UNAVAILABLE",
120
+ "DEPOSIT_DISABLED",
121
+ "WITHDRAWAL_DISABLED",
122
+ "SELF_TRADE_BLOCKED",
123
+ "LIMIT_EXCEEDED",
124
+ "RATE_LIMITED",
125
+ "INTERNAL",
126
+ "SERVICE_UNAVAILABLE",
127
+ "UNDER_MAINTENANCE",
128
+ "ENGINE_OVERLOADED"
129
+ ]);
130
+ function isKnownErrorCode(code) {
131
+ return KNOWN_CODES.has(code);
132
+ }
133
+ var DEFAULT_RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
134
+ function retryAfterMs(headers, details) {
135
+ let best = null;
136
+ const h = headers?.get("retry-after");
137
+ if (h) {
138
+ const secs = Number(h);
139
+ if (Number.isFinite(secs)) best = Math.max(0, secs * 1e3);
140
+ else {
141
+ const at = Date.parse(h);
142
+ if (!Number.isNaN(at)) best = Math.max(0, at - Date.now());
143
+ }
144
+ }
145
+ const d = details?.["retry_after_seconds"];
146
+ const ds = typeof d === "number" ? d : typeof d === "string" ? Number(d) : NaN;
147
+ if (Number.isFinite(ds)) best = Math.max(best ?? 0, ds * 1e3);
148
+ return best;
149
+ }
150
+ function errorFromResponse(status, body, headers, redact = (t) => t) {
151
+ const env = extractEnvelope(body);
152
+ if (env) env.message = redact(env.message);
153
+ const init = env ? {
154
+ status,
155
+ code: env.code,
156
+ message: env.message,
157
+ details: env.details,
158
+ fields: env.fields,
159
+ requestId: env.request_id ?? headers?.get("x-request-id") ?? null,
160
+ retryable: typeof env.retryable === "boolean" ? env.retryable : DEFAULT_RETRYABLE_STATUS.has(status),
161
+ headers
162
+ } : {
163
+ status,
164
+ code: `HTTP_${status}`,
165
+ message: `HTTP ${status}`,
166
+ requestId: headers?.get("x-request-id") ?? null,
167
+ retryable: DEFAULT_RETRYABLE_STATUS.has(status),
168
+ headers
169
+ };
170
+ if (status === 429 || init.code === "RATE_LIMITED") {
171
+ return new RateLimitError(init, retryAfterMs(headers, init.details));
172
+ }
173
+ if (env && !isKnownErrorCode(env.code)) return new CexyApiError(init);
174
+ if (status >= 500) return new ServerError(init);
175
+ const Cls = STATUS_CLASSES[status] ?? CexyApiError;
176
+ return new Cls(init);
177
+ }
178
+ function extractEnvelope(body) {
179
+ if (!body || typeof body !== "object") return null;
180
+ const e = body.error;
181
+ if (!e || typeof e !== "object") return null;
182
+ const o = e;
183
+ if (typeof o["code"] !== "string") return null;
184
+ return {
185
+ code: o["code"],
186
+ message: typeof o["message"] === "string" ? o["message"] : o["code"],
187
+ details: isRecord(o["details"]) ? o["details"] : void 0,
188
+ fields: isRecord(o["fields"]) ? o["fields"] : void 0,
189
+ request_id: typeof o["request_id"] === "string" ? o["request_id"] : null,
190
+ retryable: typeof o["retryable"] === "boolean" ? o["retryable"] : void 0
191
+ };
192
+ }
193
+ function isRecord(v) {
194
+ return !!v && typeof v === "object" && !Array.isArray(v);
195
+ }
196
+
197
+ // src/auth.ts
198
+ var REDACTED = "[REDACTED]";
199
+ var ApiKeyAuthenticator = class {
200
+ kind = "api-key";
201
+ #key;
202
+ #secret;
203
+ constructor(apiKey, apiSecret) {
204
+ if (typeof apiKey !== "string" || apiKey.trim() === "") throw new CexyConfigError("apiKey must be a non-empty string");
205
+ if (typeof apiSecret !== "string" || apiSecret.trim() === "")
206
+ throw new CexyConfigError("apiSecret must be a non-empty string");
207
+ if (/[\r\n]/.test(apiKey) || /[\r\n]/.test(apiSecret))
208
+ throw new CexyConfigError("apiKey and apiSecret must not contain line breaks");
209
+ this.#key = apiKey;
210
+ this.#secret = apiSecret;
211
+ }
212
+ /** A non-secret hint for logs: the first characters of the key id. */
213
+ get keyHint() {
214
+ return `${this.#key.slice(0, 6)}\u2026`;
215
+ }
216
+ authenticate(request) {
217
+ request.headers.set("X-API-Key", this.#key);
218
+ request.headers.set("X-API-Secret", this.#secret);
219
+ }
220
+ redact(text) {
221
+ let out = text;
222
+ for (const s of [this.#secret, this.#key]) {
223
+ if (s) out = out.split(s).join(REDACTED);
224
+ }
225
+ return out;
226
+ }
227
+ toString() {
228
+ return `ApiKeyAuthenticator(${this.keyHint}, secret=${REDACTED})`;
229
+ }
230
+ toJSON() {
231
+ return { kind: this.kind, apiKey: this.keyHint, apiSecret: REDACTED };
232
+ }
233
+ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
234
+ return this.toString();
235
+ }
236
+ };
237
+
238
+ // src/url.ts
239
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
240
+ function isLocalHost(hostname) {
241
+ return LOCAL_HOSTS.has(hostname.toLowerCase());
242
+ }
243
+ function assertSecureUrl(url, secure, insecure, allowInsecure, what) {
244
+ if (url.protocol === secure) return;
245
+ if (url.protocol === insecure) {
246
+ if (!allowInsecure) {
247
+ throw new CexyConfigError(`${what} must use ${secure}// (pass allowInsecure: true only for a local test server)`);
248
+ }
249
+ if (!isLocalHost(url.hostname)) {
250
+ throw new CexyConfigError(`${what}: ${insecure}// is only allowed for localhost, 127.0.0.1 or ::1`);
251
+ }
252
+ return;
253
+ }
254
+ throw new CexyConfigError(`${what} must use ${secure}//`);
255
+ }
256
+
257
+ // src/operations.ts
258
+ var op = (method, path, auth, scope, sdkMethod) => ({ method, path, auth, scope, sdkMethod });
259
+ var OPERATIONS = {
260
+ // Public market data
261
+ server_time: op("GET", "/api/v1/time", "none", null, "time"),
262
+ exchange_config: op("GET", "/api/v1/config", "none", null, "config"),
263
+ list_fee_schedules: op("GET", "/api/v1/fees", "none", null, "fees.get"),
264
+ list_assets: op("GET", "/api/v1/assets", "none", null, "assets.list"),
265
+ get_asset: op("GET", "/api/v1/assets/{symbol}", "none", null, "assets.get"),
266
+ list_networks: op("GET", "/api/v1/networks", "none", null, "networks.list"),
267
+ list_markets: op("GET", "/api/v1/markets", "none", null, "markets.list"),
268
+ get_market: op("GET", "/api/v1/markets/{symbol}", "none", null, "markets.get"),
269
+ get_order_book: op("GET", "/api/v1/markets/{symbol}/orderbook", "none", null, "markets.orderbook"),
270
+ get_market_trades: op("GET", "/api/v1/markets/{symbol}/trades", "none", null, "markets.trades"),
271
+ get_candles: op("GET", "/api/v1/markets/{symbol}/candles", "none", null, "markets.candles"),
272
+ list_pools: op("GET", "/api/v1/pools", "none", null, "pools.list"),
273
+ get_pool: op("GET", "/api/v1/pools/{symbol}", "none", null, "pools.get"),
274
+ // Account (read)
275
+ list_balances: op("GET", "/api/v1/account/balances", "api_key", "read", "account.balances"),
276
+ get_balance: op("GET", "/api/v1/account/balances/{asset}", "api_key", "read", "account.balance"),
277
+ get_ledger: op("GET", "/api/v1/account/ledger", "api_key", "read", "account.ledger"),
278
+ list_notifications: op("GET", "/api/v1/account/notifications", "api_key", "read", "account.notifications"),
279
+ list_sub_accounts: op("GET", "/api/v1/account/sub-accounts", "api_key", "read", "account.subAccounts"),
280
+ list_api_keys: op("GET", "/api/v1/account/api-keys", "api_key", "read", "account.apiKeys"),
281
+ // Exports (read)
282
+ export_deposits: op("GET", "/api/v1/exports/deposits", "api_key", "read", "exports.deposits"),
283
+ export_ledger: op("GET", "/api/v1/exports/ledger", "api_key", "read", "exports.ledger"),
284
+ export_orders: op("GET", "/api/v1/exports/orders", "api_key", "read", "exports.orders"),
285
+ export_trades: op("GET", "/api/v1/exports/trades", "api_key", "read", "exports.trades"),
286
+ export_withdrawals: op("GET", "/api/v1/exports/withdrawals", "api_key", "read", "exports.withdrawals"),
287
+ // Wallet (read)
288
+ list_deposits: op("GET", "/api/v1/wallet/deposits", "api_key", "read", "wallet.deposits"),
289
+ get_deposit: op("GET", "/api/v1/wallet/deposits/{deposit_id}", "api_key", "read", "wallet.deposit"),
290
+ list_withdrawals: op("GET", "/api/v1/wallet/withdrawals", "api_key", "read", "wallet.withdrawals"),
291
+ get_withdrawal: op("GET", "/api/v1/wallet/withdrawals/{withdrawal_id}", "api_key", "read", "wallet.withdrawal"),
292
+ list_withdrawal_addresses: op("GET", "/api/v1/wallet/withdrawal-addresses", "api_key", "read", "wallet.withdrawalAddresses"),
293
+ deposit_address: op("GET", "/api/v1/wallet/deposit-address", "api_key", "read", "wallet.depositAddress"),
294
+ // Trading
295
+ list_open_orders: op("GET", "/api/v1/trading/orders", "api_key", "read", "trading.openOrders"),
296
+ get_order: op("GET", "/api/v1/trading/orders/{order_id}", "api_key", "read", "trading.order"),
297
+ get_order_by_client_id: op("GET", "/api/v1/trading/orders/by-client-id/{client_order_id}", "api_key", "read", "trading.orderByClientId"),
298
+ order_history: op("GET", "/api/v1/trading/orders/history", "api_key", "read", "trading.orderHistory"),
299
+ trade_history: op("GET", "/api/v1/trading/trades", "api_key", "read", "trading.trades"),
300
+ place_order: op("POST", "/api/v1/trading/orders", "api_key", "trade", "trading.placeOrder"),
301
+ cancel_order: op("DELETE", "/api/v1/trading/orders/{order_id}", "api_key", "trade", "trading.cancelOrder"),
302
+ cancel_all: op("POST", "/api/v1/trading/orders/cancel-all", "api_key", "trade", "trading.cancelAll"),
303
+ // Liquidity pools (trade)
304
+ join_pool: op("POST", "/api/v1/pools/{symbol}/join", "api_key", "trade", "pools.join"),
305
+ exit_pool: op("POST", "/api/v1/pools/{symbol}/exit", "api_key", "trade", "pools.exit")
306
+ };
307
+
308
+ // src/http.ts
309
+ var BACKOFF_BASE_MS = 500;
310
+ var BACKOFF_MAX_MS = 1e4;
311
+ var Transport = class {
312
+ config;
313
+ constructor(config) {
314
+ this.config = config;
315
+ }
316
+ /**
317
+ * Sends a request with the standard retry policy: GETs retry on retryable errors and
318
+ * network failures. Mutations carry an `Idempotency-Key` reused on every attempt; the server
319
+ * honours it on pool join/exit, which makes their retries safe. The other mutations routed
320
+ * here (cancel-all) are naturally repeatable. `placeOrder` and `cancelOrder` use
321
+ * `attempt()` with their own policies.
322
+ */
323
+ async request(spec, opts = {}) {
324
+ const info = OPERATIONS[spec.op];
325
+ const isMutation = info.method !== "GET";
326
+ const idempotencyKey = isMutation ? spec.idempotencyKey ?? opts.idempotencyKey ?? newId() : void 0;
327
+ const maxRetries = opts.maxRetries ?? this.config.maxRetries;
328
+ for (let attempt = 0; ; attempt++) {
329
+ try {
330
+ return await this.attempt({ ...spec, idempotencyKey }, opts);
331
+ } catch (err) {
332
+ if (attempt >= maxRetries || !isRetryable(err) || opts.signal?.aborted) throw err;
333
+ await this.backoff(spec.op, info, attempt, err, idempotencyKey, opts.signal);
334
+ }
335
+ }
336
+ }
337
+ /** Waits before retry number `attempt + 1`, honouring server hints. */
338
+ async backoff(op2, info, attempt, err, idempotencyKey, signal) {
339
+ const delayMs = this.retryDelay(attempt, err);
340
+ this.config.onRetry?.({
341
+ operation: op2,
342
+ method: info.method,
343
+ path: info.path,
344
+ attempt: attempt + 1,
345
+ delayMs,
346
+ error: err,
347
+ idempotencyKey
348
+ });
349
+ await this.config.sleep(delayMs, signal);
350
+ }
351
+ /** Full-jitter exponential backoff, or the server's hint plus a little jitter. */
352
+ retryDelay(attempt, err) {
353
+ const hint = err instanceof RateLimitError ? err.retryAfterMs : err instanceof CexyApiError ? retryAfterMs(void 0, err.details) : null;
354
+ if (hint !== null && hint > 0) return Math.ceil(hint + this.config.random() * 250);
355
+ const cap = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** attempt);
356
+ return Math.ceil(this.config.random() * cap);
357
+ }
358
+ /** One attempt: rate limiter, credentials, timeout, error mapping. No retries. */
359
+ async attempt(spec, opts = {}) {
360
+ const info = OPERATIONS[spec.op];
361
+ const url = this.buildUrl(info, spec.pathParams, spec.query);
362
+ const headers = new Headers({ Accept: spec.responseType === "text" ? "text/csv, application/json" : "application/json" });
363
+ if (this.config.userAgent) headers.set("User-Agent", this.config.userAgent);
364
+ let body;
365
+ if (spec.body !== void 0) {
366
+ body = JSON.stringify(spec.body);
367
+ headers.set("Content-Type", "application/json");
368
+ }
369
+ if (info.method !== "GET" && spec.idempotencyKey) headers.set("Idempotency-Key", spec.idempotencyKey);
370
+ if (info.auth === "api_key") {
371
+ const auth = this.config.authenticator;
372
+ if (!auth) {
373
+ throw new CexyConfigError(
374
+ `${info.sdkMethod}() needs an API key: construct the client with { apiKey, apiSecret }`
375
+ );
376
+ }
377
+ await auth.authenticate({ method: info.method, url, headers, body });
378
+ }
379
+ if (this.config.limiter) await this.config.limiter.acquire(opts.signal);
380
+ const timeoutMs = opts.timeoutMs ?? this.config.timeoutMs;
381
+ const ctrl = new AbortController();
382
+ let timedOut = false;
383
+ const timer = setTimeout(() => {
384
+ timedOut = true;
385
+ ctrl.abort();
386
+ }, timeoutMs);
387
+ const onAbort = () => ctrl.abort(opts.signal?.reason);
388
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
389
+ let res;
390
+ let text;
391
+ try {
392
+ res = await this.config.fetch(url.toString(), { method: info.method, headers, body, signal: ctrl.signal });
393
+ text = await res.text();
394
+ } catch (err) {
395
+ if (opts.signal?.aborted) throw opts.signal.reason ?? err;
396
+ if (timedOut) throw new CexyTimeoutError(`${info.method} ${info.path} timed out after ${timeoutMs} ms`);
397
+ throw new CexyConnectionError(this.redact(`${info.method} ${info.path} failed: ${errMessage(err)}`), {
398
+ cause: err
399
+ });
400
+ } finally {
401
+ clearTimeout(timer);
402
+ opts.signal?.removeEventListener("abort", onAbort);
403
+ }
404
+ this.config.limiter?.update(res.headers);
405
+ if (!res.ok) {
406
+ const parsed = safeJson(text);
407
+ const error = errorFromResponse(res.status, parsed, res.headers, (t) => this.redact(t));
408
+ if (error instanceof RateLimitError && error.retryAfterMs) this.config.limiter?.blockFor(error.retryAfterMs);
409
+ throw error;
410
+ }
411
+ if (spec.responseType === "text") return { status: res.status, headers: res.headers, data: text };
412
+ if (text === "") return { status: res.status, headers: res.headers, data: null };
413
+ const data = safeJson(text);
414
+ if (data === void 0) {
415
+ throw new CexyError(`${info.method} ${info.path}: expected JSON, got ${res.headers.get("content-type") ?? "unknown content"}`);
416
+ }
417
+ return { status: res.status, headers: res.headers, data };
418
+ }
419
+ buildUrl(info, pathParams = {}, query) {
420
+ const path = info.path.replace(/\{(\w+)\}/g, (_m, name) => {
421
+ const v = pathParams[name];
422
+ if (typeof v !== "string" || v === "") throw new CexyConfigError(`${info.sdkMethod}(): ${name} is required`);
423
+ return encodeURIComponent(v);
424
+ });
425
+ const url = new URL(this.config.baseUrl.replace(/\/+$/, "") + path);
426
+ for (const [k, v] of Object.entries(query ?? {})) {
427
+ if (v === void 0 || v === null) continue;
428
+ if (v instanceof Date) url.searchParams.set(k, v.toISOString());
429
+ else if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") url.searchParams.set(k, String(v));
430
+ else throw new CexyConfigError(`${info.sdkMethod}(): query parameter ${k} must be a string, number, boolean or Date`);
431
+ }
432
+ return url;
433
+ }
434
+ redact(text) {
435
+ return this.config.authenticator ? this.config.authenticator.redact(text) : text;
436
+ }
437
+ };
438
+ function isRetryable(err) {
439
+ if (err instanceof CexyConnectionError) return true;
440
+ if (err instanceof CexyApiError) return err.retryable || err.code === "CONCURRENT_MODIFICATION";
441
+ return false;
442
+ }
443
+ function isAmbiguous(err) {
444
+ if (err instanceof CexyConnectionError) return true;
445
+ return err instanceof CexyApiError && err.status >= 500;
446
+ }
447
+ function newId() {
448
+ const c = globalThis.crypto;
449
+ if (c?.randomUUID) return c.randomUUID();
450
+ throw new CexyConfigError("crypto.randomUUID is unavailable; pass an explicit id");
451
+ }
452
+ function safeJson(text) {
453
+ if (text === "") return void 0;
454
+ try {
455
+ return JSON.parse(text);
456
+ } catch {
457
+ return void 0;
458
+ }
459
+ }
460
+ function errMessage(err) {
461
+ if (err instanceof Error) {
462
+ const cause = err.cause;
463
+ return cause instanceof Error ? `${err.message} (${cause.message})` : err.message;
464
+ }
465
+ return String(err);
466
+ }
467
+
468
+ // src/limiter.ts
469
+ var RateLimiter = class {
470
+ #rpm;
471
+ #tokens;
472
+ #last;
473
+ #blockedUntil = 0;
474
+ #now;
475
+ #sleep;
476
+ constructor(opts) {
477
+ if (!(opts.requestsPerMinute > 0)) throw new RangeError("requestsPerMinute must be > 0");
478
+ this.#rpm = opts.requestsPerMinute;
479
+ this.#tokens = opts.requestsPerMinute;
480
+ this.#now = opts.now ?? Date.now;
481
+ this.#sleep = opts.sleep ?? sleep;
482
+ this.#last = this.#now();
483
+ }
484
+ get state() {
485
+ this.#refill();
486
+ return { requestsPerMinute: this.#rpm, tokens: this.#tokens, blockedUntil: this.#blockedUntil };
487
+ }
488
+ /** Waits until a request may be sent, then takes a token. */
489
+ async acquire(signal) {
490
+ for (; ; ) {
491
+ this.#refill();
492
+ const now = this.#now();
493
+ if (now < this.#blockedUntil) {
494
+ await this.#sleep(this.#blockedUntil - now, signal);
495
+ continue;
496
+ }
497
+ if (this.#tokens >= 1) {
498
+ this.#tokens -= 1;
499
+ return;
500
+ }
501
+ const msPerToken = 6e4 / this.#rpm;
502
+ await this.#sleep(Math.ceil((1 - this.#tokens) * msPerToken), signal);
503
+ }
504
+ }
505
+ /** Adapts to the server's rate-limit headers. Never raises the configured limit. */
506
+ update(headers) {
507
+ this.#refill();
508
+ const limit = num(headers.get("x-ratelimit-limit"));
509
+ if (limit !== null && limit > 0 && limit < this.#rpm) {
510
+ this.#rpm = limit;
511
+ this.#tokens = Math.min(this.#tokens, limit);
512
+ }
513
+ const remaining = num(headers.get("x-ratelimit-remaining"));
514
+ if (remaining !== null && remaining >= 0 && remaining < this.#tokens) this.#tokens = remaining;
515
+ if (remaining === 0) {
516
+ const reset = num(headers.get("x-ratelimit-reset"));
517
+ if (reset !== null) this.blockFor(resetToMs(reset, this.#now()));
518
+ else this.blockFor(6e4 / this.#rpm);
519
+ }
520
+ }
521
+ /** Blocks all requests for `ms` (used for 429 Retry-After). */
522
+ blockFor(ms) {
523
+ if (!(ms > 0)) return;
524
+ this.#blockedUntil = Math.max(this.#blockedUntil, this.#now() + ms);
525
+ }
526
+ #refill() {
527
+ const now = this.#now();
528
+ const elapsed = now - this.#last;
529
+ if (elapsed > 0) {
530
+ this.#tokens = Math.min(this.#rpm, this.#tokens + elapsed * this.#rpm / 6e4);
531
+ this.#last = now;
532
+ }
533
+ }
534
+ };
535
+ function resetToMs(reset, now) {
536
+ if (reset > 1e12) return Math.max(0, reset - now);
537
+ if (reset > 1e9) return Math.max(0, reset * 1e3 - now);
538
+ return Math.max(0, reset * 1e3);
539
+ }
540
+ function num(v) {
541
+ if (v === null || v.trim() === "") return null;
542
+ const n = Number(v);
543
+ return Number.isFinite(n) ? n : null;
544
+ }
545
+ function sleep(ms, signal) {
546
+ return new Promise((resolve, reject) => {
547
+ if (signal?.aborted) {
548
+ reject(abortError(signal));
549
+ return;
550
+ }
551
+ const t = setTimeout(() => {
552
+ signal?.removeEventListener("abort", onAbort);
553
+ resolve();
554
+ }, ms);
555
+ const onAbort = () => {
556
+ clearTimeout(t);
557
+ reject(abortError(signal));
558
+ };
559
+ signal?.addEventListener("abort", onAbort, { once: true });
560
+ });
561
+ }
562
+ function abortError(signal) {
563
+ const r = signal?.reason;
564
+ return r instanceof Error ? r : new Error("aborted");
565
+ }
566
+
567
+ // src/amounts.ts
568
+ var AMOUNT_RE = /^-?\d+(\.\d+)?$/;
569
+ function isAmount(value) {
570
+ return typeof value === "string" && AMOUNT_RE.test(value);
571
+ }
572
+ function assertAmountFields(body, fields, context) {
573
+ for (const field of fields) {
574
+ const v = body[field];
575
+ if (v === void 0 || v === null) continue;
576
+ if (typeof v === "number" || typeof v === "bigint") {
577
+ throw new InvalidAmountError(
578
+ field,
579
+ `${context}: ${field} must be a decimal string such as "0.5", not a ${typeof v}. JS numbers are binary floats and cannot represent most decimals exactly.`
580
+ );
581
+ }
582
+ if (!isAmount(v)) {
583
+ throw new InvalidAmountError(field, `${context}: ${field} must be a plain decimal string (digits, optional "." and sign)`);
584
+ }
585
+ }
586
+ }
587
+
588
+ // src/pagination.ts
589
+ async function* paginate(fetchPage, startCursor, opts = {}) {
590
+ let cursor = startCursor ?? void 0;
591
+ let yielded = 0;
592
+ const max = opts.maxItems ?? Infinity;
593
+ const seen = /* @__PURE__ */ new Set();
594
+ for (; ; ) {
595
+ const page = await fetchPage(cursor);
596
+ for (const item of page.items) {
597
+ if (yielded >= max) return;
598
+ yield item;
599
+ yielded++;
600
+ }
601
+ if (!page.has_more || !page.next_cursor) return;
602
+ if (seen.has(page.next_cursor)) return;
603
+ seen.add(page.next_cursor);
604
+ cursor = page.next_cursor;
605
+ }
606
+ }
607
+
608
+ // src/resources.ts
609
+ var Resource = class {
610
+ t;
611
+ constructor(transport) {
612
+ this.t = transport;
613
+ }
614
+ async data(spec, opts) {
615
+ const raw = await this.t.request(spec, opts);
616
+ return raw.data.data;
617
+ }
618
+ async page(spec, opts) {
619
+ const raw = await this.t.request(spec, opts);
620
+ return raw.data;
621
+ }
622
+ async text(spec, opts) {
623
+ const raw = await this.t.request({ ...spec, responseType: "text" }, opts);
624
+ return raw.data;
625
+ }
626
+ };
627
+ function withoutCursor(p) {
628
+ const { cursor: _c, ...rest } = p ?? {};
629
+ return rest;
630
+ }
631
+ var MarketsResource = class extends Resource {
632
+ /** All markets with their current ticker. */
633
+ list(opts) {
634
+ return this.data({ op: "list_markets" }, opts);
635
+ }
636
+ /** One market, e.g. `"BTC/USDT"`. */
637
+ get(symbol, opts) {
638
+ return this.data({ op: "get_market", pathParams: { symbol } }, opts);
639
+ }
640
+ /**
641
+ * Order-book snapshot aggregated by price, with the realtime `sequence` it is current as of.
642
+ * To follow the book live, use `CexyWebSocket.orderBook()`, which applies the sync rules.
643
+ */
644
+ orderbook(symbol, params, opts) {
645
+ return this.data({ op: "get_order_book", pathParams: { symbol }, query: params }, opts);
646
+ }
647
+ /** One page of recent public trades. */
648
+ trades(symbol, params, opts) {
649
+ return this.page({ op: "get_market_trades", pathParams: { symbol }, query: params }, opts);
650
+ }
651
+ /** Every public trade, page by page (`for await`). */
652
+ iterateTrades(symbol, params, iter) {
653
+ return paginate((cursor) => this.trades(symbol, { ...withoutCursor(params), cursor }, iter), params?.cursor, iter);
654
+ }
655
+ /** OHLCV candles. `interval` is required. */
656
+ candles(symbol, params, opts) {
657
+ return this.data({ op: "get_candles", pathParams: { symbol }, query: params }, opts);
658
+ }
659
+ };
660
+ var AssetsResource = class extends Resource {
661
+ list(opts) {
662
+ return this.data({ op: "list_assets" }, opts);
663
+ }
664
+ get(symbol, opts) {
665
+ return this.data({ op: "get_asset", pathParams: { symbol } }, opts);
666
+ }
667
+ };
668
+ var NetworksResource = class extends Resource {
669
+ list(opts) {
670
+ return this.data({ op: "list_networks" }, opts);
671
+ }
672
+ };
673
+ var FeesResource = class extends Resource {
674
+ /** The fee schedules (maker/taker rates by tier). */
675
+ get(opts) {
676
+ return this.data({ op: "list_fee_schedules" }, opts);
677
+ }
678
+ };
679
+ var PoolsResource = class extends Resource {
680
+ list(opts) {
681
+ return this.data({ op: "list_pools" }, opts);
682
+ }
683
+ get(symbol, opts) {
684
+ return this.data({ op: "get_pool", pathParams: { symbol } }, opts);
685
+ }
686
+ /** Adds liquidity. Needs the `trade` scope. Amounts are decimal strings. */
687
+ async join(symbol, body, opts) {
688
+ assertAmountFields(body, ["base_amount", "quote_amount", "max_ratio_deviation_percent"], "pools.join");
689
+ return this.data({ op: "join_pool", pathParams: { symbol }, body }, opts);
690
+ }
691
+ /** Removes liquidity. Needs the `trade` scope. */
692
+ async exit(symbol, body, opts) {
693
+ assertAmountFields(body, ["shares"], "pools.exit");
694
+ return this.data({ op: "exit_pool", pathParams: { symbol }, body }, opts);
695
+ }
696
+ };
697
+ var AccountResource = class extends Resource {
698
+ balances(opts) {
699
+ return this.data({ op: "list_balances" }, opts);
700
+ }
701
+ balance(asset, opts) {
702
+ return this.data({ op: "get_balance", pathParams: { asset } }, opts);
703
+ }
704
+ ledger(params, opts) {
705
+ return this.page({ op: "get_ledger", query: params }, opts);
706
+ }
707
+ iterateLedger(params, iter) {
708
+ return paginate((cursor) => this.ledger({ ...withoutCursor(params), cursor }, iter), params?.cursor, iter);
709
+ }
710
+ notifications(params, opts) {
711
+ return this.page({ op: "list_notifications", query: params }, opts);
712
+ }
713
+ iterateNotifications(params, iter) {
714
+ return paginate((cursor) => this.notifications({ ...withoutCursor(params), cursor }, iter), params?.cursor, iter);
715
+ }
716
+ subAccounts(opts) {
717
+ return this.data({ op: "list_sub_accounts" }, opts);
718
+ }
719
+ /** Your API keys (metadata only; secrets are never returned). */
720
+ apiKeys(opts) {
721
+ return this.data({ op: "list_api_keys" }, opts);
722
+ }
723
+ };
724
+ var ExportsResource = class extends Resource {
725
+ deposits(params, opts) {
726
+ return this.text({ op: "export_deposits", query: params }, opts);
727
+ }
728
+ ledger(params, opts) {
729
+ return this.text({ op: "export_ledger", query: params }, opts);
730
+ }
731
+ orders(params, opts) {
732
+ return this.text({ op: "export_orders", query: params }, opts);
733
+ }
734
+ trades(params, opts) {
735
+ return this.text({ op: "export_trades", query: params }, opts);
736
+ }
737
+ withdrawals(params, opts) {
738
+ return this.text({ op: "export_withdrawals", query: params }, opts);
739
+ }
740
+ };
741
+ var WalletResource = class extends Resource {
742
+ deposits(params, opts) {
743
+ return this.page({ op: "list_deposits", query: params }, opts);
744
+ }
745
+ iterateDeposits(params, iter) {
746
+ return paginate((cursor) => this.deposits({ ...withoutCursor(params), cursor }, iter), params?.cursor, iter);
747
+ }
748
+ deposit(depositId, opts) {
749
+ return this.data({ op: "get_deposit", pathParams: { deposit_id: depositId } }, opts);
750
+ }
751
+ withdrawals(params, opts) {
752
+ return this.page({ op: "list_withdrawals", query: params }, opts);
753
+ }
754
+ iterateWithdrawals(params, iter) {
755
+ return paginate((cursor) => this.withdrawals({ ...withoutCursor(params), cursor }, iter), params?.cursor, iter);
756
+ }
757
+ withdrawal(withdrawalId, opts) {
758
+ return this.data({ op: "get_withdrawal", pathParams: { withdrawal_id: withdrawalId } }, opts);
759
+ }
760
+ withdrawalAddresses(opts) {
761
+ return this.data({ op: "list_withdrawal_addresses" }, opts);
762
+ }
763
+ /**
764
+ * Your deposit address for an asset on a network.
765
+ *
766
+ * **Side effect:** the first call for an asset/network CREATES the address (and it is
767
+ * permanent); later calls return the same address. Always send the `memo` too when the
768
+ * response has one, or the deposit may be unrecoverable.
769
+ */
770
+ depositAddress(params, opts) {
771
+ return this.data({ op: "deposit_address", query: params }, opts);
772
+ }
773
+ };
774
+ var ORDER_AMOUNT_FIELDS = ["price", "quantity", "quote_quantity", "stop_price"];
775
+ var TradingResource = class extends Resource {
776
+ /** Open orders, optionally filtered by market/status. */
777
+ openOrders(params, opts) {
778
+ return this.data({ op: "list_open_orders", query: params }, opts);
779
+ }
780
+ order(orderId, opts) {
781
+ return this.data({ op: "get_order", pathParams: { order_id: orderId } }, opts);
782
+ }
783
+ orderByClientId(clientOrderId, opts) {
784
+ return this.data({ op: "get_order_by_client_id", pathParams: { client_order_id: clientOrderId } }, opts);
785
+ }
786
+ orderHistory(params, opts) {
787
+ return this.page({ op: "order_history", query: params }, opts);
788
+ }
789
+ iterateOrderHistory(params, iter) {
790
+ return paginate((cursor) => this.orderHistory({ ...withoutCursor(params), cursor }, iter), params?.cursor, iter);
791
+ }
792
+ /** Your executions. */
793
+ trades(params, opts) {
794
+ return this.page({ op: "trade_history", query: params }, opts);
795
+ }
796
+ iterateTrades(params, iter) {
797
+ return paginate((cursor) => this.trades({ ...withoutCursor(params), cursor }, iter), params?.cursor, iter);
798
+ }
799
+ /**
800
+ * Places a REAL order (needs the `trade` scope). Amounts must be decimal strings.
801
+ *
802
+ * Retry safety rests on `client_order_id` (generated as a UUID when absent): it is unique
803
+ * per account and the server refuses a repeat before any funds move. The server does NOT
804
+ * honour `Idempotency-Key` on orders (the header is sent but gives no protection). After an
805
+ * ambiguous failure (network error, timeout or 5xx) the SDK first looks the order up by
806
+ * `client_order_id` and returns it if it exists (`recovered: true`); only if it does not
807
+ * exist does it send the order again, with the same `client_order_id`, so a late-arriving
808
+ * first attempt makes the resend fail as a duplicate, which is again resolved by lookup.
809
+ * Throws `OrderStateUnknownError` when even the lookup fails.
810
+ */
811
+ async placeOrder(order, opts = {}) {
812
+ if (!order || typeof order !== "object") throw new CexyConfigError("placeOrder(): order is required");
813
+ for (const f of ["symbol", "side", "type"]) {
814
+ if (!order[f]) throw new CexyConfigError(`placeOrder(): ${f} is required`);
815
+ }
816
+ assertAmountFields(order, ORDER_AMOUNT_FIELDS, "placeOrder");
817
+ const clientOrderId = order.client_order_id ?? newId();
818
+ const body = { ...order, client_order_id: clientOrderId };
819
+ const idempotencyKey = opts.idempotencyKey ?? newId();
820
+ const maxRetries = opts.maxRetries ?? this.t.config.maxRetries;
821
+ const info = OPERATIONS.place_order;
822
+ for (let attempt = 0; ; attempt++) {
823
+ try {
824
+ const raw = await this.t.attempt({ op: "place_order", body, idempotencyKey }, opts);
825
+ const data = raw.data.data;
826
+ return { ...data, client_order_id: clientOrderId, recovered: false };
827
+ } catch (err) {
828
+ if (opts.signal?.aborted) throw err;
829
+ const duplicateAfterRetry = attempt > 0 && err instanceof ConflictError && (err.code === "ALREADY_EXISTS" || err.code === "IDEMPOTENCY_KEY_CONFLICT");
830
+ if (isAmbiguous(err) || duplicateAfterRetry) {
831
+ const existing = await this.#lookup(clientOrderId, err, opts);
832
+ if (existing) return { order: existing, fills: [], client_order_id: clientOrderId, recovered: true };
833
+ if (duplicateAfterRetry || attempt >= maxRetries) throw err;
834
+ await this.t.backoff("place_order", info, attempt, err, idempotencyKey, opts.signal);
835
+ continue;
836
+ }
837
+ if (isRetryable(err) && attempt < maxRetries) {
838
+ await this.t.backoff("place_order", info, attempt, err, idempotencyKey, opts.signal);
839
+ continue;
840
+ }
841
+ throw err;
842
+ }
843
+ }
844
+ }
845
+ async #lookup(clientOrderId, original, opts) {
846
+ try {
847
+ return await this.orderByClientId(clientOrderId, { signal: opts.signal, timeoutMs: opts.timeoutMs });
848
+ } catch (lookupErr) {
849
+ if (lookupErr instanceof NotFoundError) return null;
850
+ throw new OrderStateUnknownError(clientOrderId, original);
851
+ }
852
+ }
853
+ /**
854
+ * Cancels one order (needs the `trade` scope). Retried on network errors and retryable
855
+ * responses. If a RETRY gets `INVALID_STATE` (the order is no longer open, typically because
856
+ * the first attempt did cancel it), the cancel is treated as done and the order is fetched
857
+ * and returned. `INVALID_STATE` on the first attempt is thrown (e.g. already filled).
858
+ */
859
+ async cancelOrder(orderId, opts = {}) {
860
+ const maxRetries = opts.maxRetries ?? this.t.config.maxRetries;
861
+ const info = OPERATIONS.cancel_order;
862
+ const idempotencyKey = opts.idempotencyKey ?? newId();
863
+ for (let attempt = 0; ; attempt++) {
864
+ try {
865
+ const raw = await this.t.attempt({ op: "cancel_order", pathParams: { order_id: orderId }, idempotencyKey }, opts);
866
+ return raw.data.data;
867
+ } catch (err) {
868
+ if (opts.signal?.aborted) throw err;
869
+ if (attempt > 0 && err instanceof CexyApiError && err.code === "INVALID_STATE") {
870
+ return this.order(orderId, { signal: opts.signal, timeoutMs: opts.timeoutMs });
871
+ }
872
+ if (isRetryable(err) && attempt < maxRetries) {
873
+ await this.t.backoff("cancel_order", info, attempt, err, idempotencyKey, opts.signal);
874
+ continue;
875
+ }
876
+ throw err;
877
+ }
878
+ }
879
+ }
880
+ /**
881
+ * Cancels every open order in one market: `cancelAll({ symbol: "BTC/USDT" })`.
882
+ * To cancel across ALL markets, pass `symbol: null` explicitly: `cancelAll({ symbol: null })`.
883
+ * Omitting `symbol` is an error, so an account-wide cancel never happens by accident
884
+ * (the server itself treats `{}` as every market).
885
+ *
886
+ * The server limits cancel-all to 30 calls per minute per account. It is naturally
887
+ * repeatable, so it is retried after network errors; a retry reports only what that retry
888
+ * cancelled.
889
+ */
890
+ async cancelAll(params, opts) {
891
+ const hasSymbol = !!params && typeof params === "object" && Object.prototype.hasOwnProperty.call(params, "symbol");
892
+ const symbol = hasSymbol ? params.symbol : void 0;
893
+ let body;
894
+ if (typeof symbol === "string" && symbol !== "") body = { symbol };
895
+ else if (hasSymbol && symbol === null) body = {};
896
+ else throw new CexyConfigError('cancelAll(): pass { symbol: "BASE/QUOTE" }, or { symbol: null } to cancel in every market');
897
+ return this.data({ op: "cancel_all", body }, opts);
898
+ }
899
+ };
900
+
901
+ // src/version.ts
902
+ var VERSION = "0.1.0-dev.0";
903
+ var USER_AGENT = `cexy-typescript/${VERSION}`;
904
+
905
+ // src/ws/emitter.ts
906
+ var TypedEmitter = class {
907
+ #listeners = /* @__PURE__ */ new Map();
908
+ /** Adds a listener; returns a function that removes it. */
909
+ on(event, listener) {
910
+ let set = this.#listeners.get(event);
911
+ if (!set) this.#listeners.set(event, set = /* @__PURE__ */ new Set());
912
+ set.add(listener);
913
+ return () => this.off(event, listener);
914
+ }
915
+ once(event, listener) {
916
+ const off = this.on(event, ((...args) => {
917
+ off();
918
+ listener(...args);
919
+ }));
920
+ return off;
921
+ }
922
+ off(event, listener) {
923
+ this.#listeners.get(event)?.delete(listener);
924
+ }
925
+ removeAllListeners(event) {
926
+ if (event === void 0) this.#listeners.clear();
927
+ else this.#listeners.delete(event);
928
+ }
929
+ listenerCount(event) {
930
+ return this.#listeners.get(event)?.size ?? 0;
931
+ }
932
+ /** Calls listeners synchronously. A throwing listener does not stop the others. */
933
+ emit(event, ...args) {
934
+ const set = this.#listeners.get(event);
935
+ if (!set) return;
936
+ for (const l of [...set]) {
937
+ try {
938
+ l(...args);
939
+ } catch (err) {
940
+ queueMicrotask(() => {
941
+ throw err;
942
+ });
943
+ }
944
+ }
945
+ }
946
+ };
947
+
948
+ // src/ws/orderbook.ts
949
+ var WS_BOOK_DEPTH = 50;
950
+ var LiveOrderBook = class extends TypedEmitter {
951
+ symbol;
952
+ bids = [];
953
+ asks = [];
954
+ /** Sequence of the last applied snapshot/update on the current connection. */
955
+ sequence = null;
956
+ /** True after a sequence gap, until the next in-order update. */
957
+ stale = false;
958
+ /** False while waiting for a snapshot (after connect, reconnect or resync). */
959
+ synced = false;
960
+ #rest;
961
+ #onClose;
962
+ #retryMs;
963
+ #buffer = [];
964
+ #generation = 0;
965
+ #closed = false;
966
+ #retryTimer = null;
967
+ /** @internal use `CexyWebSocket.orderBook()` */
968
+ constructor(symbol, rest, options, onClose) {
969
+ super();
970
+ this.symbol = symbol;
971
+ this.#rest = rest;
972
+ this.#onClose = onClose;
973
+ this.#retryMs = options.snapshotRetryMs ?? 1e3;
974
+ }
975
+ /** Best bid and ask (null when that side is empty). */
976
+ get top() {
977
+ return { bid: this.bids[0] ?? null, ask: this.asks[0] ?? null };
978
+ }
979
+ /** @internal The connection dropped: sequences from the next connection are unrelated. */
980
+ markDisconnected() {
981
+ this.#generation++;
982
+ this.synced = false;
983
+ this.sequence = null;
984
+ this.#buffer = [];
985
+ if (this.#retryTimer) clearTimeout(this.#retryTimer);
986
+ this.#retryTimer = null;
987
+ }
988
+ /**
989
+ * Takes a fresh REST snapshot and replays buffered updates newer than it.
990
+ * Called automatically; safe to call yourself.
991
+ */
992
+ async resync(attempt = 0) {
993
+ if (this.#closed) return;
994
+ const gen = ++this.#generation;
995
+ this.synced = false;
996
+ this.sequence = null;
997
+ this.emit("resync");
998
+ try {
999
+ const snap = await this.#rest.markets.orderbook(this.symbol, { depth: WS_BOOK_DEPTH });
1000
+ if (gen !== this.#generation || this.#closed) return;
1001
+ this.bids = snap.bids.slice(0, WS_BOOK_DEPTH);
1002
+ this.asks = snap.asks.slice(0, WS_BOOK_DEPTH);
1003
+ this.sequence = snap.sequence;
1004
+ this.stale = false;
1005
+ this.synced = true;
1006
+ const buffered = this.#buffer;
1007
+ this.#buffer = [];
1008
+ this.emit("update", this);
1009
+ for (const u of buffered) this.#apply(u);
1010
+ } catch (err) {
1011
+ if (gen !== this.#generation || this.#closed) return;
1012
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
1013
+ const delay = Math.min(3e4, this.#retryMs * 2 ** attempt);
1014
+ this.#retryTimer = setTimeout(() => {
1015
+ this.#retryTimer = null;
1016
+ if (gen === this.#generation) void this.resync(attempt + 1);
1017
+ }, delay);
1018
+ }
1019
+ }
1020
+ /** @internal */
1021
+ onUpdate(event) {
1022
+ if (this.#closed) return;
1023
+ if (!this.synced) {
1024
+ this.#buffer.push(event);
1025
+ return;
1026
+ }
1027
+ this.#apply(event);
1028
+ }
1029
+ /** Stops following the book and unsubscribes. */
1030
+ close() {
1031
+ if (this.#closed) return;
1032
+ this.#closed = true;
1033
+ this.#generation++;
1034
+ if (this.#retryTimer) clearTimeout(this.#retryTimer);
1035
+ this.#onClose();
1036
+ this.removeAllListeners();
1037
+ }
1038
+ #apply(event) {
1039
+ const seq = event.sequence;
1040
+ if (typeof seq === "number" && this.sequence !== null) {
1041
+ if (seq <= this.sequence) return;
1042
+ if (seq !== this.sequence + 1) {
1043
+ this.stale = true;
1044
+ this.emit("stale", { expected: this.sequence + 1, received: seq });
1045
+ } else if (this.stale) {
1046
+ this.stale = false;
1047
+ this.emit("healed");
1048
+ }
1049
+ }
1050
+ this.bids = (event.data.bids ?? []).slice(0, WS_BOOK_DEPTH);
1051
+ this.asks = (event.data.asks ?? []).slice(0, WS_BOOK_DEPTH);
1052
+ if (typeof seq === "number") this.sequence = seq;
1053
+ this.emit("update", this);
1054
+ }
1055
+ };
1056
+
1057
+ // src/ws/types.ts
1058
+ var SUPPORTED_PROTOCOL_VERSION = 1;
1059
+ var KNOWN_EVENT_TYPES = /* @__PURE__ */ new Set([
1060
+ "ticker.update",
1061
+ "orderbook.update",
1062
+ "trade.new",
1063
+ "market.status",
1064
+ "order.created",
1065
+ "order.updated",
1066
+ "order.cancelled",
1067
+ "order.filled",
1068
+ "balance.updated",
1069
+ "deposit.detected",
1070
+ "deposit.updated",
1071
+ "deposit.completed",
1072
+ "withdrawal.updated",
1073
+ "session.revoked"
1074
+ ]);
1075
+ var PRIVATE_CHANNELS = /* @__PURE__ */ new Set(["orders", "balances", "deposits", "withdrawals", "account"]);
1076
+
1077
+ // src/ws/client.ts
1078
+ var DEFAULT_WS_URL = "wss://api.cexy.io/api/v1/ws";
1079
+ var MAX_CHANNEL_LENGTH = 64;
1080
+ var OPEN = 1;
1081
+ var CexyWebSocketError = class extends CexyError {
1082
+ code;
1083
+ /** True when this came from a server `error` frame (not a local guard or disconnect). */
1084
+ fromServer;
1085
+ constructor(code, message, fromServer = false) {
1086
+ super(message);
1087
+ this.code = code;
1088
+ this.fromServer = fromServer;
1089
+ }
1090
+ };
1091
+ var TEARDOWN_CODES = /* @__PURE__ */ new Set(["DISCONNECTED", "CLOSED"]);
1092
+ var ACK_TYPE = {
1093
+ auth: "authenticated",
1094
+ subscribe: "subscribed",
1095
+ unsubscribe: "unsubscribed",
1096
+ ping: "pong"
1097
+ };
1098
+ var defaultLogger = { warn: (m) => console.warn(`[cexy] ${m}`) };
1099
+ var CexyWebSocket = class extends TypedEmitter {
1100
+ url;
1101
+ #opts;
1102
+ #reconnect;
1103
+ #logger;
1104
+ #random;
1105
+ #restClient;
1106
+ #userAgent;
1107
+ #ctorOption;
1108
+ #socket = null;
1109
+ #welcome = null;
1110
+ #channels = /* @__PURE__ */ new Set();
1111
+ #token = null;
1112
+ #closedByUser = true;
1113
+ #everConnected = false;
1114
+ #reconnectAttempt = 0;
1115
+ #reconnectTimer = null;
1116
+ #pingTimer = null;
1117
+ #livenessTimer = null;
1118
+ #pending = /* @__PURE__ */ new Map();
1119
+ #nextId = 1;
1120
+ #windowStart = 0;
1121
+ #windowCount = 0;
1122
+ #warnedVersion = false;
1123
+ #books = /* @__PURE__ */ new Map();
1124
+ #connecting = null;
1125
+ constructor(options = {}) {
1126
+ super();
1127
+ this.url = options.url ?? DEFAULT_WS_URL;
1128
+ let parsed;
1129
+ try {
1130
+ parsed = new URL(this.url);
1131
+ } catch {
1132
+ throw new CexyWebSocketError("CONFIG", `invalid WebSocket url: ${this.url}`);
1133
+ }
1134
+ try {
1135
+ assertSecureUrl(parsed, "wss:", "ws:", options.allowInsecure === true, "WebSocket url");
1136
+ } catch (err) {
1137
+ throw new CexyWebSocketError("CONFIG", err.message);
1138
+ }
1139
+ this.#opts = {
1140
+ pingIntervalMs: options.pingIntervalMs ?? 3e4,
1141
+ livenessTimeoutMs: options.livenessTimeoutMs ?? 75e3,
1142
+ welcomeTimeoutMs: options.welcomeTimeoutMs ?? 1e4,
1143
+ ackTimeoutMs: options.ackTimeoutMs ?? 5e3,
1144
+ maxSubscriptions: options.maxSubscriptions ?? 100,
1145
+ maxMessagesPerMinute: options.maxMessagesPerMinute ?? 200
1146
+ };
1147
+ const rc = options.reconnect ?? true;
1148
+ this.#reconnect = rc === false ? null : {
1149
+ baseDelayMs: (rc === true ? void 0 : rc.baseDelayMs) ?? 1e3,
1150
+ maxDelayMs: (rc === true ? void 0 : rc.maxDelayMs) ?? 3e4,
1151
+ maxAttempts: (rc === true ? void 0 : rc.maxAttempts) ?? Infinity
1152
+ };
1153
+ this.#logger = options.logger ?? defaultLogger;
1154
+ this.#random = options.random ?? Math.random;
1155
+ this.#restClient = options.restClient;
1156
+ this.#userAgent = options.userAgent;
1157
+ this.#ctorOption = options.WebSocket;
1158
+ }
1159
+ /** The last `welcome` frame, or null before the first connection. */
1160
+ get welcome() {
1161
+ return this.#welcome;
1162
+ }
1163
+ get connected() {
1164
+ return this.#socket?.readyState === OPEN && this.#welcome !== null;
1165
+ }
1166
+ /** Channels currently held (restored after every reconnect). */
1167
+ get channels() {
1168
+ return [...this.#channels];
1169
+ }
1170
+ /** Opens the connection; resolves on the server's `welcome` frame. */
1171
+ connect() {
1172
+ if (this.connected && this.#welcome) return Promise.resolve(this.#welcome);
1173
+ if (this.#connecting) return this.#connecting;
1174
+ this.#closedByUser = false;
1175
+ this.#connecting = this.#open().finally(() => {
1176
+ this.#connecting = null;
1177
+ });
1178
+ return this.#connecting;
1179
+ }
1180
+ /**
1181
+ * Authenticates private channels with a session access token. Resolves on the server's
1182
+ * `authenticated` acknowledgement (same request id); rejects on an `error` with that id
1183
+ * (the token is then forgotten) or when no acknowledgement arrives within `ackTimeoutMs`.
1184
+ * The token is kept in memory and re-sent after each reconnect. When not connected, the
1185
+ * token is queued and the promise resolves with `queued: true`.
1186
+ * (API-key authentication is not available on the WebSocket yet.)
1187
+ */
1188
+ auth(token) {
1189
+ if (typeof token !== "string" || token === "") throw new CexyWebSocketError("CONFIG", "auth(): token is required");
1190
+ this.#token = token;
1191
+ if (!this.connected) return Promise.resolve({ userId: null, queued: true });
1192
+ const p = this.#auth(token);
1193
+ p.catch(() => {
1194
+ });
1195
+ return p;
1196
+ }
1197
+ /** Sends a ping with an id and resolves with the round-trip time in ms. */
1198
+ async ping() {
1199
+ const started = Date.now();
1200
+ await this.#request("ping", {}, [], true);
1201
+ return Date.now() - started;
1202
+ }
1203
+ /**
1204
+ * Subscribes to channels, e.g. `["ticker:BTC/USDT", "trades:BTC/USDT"]`. Resolves when the
1205
+ * server confirms. Beyond `maxSubscriptions` channels are refused locally (see `refused`).
1206
+ */
1207
+ async subscribe(channels) {
1208
+ const wanted = [...new Set(channels)];
1209
+ for (const c of wanted) {
1210
+ if (typeof c !== "string" || c === "" || c.length > MAX_CHANNEL_LENGTH) {
1211
+ throw new CexyWebSocketError("CONFIG", `invalid channel name: ${JSON.stringify(c)}`);
1212
+ }
1213
+ }
1214
+ const alreadySubscribed = wanted.filter((c) => this.#channels.has(c));
1215
+ const fresh = wanted.filter((c) => !this.#channels.has(c));
1216
+ const room = Math.max(0, this.#opts.maxSubscriptions - this.#channels.size);
1217
+ const accepted = fresh.slice(0, room);
1218
+ const refused = fresh.slice(room);
1219
+ if (refused.length) this.#logger.warn(`subscription cap (${this.#opts.maxSubscriptions}) reached; refused: ${refused.join(", ")}`);
1220
+ if (accepted.length === 0) return { added: [], refused, alreadySubscribed };
1221
+ for (const c of accepted) this.#channels.add(c);
1222
+ if (!this.connected) return { added: [], refused, alreadySubscribed };
1223
+ const added = await this.#sendSubscribe(accepted);
1224
+ return { added, refused, alreadySubscribed };
1225
+ }
1226
+ /**
1227
+ * Unsubscribes. Resolves on the `unsubscribed` acknowledgement (or after `ackTimeoutMs`
1228
+ * without one); rejects on an `error` with the request id.
1229
+ */
1230
+ async unsubscribe(channels) {
1231
+ const held = [...new Set(channels)].filter((c) => this.#channels.delete(c));
1232
+ if (held.length && this.connected) await this.#request("unsubscribe", { channels: held }, held, false);
1233
+ }
1234
+ /**
1235
+ * A live order book for `symbol` that follows the sync rules: subscribe first, then a REST
1236
+ * snapshot (sequence S); drop updates with sequence <= S; each update replaces the top 50
1237
+ * levels; a gap marks the book stale until the next update; a fresh snapshot after every
1238
+ * reconnect and after `CONCURRENT_MODIFICATION`. Resolves after the first snapshot.
1239
+ */
1240
+ async orderBook(symbol, options = {}) {
1241
+ const rest = this.#restClient;
1242
+ if (!rest) throw new CexyWebSocketError("CONFIG", "orderBook() needs restClient (use CexyClient.websocket())");
1243
+ const existing = this.#books.get(symbol);
1244
+ if (existing) return existing;
1245
+ const book = new LiveOrderBook(symbol, rest, options, () => {
1246
+ this.#books.delete(symbol);
1247
+ void this.unsubscribe([`orderbook:${symbol}`]);
1248
+ });
1249
+ this.#books.set(symbol, book);
1250
+ const res = await this.subscribe([`orderbook:${symbol}`]);
1251
+ if (res.refused.length) {
1252
+ this.#books.delete(symbol);
1253
+ throw new CexyWebSocketError("LOCAL_SUBSCRIPTION_LIMIT", `cannot subscribe to orderbook:${symbol}: cap reached`);
1254
+ }
1255
+ await book.resync();
1256
+ return book;
1257
+ }
1258
+ /** Closes the connection for good (no reconnect). */
1259
+ close() {
1260
+ this.#closedByUser = true;
1261
+ if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer);
1262
+ this.#reconnectTimer = null;
1263
+ for (const b of [...this.#books.values()]) b.markDisconnected();
1264
+ const s = this.#socket;
1265
+ this.#teardown(new CexyWebSocketError("CLOSED", "connection closed by client"));
1266
+ if (s) {
1267
+ try {
1268
+ s.close(1e3, "client closing");
1269
+ } catch {
1270
+ }
1271
+ }
1272
+ this.emit("close", { code: 1e3, reason: "client closing", willReconnect: false });
1273
+ }
1274
+ // ---------------------------------------------------------------------------------------
1275
+ async #open() {
1276
+ const Ctor = await this.#resolveCtor();
1277
+ return new Promise((resolve, reject) => {
1278
+ let settled = false;
1279
+ const socket = Ctor.native ? new Ctor.ctor(this.url) : new Ctor.ctor(this.url, this.#userAgent ? { headers: { "User-Agent": this.#userAgent } } : void 0);
1280
+ this.#socket = socket;
1281
+ const welcomeTimer = setTimeout(() => fail(new CexyWebSocketError("TIMEOUT", "no welcome frame from server")), this.#opts.welcomeTimeoutMs);
1282
+ const fail = (err) => {
1283
+ if (settled) return;
1284
+ settled = true;
1285
+ clearTimeout(welcomeTimer);
1286
+ this.#detach(socket);
1287
+ try {
1288
+ socket.close();
1289
+ } catch {
1290
+ }
1291
+ if (this.#socket === socket) this.#socket = null;
1292
+ reject(err);
1293
+ };
1294
+ socket.onopen = () => {
1295
+ this.#startHeartbeat();
1296
+ this.emit("open");
1297
+ };
1298
+ socket.onerror = () => {
1299
+ if (!settled) fail(new CexyWebSocketError("CONNECT_FAILED", `could not connect to ${this.url}`));
1300
+ };
1301
+ socket.onclose = (ev) => {
1302
+ if (!settled) {
1303
+ fail(new CexyWebSocketError("CONNECT_FAILED", `connection closed before welcome (code ${ev?.code ?? "?"})`));
1304
+ return;
1305
+ }
1306
+ if (this.#socket === socket) this.#onDropped(ev?.code, ev?.reason);
1307
+ };
1308
+ socket.onmessage = (ev) => {
1309
+ const frame = parseFrame(ev.data);
1310
+ if (!frame) return;
1311
+ this.#touch();
1312
+ if (!settled && frame["type"] === "welcome") {
1313
+ settled = true;
1314
+ clearTimeout(welcomeTimer);
1315
+ const welcome = this.#onWelcome(frame);
1316
+ resolve(welcome);
1317
+ return;
1318
+ }
1319
+ this.#onFrame(frame);
1320
+ };
1321
+ });
1322
+ }
1323
+ async #resolveCtor() {
1324
+ if (this.#ctorOption) return { ctor: this.#ctorOption, native: true };
1325
+ const g = globalThis;
1326
+ if (g.process?.versions?.node) {
1327
+ try {
1328
+ const name = "ws";
1329
+ const mod = await import(
1330
+ /* webpackIgnore: true */
1331
+ /* @vite-ignore */
1332
+ name
1333
+ );
1334
+ const def = mod.default;
1335
+ const ctor = mod.WebSocket ?? (typeof def === "function" ? def : def?.WebSocket);
1336
+ if (ctor) return { ctor, native: false };
1337
+ } catch {
1338
+ }
1339
+ }
1340
+ if (g.WebSocket) return { ctor: g.WebSocket, native: true };
1341
+ throw new CexyWebSocketError("NO_WEBSOCKET", "no WebSocket implementation: install the `ws` package or use Node 22+");
1342
+ }
1343
+ #onWelcome(welcome) {
1344
+ this.#welcome = welcome;
1345
+ if (welcome.protocol_version !== SUPPORTED_PROTOCOL_VERSION && !this.#warnedVersion) {
1346
+ this.#warnedVersion = true;
1347
+ this.#logger.warn(
1348
+ `server protocol_version ${welcome.protocol_version} is newer than this SDK supports (${SUPPORTED_PROTOCOL_VERSION}); continuing`
1349
+ );
1350
+ }
1351
+ const isReconnect = this.#everConnected;
1352
+ this.#everConnected = true;
1353
+ this.#reconnectAttempt = 0;
1354
+ this.emit("welcome", welcome);
1355
+ if (isReconnect) {
1356
+ if (this.#token) this.#reAuth(this.#token);
1357
+ const channels = [...this.#channels];
1358
+ if (channels.length) {
1359
+ this.#sendSubscribe(channels).catch((err) => this.#emitError(err));
1360
+ }
1361
+ this.emit("reconnected", welcome);
1362
+ this.emit("resync", "reconnect");
1363
+ for (const b of this.#books.values()) void b.resync();
1364
+ } else if (this.#channels.size) {
1365
+ if (this.#token) this.#reAuth(this.#token);
1366
+ this.#sendSubscribe([...this.#channels]).catch((err) => this.#emitError(err));
1367
+ } else if (this.#token) {
1368
+ this.#reAuth(this.#token);
1369
+ }
1370
+ return welcome;
1371
+ }
1372
+ #onFrame(frame) {
1373
+ const type = frame["type"];
1374
+ switch (type) {
1375
+ case "welcome":
1376
+ this.#onWelcome(frame);
1377
+ return;
1378
+ case "pong": {
1379
+ const id = typeof frame["id"] === "string" ? frame["id"] : null;
1380
+ if (id !== null) this.#settle(id, "ping", frame);
1381
+ this.emit("pong", id);
1382
+ return;
1383
+ }
1384
+ case "authenticated": {
1385
+ const id = typeof frame["id"] === "string" ? frame["id"] : null;
1386
+ if (id !== null) this.#settle(id, "auth", frame);
1387
+ this.emit("authenticated", typeof frame["user_id"] === "string" ? frame["user_id"] : null);
1388
+ return;
1389
+ }
1390
+ case "subscribed":
1391
+ case "unsubscribed": {
1392
+ const f = frame;
1393
+ const channels = Array.isArray(f.channels) ? f.channels.filter((c) => typeof c === "string") : [];
1394
+ const kind = type === "subscribed" ? "subscribe" : "unsubscribe";
1395
+ const id = typeof f.id === "string" ? f.id : null;
1396
+ if (id !== null) this.#settle(id, kind, frame);
1397
+ else {
1398
+ for (const p of this.#pending.values()) {
1399
+ if (p.kind === kind && channels.some((c) => p.channels.includes(c))) {
1400
+ this.#settle(p.id, kind, frame);
1401
+ break;
1402
+ }
1403
+ }
1404
+ }
1405
+ if (type === "subscribed") this.emit("subscribed", channels);
1406
+ else this.emit("unsubscribed", channels);
1407
+ return;
1408
+ }
1409
+ case "error": {
1410
+ const code = typeof frame["code"] === "string" ? frame["code"] : "UNKNOWN";
1411
+ const message = typeof frame["message"] === "string" ? frame["message"] : code;
1412
+ const f = { type: "error", code, message, id: typeof frame["id"] === "string" ? frame["id"] : null };
1413
+ const err = new CexyWebSocketError(code, message, true);
1414
+ const id = f.id ?? null;
1415
+ if (id !== null) {
1416
+ const pending = this.#pending.get(id);
1417
+ if (pending) {
1418
+ this.#pending.delete(id);
1419
+ clearTimeout(pending.timer);
1420
+ pending.reject(err);
1421
+ }
1422
+ }
1423
+ this.emit("serverError", err, f);
1424
+ if (f.code === "CONCURRENT_MODIFICATION" && id === null) {
1425
+ this.emit("resync", "concurrent_modification");
1426
+ for (const b of this.#books.values()) void b.resync();
1427
+ }
1428
+ return;
1429
+ }
1430
+ }
1431
+ if (typeof type !== "string" || !KNOWN_EVENT_TYPES.has(type)) {
1432
+ this.#logger.debug?.(`ignoring unknown frame type ${String(type)}`);
1433
+ return;
1434
+ }
1435
+ const event = frame;
1436
+ if (event.type === "session.revoked") this.#onAuthLost(event);
1437
+ if (event.type === "orderbook.update") {
1438
+ const symbol = event.channel.startsWith("orderbook:") ? event.channel.slice("orderbook:".length) : event.data?.symbol;
1439
+ if (symbol) this.#books.get(symbol)?.onUpdate(event);
1440
+ }
1441
+ this.emit("event", event);
1442
+ }
1443
+ #onAuthLost(event) {
1444
+ this.#token = null;
1445
+ for (const c of [...this.#channels]) if (PRIVATE_CHANNELS.has(c)) this.#channels.delete(c);
1446
+ this.emit("authLost", event);
1447
+ }
1448
+ /** Resolves the pending request `id` if the acknowledgement type matches its kind. */
1449
+ #settle(id, kind, frame) {
1450
+ const p = this.#pending.get(id);
1451
+ if (!p || p.kind !== kind) return;
1452
+ this.#pending.delete(id);
1453
+ clearTimeout(p.timer);
1454
+ p.resolve(frame);
1455
+ }
1456
+ /**
1457
+ * Sends `{op, id, ...payload}` and waits for the acknowledgement with the same id
1458
+ * (`ACK_TYPE[kind]`) or an `error` with that id. With `strict`, no acknowledgement within
1459
+ * `ackTimeoutMs` rejects; otherwise it resolves with null.
1460
+ */
1461
+ #request(kind, payload, channels, strict) {
1462
+ const id = this.#newId();
1463
+ return new Promise((resolve, reject) => {
1464
+ const timer = setTimeout(() => {
1465
+ this.#pending.delete(id);
1466
+ if (strict) reject(new CexyWebSocketError("TIMEOUT", `no ${ACK_TYPE[kind]} acknowledgement for ${kind} (id ${id})`));
1467
+ else resolve(null);
1468
+ }, this.#opts.ackTimeoutMs);
1469
+ this.#pending.set(id, { id, kind, channels, resolve, reject, timer });
1470
+ try {
1471
+ this.#send({ op: kind, ...payload, id });
1472
+ } catch (err) {
1473
+ clearTimeout(timer);
1474
+ this.#pending.delete(id);
1475
+ reject(toError(err));
1476
+ }
1477
+ });
1478
+ }
1479
+ async #auth(token) {
1480
+ try {
1481
+ const ack = await this.#request("auth", { token }, [], true);
1482
+ const userId = ack && typeof ack["user_id"] === "string" ? ack["user_id"] : null;
1483
+ return { userId, queued: false };
1484
+ } catch (err) {
1485
+ if (err instanceof CexyWebSocketError && err.fromServer && this.#token === token) {
1486
+ this.#token = null;
1487
+ }
1488
+ throw err;
1489
+ }
1490
+ }
1491
+ /** Automatic (re-)authentication; failures surface as `error` events. */
1492
+ #reAuth(token) {
1493
+ this.#auth(token).catch((err) => this.#emitError(err));
1494
+ }
1495
+ async #sendSubscribe(channels) {
1496
+ const ack = await this.#request("subscribe", { channels }, channels, false);
1497
+ const acked = ack?.["channels"];
1498
+ return Array.isArray(acked) ? acked.filter((c) => typeof c === "string") : [];
1499
+ }
1500
+ #send(frame) {
1501
+ const s = this.#socket;
1502
+ if (!s || s.readyState !== OPEN) throw new CexyWebSocketError("NOT_CONNECTED", "WebSocket is not connected");
1503
+ const now = Date.now();
1504
+ if (now - this.#windowStart >= 6e4) {
1505
+ this.#windowStart = now;
1506
+ this.#windowCount = 0;
1507
+ }
1508
+ if (frame["op"] !== "ping" && this.#windowCount >= this.#opts.maxMessagesPerMinute) {
1509
+ throw new CexyWebSocketError(
1510
+ "LOCAL_RATE_LIMIT",
1511
+ `more than ${this.#opts.maxMessagesPerMinute} messages this minute; the server closes the socket above 240`
1512
+ );
1513
+ }
1514
+ this.#windowCount++;
1515
+ s.send(JSON.stringify(frame));
1516
+ }
1517
+ #startHeartbeat() {
1518
+ this.#stopTimers();
1519
+ this.#pingTimer = setInterval(() => {
1520
+ try {
1521
+ this.#send({ op: "ping" });
1522
+ } catch {
1523
+ }
1524
+ }, this.#opts.pingIntervalMs);
1525
+ this.#touch();
1526
+ }
1527
+ #touch() {
1528
+ if (this.#livenessTimer) clearTimeout(this.#livenessTimer);
1529
+ this.#livenessTimer = setTimeout(() => {
1530
+ this.#logger.warn(`no frame from server for ${this.#opts.livenessTimeoutMs} ms; reconnecting`);
1531
+ const s = this.#socket;
1532
+ if (s) {
1533
+ this.#detach(s);
1534
+ try {
1535
+ const terminate = s.terminate;
1536
+ if (terminate) terminate.call(s);
1537
+ else s.close(4e3, "liveness timeout");
1538
+ } catch {
1539
+ }
1540
+ }
1541
+ this.#onDropped(4e3, "liveness timeout");
1542
+ }, this.#opts.livenessTimeoutMs);
1543
+ }
1544
+ #stopTimers() {
1545
+ if (this.#pingTimer) clearInterval(this.#pingTimer);
1546
+ if (this.#livenessTimer) clearTimeout(this.#livenessTimer);
1547
+ this.#pingTimer = null;
1548
+ this.#livenessTimer = null;
1549
+ }
1550
+ #detach(s) {
1551
+ s.onopen = null;
1552
+ s.onmessage = null;
1553
+ s.onclose = null;
1554
+ s.onerror = () => {
1555
+ };
1556
+ }
1557
+ #teardown(err) {
1558
+ this.#stopTimers();
1559
+ if (this.#socket) this.#detach(this.#socket);
1560
+ this.#socket = null;
1561
+ this.#welcome = null;
1562
+ for (const p of this.#pending.values()) {
1563
+ clearTimeout(p.timer);
1564
+ p.reject(err);
1565
+ }
1566
+ this.#pending.clear();
1567
+ }
1568
+ #onDropped(code, reason) {
1569
+ this.#teardown(new CexyWebSocketError("DISCONNECTED", `connection lost (code ${code ?? "?"})`));
1570
+ for (const b of this.#books.values()) b.markDisconnected();
1571
+ const willReconnect = !this.#closedByUser && this.#reconnect !== null;
1572
+ this.emit("close", { code, reason, willReconnect });
1573
+ if (willReconnect) this.#scheduleReconnect();
1574
+ }
1575
+ #scheduleReconnect() {
1576
+ const rc = this.#reconnect;
1577
+ if (!rc || this.#closedByUser) return;
1578
+ if (this.#reconnectAttempt >= rc.maxAttempts) {
1579
+ this.emit("error", new CexyWebSocketError("RECONNECT_FAILED", `gave up after ${rc.maxAttempts} reconnect attempts`));
1580
+ return;
1581
+ }
1582
+ const attempt = ++this.#reconnectAttempt;
1583
+ const cap = Math.min(rc.maxDelayMs, rc.baseDelayMs * 2 ** (attempt - 1));
1584
+ const delayMs = Math.floor(this.#random() * cap);
1585
+ this.emit("reconnecting", { attempt, delayMs });
1586
+ this.#reconnectTimer = setTimeout(() => {
1587
+ this.#reconnectTimer = null;
1588
+ if (this.#closedByUser) return;
1589
+ this.#open().catch((err) => {
1590
+ this.emit("error", toError(err));
1591
+ this.#scheduleReconnect();
1592
+ });
1593
+ }, delayMs);
1594
+ }
1595
+ /** Emits `error`, except for requests cut short by a disconnect (the reconnect redoes them). */
1596
+ #emitError(err) {
1597
+ if (err instanceof CexyWebSocketError && TEARDOWN_CODES.has(err.code)) return;
1598
+ this.emit("error", toError(err));
1599
+ }
1600
+ #newId() {
1601
+ return String(this.#nextId++);
1602
+ }
1603
+ };
1604
+ function parseFrame(data) {
1605
+ let text;
1606
+ if (typeof data === "string") text = data;
1607
+ else if (data instanceof ArrayBuffer) text = new TextDecoder().decode(data);
1608
+ else if (ArrayBuffer.isView(data)) text = new TextDecoder().decode(data);
1609
+ else return null;
1610
+ try {
1611
+ const v = JSON.parse(text);
1612
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
1613
+ } catch {
1614
+ return null;
1615
+ }
1616
+ }
1617
+ function toError(err) {
1618
+ return err instanceof Error ? err : new Error(String(err));
1619
+ }
1620
+
1621
+ // src/client.ts
1622
+ var DEFAULT_BASE_URL = "https://api.cexy.io";
1623
+ var DEFAULT_RPM_ANONYMOUS = 100;
1624
+ var DEFAULT_RPM_WITH_KEY = 300;
1625
+ var CexyClient = class {
1626
+ markets;
1627
+ assets;
1628
+ networks;
1629
+ fees;
1630
+ pools;
1631
+ account;
1632
+ exports;
1633
+ wallet;
1634
+ trading;
1635
+ #transport;
1636
+ #baseUrl;
1637
+ #allowInsecure;
1638
+ constructor(options = {}) {
1639
+ const { apiKey, apiSecret } = options;
1640
+ const hasKey = apiKey !== void 0 && apiKey !== null && apiKey !== "";
1641
+ const hasSecret = apiSecret !== void 0 && apiSecret !== null && apiSecret !== "";
1642
+ if (hasKey !== hasSecret) {
1643
+ throw new CexyConfigError("apiKey and apiSecret must be given together (got only one of them)");
1644
+ }
1645
+ if (hasKey && options.authenticator) {
1646
+ throw new CexyConfigError("pass either apiKey/apiSecret or authenticator, not both");
1647
+ }
1648
+ const authenticator = options.authenticator ?? (hasKey ? new ApiKeyAuthenticator(apiKey, apiSecret) : null);
1649
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
1650
+ let parsed;
1651
+ try {
1652
+ parsed = new URL(baseUrl);
1653
+ } catch {
1654
+ throw new CexyConfigError(`baseUrl is not a valid URL: ${baseUrl}`);
1655
+ }
1656
+ if (parsed.username || parsed.password || parsed.search) {
1657
+ throw new CexyConfigError("baseUrl must not contain credentials or a query string");
1658
+ }
1659
+ assertSecureUrl(parsed, "https:", "http:", options.allowInsecure === true, "baseUrl");
1660
+ this.#allowInsecure = options.allowInsecure === true;
1661
+ const fetchImpl = options.fetch ?? globalThis.fetch;
1662
+ if (!fetchImpl) throw new CexyConfigError("no global fetch found (Node 20+ required); pass options.fetch");
1663
+ const timeoutMs = options.timeoutMs ?? 1e4;
1664
+ const maxRetries = options.maxRetries ?? 3;
1665
+ if (!(timeoutMs > 0)) throw new CexyConfigError("timeoutMs must be > 0");
1666
+ if (!(maxRetries >= 0)) throw new CexyConfigError("maxRetries must be >= 0");
1667
+ const sleep2 = options.sleep ?? sleep;
1668
+ const limiter = options.rateLimit === false ? null : new RateLimiter({
1669
+ requestsPerMinute: options.rateLimit?.requestsPerMinute ?? (authenticator ? DEFAULT_RPM_WITH_KEY : DEFAULT_RPM_ANONYMOUS),
1670
+ sleep: sleep2
1671
+ });
1672
+ this.#baseUrl = baseUrl;
1673
+ this.#transport = new Transport({
1674
+ baseUrl,
1675
+ timeoutMs,
1676
+ maxRetries,
1677
+ fetch: options.fetch ? fetchImpl : (input, init) => fetchImpl(input, init),
1678
+ authenticator,
1679
+ limiter,
1680
+ userAgent: canSetUserAgent() ? [USER_AGENT, options.userAgentSuffix].filter(Boolean).join(" ") : null,
1681
+ sleep: sleep2,
1682
+ random: options.random ?? Math.random,
1683
+ onRetry: options.onRetry
1684
+ });
1685
+ const t = this.#transport;
1686
+ this.markets = new MarketsResource(t);
1687
+ this.assets = new AssetsResource(t);
1688
+ this.networks = new NetworksResource(t);
1689
+ this.fees = new FeesResource(t);
1690
+ this.pools = new PoolsResource(t);
1691
+ this.account = new AccountResource(t);
1692
+ this.exports = new ExportsResource(t);
1693
+ this.wallet = new WalletResource(t);
1694
+ this.trading = new TradingResource(t);
1695
+ }
1696
+ /** True if the client holds credentials (private endpoints are available). */
1697
+ get hasCredentials() {
1698
+ return this.#transport.config.authenticator !== null;
1699
+ }
1700
+ get baseUrl() {
1701
+ return this.#baseUrl;
1702
+ }
1703
+ /** Current client-side rate limit state, or null when disabled. */
1704
+ get rateLimit() {
1705
+ return this.#transport.config.limiter?.state ?? null;
1706
+ }
1707
+ /** The User-Agent this client sends (null in browsers). */
1708
+ get userAgent() {
1709
+ return this.#transport.config.userAgent;
1710
+ }
1711
+ /** Server clock. Compare it with yours to detect skew. */
1712
+ async time(opts) {
1713
+ const raw = await this.#transport.request({ op: "server_time" }, opts);
1714
+ return raw.data.data;
1715
+ }
1716
+ /** Public exchange configuration (maintenance state, page sizes, WebSocket path, ...). */
1717
+ async config(opts) {
1718
+ const raw = await this.#transport.request({ op: "exchange_config" }, opts);
1719
+ return raw.data.data;
1720
+ }
1721
+ /**
1722
+ * A WebSocket client for the same deployment, wired to this client for order-book
1723
+ * snapshots. Call `connect()` on it.
1724
+ */
1725
+ websocket(options = {}) {
1726
+ const url = options.url ?? this.#baseUrl.replace(/^http/, "ws") + "/api/v1/ws";
1727
+ return new CexyWebSocket({
1728
+ ...options,
1729
+ allowInsecure: options.allowInsecure ?? this.#allowInsecure,
1730
+ url,
1731
+ restClient: this,
1732
+ userAgent: options.userAgent ?? this.#transport.config.userAgent ?? void 0
1733
+ });
1734
+ }
1735
+ toString() {
1736
+ const auth = this.#transport.config.authenticator;
1737
+ return `CexyClient(${this.#baseUrl}, auth=${auth ? `${auth.kind} [REDACTED]` : "none"})`;
1738
+ }
1739
+ toJSON() {
1740
+ const auth = this.#transport.config.authenticator;
1741
+ return { baseUrl: this.#baseUrl, auth: auth ? `${auth.kind} [REDACTED]` : "none" };
1742
+ }
1743
+ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
1744
+ return this.toString();
1745
+ }
1746
+ };
1747
+ function canSetUserAgent() {
1748
+ const g = globalThis;
1749
+ return !(typeof g.window !== "undefined" && typeof g.window.document !== "undefined");
1750
+ }
1751
+
1752
+ export { AccountResource, ApiKeyAuthenticator, AssetsResource, AuthenticationError, CexyApiError, CexyClient, CexyConfigError, CexyConnectionError, CexyError, CexyTimeoutError, CexyWebSocket, CexyWebSocketError, ConflictError, DEFAULT_BASE_URL, DEFAULT_RPM_ANONYMOUS, DEFAULT_RPM_WITH_KEY, DEFAULT_WS_URL, ExportsResource, FeesResource, ForbiddenError, InvalidAmountError, KNOWN_EVENT_TYPES, LiveOrderBook, MarketsResource, NetworksResource, NotFoundError, OPERATIONS, OrderStateUnknownError, PRIVATE_CHANNELS, PoolsResource, RateLimitError, RateLimiter, SUPPORTED_PROTOCOL_VERSION, ServerError, TradingResource, TypedEmitter, USER_AGENT, UnprocessableError, VERSION, ValidationError, WS_BOOK_DEPTH, WalletResource, assertAmountFields, errorFromResponse, isAmount, isKnownErrorCode, isLocalHost, isRetryable, paginate };