@solard/sdk 0.2.31 → 0.2.37

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/src/live.ts CHANGED
@@ -4,42 +4,61 @@ import {
4
4
  subscribeMigrations as subscribeCoreMigrations,
5
5
  subscribeTrades as subscribeCoreTrades,
6
6
  type TradeEvent,
7
- type TradeSubscription,
8
- type MigrationEvent,
7
+ type TradeSubscription,
8
+ type MigrationEvent,
9
+ createLiveTradeHistoryRecorder,
10
+ type LiveTradeHistoryOptions,
9
11
  } from "@solard/core";
10
12
 
11
- export type LiveEndpointOptions = {
12
- /** Ordered endpoints on the same cluster; public RPC can be supplied last. */
13
- rpcUrls?: readonly string[];
14
- /** Fresh HTTP and WebSocket readiness probes for pooled trade streams (default 30s). */
15
- healthCheckIntervalMs?: number;
16
- };
17
- export type SubscribeLaunchesOptions = Omit<
13
+ export type LiveEndpointOptions = {
14
+ /** Ordered endpoints on the same cluster; public RPC can be supplied last. */
15
+ rpcUrls?: readonly string[];
16
+ /** Fresh HTTP and WebSocket readiness probes for pooled trade streams (default 30s). */
17
+ healthCheckIntervalMs?: number;
18
+ };
19
+ export type SubscribeLaunchesOptions = Omit<
18
20
  Parameters<typeof subscribeCoreLaunches>[0],
19
21
  "connection"
20
- > & LiveEndpointOptions;
22
+ > &
23
+ LiveEndpointOptions;
21
24
  export type SubscribeMigrationsOptions = Omit<
22
25
  Parameters<typeof subscribeCoreMigrations>[0],
23
26
  "connection"
24
- > & LiveEndpointOptions;
27
+ > &
28
+ LiveEndpointOptions;
25
29
  export type SubscribeTradesOptions = Omit<
26
30
  Parameters<typeof subscribeCoreTrades>[0],
27
31
  "connection"
28
- > & LiveEndpointOptions;
32
+ > &
33
+ LiveEndpointOptions;
29
34
 
30
- export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade">;
35
+ export type ListenTradesOptions = Omit<SubscribeTradesOptions, "onTrade"> & {
36
+ dbPath?: string;
37
+ /** Enabled by default; collected live history is partial and retains commitment. */
38
+ history?:
39
+ | false
40
+ | Pick<
41
+ LiveTradeHistoryOptions,
42
+ "flushIntervalMs" | "batchSize" | "maxPending"
43
+ >;
44
+ };
31
45
 
32
- export type TradeListener = {
46
+ export type TradeListener = {
33
47
  add(tokens: string | readonly string[]): Promise<void>;
34
48
  remove(tokens: string | readonly string[]): Promise<void>;
35
49
  has(token: string): boolean;
36
50
  list(): string[];
37
- onTrade(callback: (event: TradeEvent) => void | Promise<void>): () => void;
38
- onMigration(callback: (event: TradeListenerMigration) => void | Promise<void>): () => void;
51
+ onTrade(callback: (event: TradeEvent) => void | Promise<void>): () => void;
52
+ onMigration(
53
+ callback: (event: TradeListenerMigration) => void | Promise<void>,
54
+ ): () => void;
39
55
  close(): Promise<void>;
40
56
  readonly closed: Promise<void>;
41
- };
42
- export type TradeListenerMigration = MigrationEvent & { oldPool: string | null; newPool: string };
57
+ };
58
+ export type TradeListenerMigration = MigrationEvent & {
59
+ oldPool: string | null;
60
+ newPool: string;
61
+ };
43
62
 
44
63
  type LiveConnection = {
45
64
  endpoint: string;
@@ -48,46 +67,76 @@ type LiveConnection = {
48
67
  };
49
68
 
50
69
  let shared: LiveConnection | null = null;
51
- let readiness: { endpoint: string; promise: Promise<void> } | null = null;
52
-
53
- /** HTTP + a fresh WebSocket handshake must both succeed before choosing an endpoint. */
54
- export class LiveEndpointPool {
55
- private active = 0;
56
- readonly endpoints: readonly string[];
57
- constructor(endpoints: readonly string[], private readonly probe: (endpoint: string) => Promise<void> = async (endpoint) => {
58
- await Promise.all([preflightRpc(endpoint), preflightWebSocket(endpoint)]);
59
- }) {
60
- this.endpoints = [...new Set(endpoints)];
61
- if (!this.endpoints.length) throw new Error("Live RPC endpoint pool cannot be empty");
62
- for (const endpoint of this.endpoints) deriveWebSocketEndpoint(endpoint);
63
- }
64
- get endpoint(): string { return this.endpoints[this.active]!; }
65
- async select(skipCurrent = false): Promise<string> {
66
- const start = this.active;
67
- let lastError: unknown;
68
- for (let offset = skipCurrent ? 1 : 0; offset < this.endpoints.length + (skipCurrent ? 1 : 0); offset++) {
69
- const index = (start + offset) % this.endpoints.length;
70
- try { await this.probe(this.endpoints[index]!); this.active = index; return this.endpoint; }
71
- catch (error) { lastError = error; }
72
- }
73
- throw lastError;
74
- }
75
- async check(): Promise<string> {
76
- try { await this.probe(this.endpoint); return this.endpoint; }
77
- catch { return await this.select(true); }
78
- }
79
- }
80
- const livePools = new Map<string, LiveEndpointPool>();
81
- function endpointPool(options: LiveEndpointOptions): LiveEndpointPool | undefined {
82
- if (!options.rpcUrls?.length) return undefined;
83
- const key = JSON.stringify(options.rpcUrls);
84
- let pool = livePools.get(key);
85
- if (!pool) { pool = new LiveEndpointPool(options.rpcUrls); livePools.set(key, pool); }
86
- return pool;
87
- }
88
- function connectionFor(endpoint: string): LiveConnection {
89
- return { endpoint, websocketEndpoint: deriveWebSocketEndpoint(endpoint), connection: new Connection(endpoint, "confirmed") };
90
- }
70
+ let readiness: { endpoint: string; promise: Promise<void> } | null = null;
71
+
72
+ /** HTTP + a fresh WebSocket handshake must both succeed before choosing an endpoint. */
73
+ export class LiveEndpointPool {
74
+ private active = 0;
75
+ readonly endpoints: readonly string[];
76
+ constructor(
77
+ endpoints: readonly string[],
78
+ private readonly probe: (endpoint: string) => Promise<void> = async (
79
+ endpoint,
80
+ ) => {
81
+ await Promise.all([preflightRpc(endpoint), preflightWebSocket(endpoint)]);
82
+ },
83
+ ) {
84
+ this.endpoints = [...new Set(endpoints)];
85
+ if (!this.endpoints.length)
86
+ throw new Error("Live RPC endpoint pool cannot be empty");
87
+ for (const endpoint of this.endpoints) deriveWebSocketEndpoint(endpoint);
88
+ }
89
+ get endpoint(): string {
90
+ return this.endpoints[this.active]!;
91
+ }
92
+ async select(skipCurrent = false): Promise<string> {
93
+ const start = this.active;
94
+ let lastError: unknown;
95
+ for (
96
+ let offset = skipCurrent ? 1 : 0;
97
+ offset < this.endpoints.length + (skipCurrent ? 1 : 0);
98
+ offset++
99
+ ) {
100
+ const index = (start + offset) % this.endpoints.length;
101
+ try {
102
+ await this.probe(this.endpoints[index]!);
103
+ this.active = index;
104
+ return this.endpoint;
105
+ } catch (error) {
106
+ lastError = error;
107
+ }
108
+ }
109
+ throw lastError;
110
+ }
111
+ async check(): Promise<string> {
112
+ try {
113
+ await this.probe(this.endpoint);
114
+ return this.endpoint;
115
+ } catch {
116
+ return await this.select(true);
117
+ }
118
+ }
119
+ }
120
+ const livePools = new Map<string, LiveEndpointPool>();
121
+ function endpointPool(
122
+ options: LiveEndpointOptions,
123
+ ): LiveEndpointPool | undefined {
124
+ if (!options.rpcUrls?.length) return undefined;
125
+ const key = JSON.stringify(options.rpcUrls);
126
+ let pool = livePools.get(key);
127
+ if (!pool) {
128
+ pool = new LiveEndpointPool(options.rpcUrls);
129
+ livePools.set(key, pool);
130
+ }
131
+ return pool;
132
+ }
133
+ function connectionFor(endpoint: string): LiveConnection {
134
+ return {
135
+ endpoint,
136
+ websocketEndpoint: deriveWebSocketEndpoint(endpoint),
137
+ connection: new Connection(endpoint, "confirmed"),
138
+ };
139
+ }
91
140
 
92
141
  function endpointFromEnv(): string {
93
142
  const endpoint = process.env.RPC_ENDPOINT?.trim();
@@ -289,9 +338,11 @@ async function ensureReady(endpoint: string): Promise<void> {
289
338
  }
290
339
  }
291
340
 
292
- async function liveConnection(options: LiveEndpointOptions = {}): Promise<LiveConnection> {
293
- const pool = endpointPool(options);
294
- if (pool) return connectionFor(await pool.select());
341
+ async function liveConnection(
342
+ options: LiveEndpointOptions = {},
343
+ ): Promise<LiveConnection> {
344
+ const pool = endpointPool(options);
345
+ if (pool) return connectionFor(await pool.select());
295
346
  const endpoint = endpointFromEnv();
296
347
  await ensureReady(endpoint);
297
348
  if (shared?.endpoint === endpoint) return shared;
@@ -317,78 +368,120 @@ function notifyReady(
317
368
  } catch {}
318
369
  }
319
370
 
320
- export async function subscribeTrades(options: SubscribeTradesOptions) {
321
- let live = await liveConnection(options);
322
- notifyReady(options, live);
323
- const pool = endpointPool(options);
324
- if (!pool) return await subscribeCoreTrades({ ...options, connection: live.connection });
325
- // Keep duplicate suppression across connection changes. Provider probes detect
326
- // dead credentials/quota; they cannot diagnose a silent old socket when a new
327
- // handshake to that provider remains healthy.
328
- const seen = new Set<string>();
329
- const onTrade = async (event: TradeEvent) => {
330
- const key = [event.signature, event.venue, event.mint, event.pool, event.side, String(event.baseRaw), String(event.quoteRaw)].join(":");
331
- if (seen.has(key)) return;
332
- seen.add(key);
333
- if (seen.size > 4096) seen.delete(seen.values().next().value!);
334
- await options.onTrade(event);
335
- };
336
- let subscription = await subscribeCoreTrades({ ...options, signal: undefined, onTrade, connection: live.connection });
337
- let stopped = false;
338
- let tail: Promise<unknown> = Promise.resolve();
339
- const serial = <T>(work: () => Promise<T>): Promise<T> => {
340
- const pending = tail.then(work, work); tail = pending.catch(() => undefined); return pending;
341
- };
342
- let settle!: () => void;
343
- const closed = new Promise<void>((resolve) => { settle = resolve; });
344
- const requestedInterval = options.healthCheckIntervalMs ?? 30_000;
345
- if (!Number.isFinite(requestedInterval) || requestedInterval < 1000) {
346
- await subscription.close();
347
- throw new Error("healthCheckIntervalMs must be at least 1000");
348
- }
349
- let checking = false;
350
- const timer = setInterval(() => {
351
- if (stopped || checking) return;
352
- checking = true;
353
- void serial(async () => {
354
- if (stopped) return;
355
- try {
356
- const endpoint = await pool.check();
357
- if (stopped || endpoint === live.endpoint) return;
358
- const tokens = subscription.listTokens();
359
- const nextLive = connectionFor(endpoint);
360
- const next = await subscribeCoreTrades({ ...options, signal: undefined, tokens, onTrade, connection: nextLive.connection });
361
- await subscription.close();
362
- subscription = next;
363
- live = nextLive;
364
- notifyReady(options, live);
365
- } catch (error) {
366
- try { options.onStatus?.("rpc-unavailable", { error: safeMessage(error) }); } catch {}
367
- }
368
- }).finally(() => { checking = false; });
369
- }, Math.trunc(requestedInterval));
370
- timer.unref?.();
371
- const close = async () => {
372
- if (stopped) return await closed;
373
- stopped = true;
374
- clearInterval(timer);
375
- options.signal?.removeEventListener("abort", abort);
376
- await serial(async () => { await subscription.close(); settle(); });
377
- };
378
- const abort = () => { void close(); };
379
- if (options.signal?.aborted) await close();
380
- else options.signal?.addEventListener("abort", abort, { once: true });
381
- return {
382
- addTokens: (tokens: string | readonly string[]) => serial(async () => { if (stopped) throw new Error("Trade subscription is closed"); await subscription.addTokens(tokens); }),
383
- removeTokens: (tokens: string | readonly string[]) => serial(async () => { if (!stopped) await subscription.removeTokens(tokens); }),
384
- hasToken: (token: string) => !stopped && subscription.hasToken(token),
385
- listTokens: () => stopped ? [] : subscription.listTokens(),
386
- close, closed,
387
- } satisfies TradeSubscription;
388
- }
371
+ export async function subscribeTrades(options: SubscribeTradesOptions) {
372
+ let live = await liveConnection(options);
373
+ notifyReady(options, live);
374
+ const pool = endpointPool(options);
375
+ if (!pool)
376
+ return await subscribeCoreTrades({
377
+ ...options,
378
+ connection: live.connection,
379
+ });
380
+ // Keep duplicate suppression across connection changes. Provider probes detect
381
+ // dead credentials/quota; they cannot diagnose a silent old socket when a new
382
+ // handshake to that provider remains healthy.
383
+ const seen = new Set<string>();
384
+ const onTrade = async (event: TradeEvent) => {
385
+ const key = [
386
+ event.signature,
387
+ event.venue,
388
+ event.mint,
389
+ event.pool,
390
+ event.eventIndex ?? `${event.side}:${event.baseRaw}:${event.quoteRaw}`,
391
+ ].join(":");
392
+ if (seen.has(key)) return;
393
+ seen.add(key);
394
+ if (seen.size > 4096) seen.delete(seen.values().next().value!);
395
+ await options.onTrade(event);
396
+ };
397
+ let subscription = await subscribeCoreTrades({
398
+ ...options,
399
+ signal: undefined,
400
+ onTrade,
401
+ connection: live.connection,
402
+ });
403
+ let stopped = false;
404
+ let tail: Promise<unknown> = Promise.resolve();
405
+ const serial = <T>(work: () => Promise<T>): Promise<T> => {
406
+ const pending = tail.then(work, work);
407
+ tail = pending.catch(() => undefined);
408
+ return pending;
409
+ };
410
+ let settle!: () => void;
411
+ const closed = new Promise<void>((resolve) => {
412
+ settle = resolve;
413
+ });
414
+ const requestedInterval = options.healthCheckIntervalMs ?? 30_000;
415
+ if (!Number.isFinite(requestedInterval) || requestedInterval < 1000) {
416
+ await subscription.close();
417
+ throw new Error("healthCheckIntervalMs must be at least 1000");
418
+ }
419
+ let checking = false;
420
+ const timer = setInterval(() => {
421
+ if (stopped || checking) return;
422
+ checking = true;
423
+ void serial(async () => {
424
+ if (stopped) return;
425
+ try {
426
+ const endpoint = await pool.check();
427
+ if (stopped || endpoint === live.endpoint) return;
428
+ const tokens = subscription.listTokens();
429
+ const nextLive = connectionFor(endpoint);
430
+ const next = await subscribeCoreTrades({
431
+ ...options,
432
+ signal: undefined,
433
+ tokens,
434
+ onTrade,
435
+ connection: nextLive.connection,
436
+ });
437
+ await subscription.close();
438
+ subscription = next;
439
+ live = nextLive;
440
+ notifyReady(options, live);
441
+ } catch (error) {
442
+ try {
443
+ options.onStatus?.("rpc-unavailable", { error: safeMessage(error) });
444
+ } catch {}
445
+ }
446
+ }).finally(() => {
447
+ checking = false;
448
+ });
449
+ }, Math.trunc(requestedInterval));
450
+ timer.unref?.();
451
+ const close = async () => {
452
+ if (stopped) return await closed;
453
+ stopped = true;
454
+ clearInterval(timer);
455
+ options.signal?.removeEventListener("abort", abort);
456
+ await serial(async () => {
457
+ await subscription.close();
458
+ settle();
459
+ });
460
+ };
461
+ const abort = () => {
462
+ void close();
463
+ };
464
+ if (options.signal?.aborted) await close();
465
+ else options.signal?.addEventListener("abort", abort, { once: true });
466
+ return {
467
+ addTokens: (tokens: string | readonly string[]) =>
468
+ serial(async () => {
469
+ if (stopped) throw new Error("Trade subscription is closed");
470
+ await subscription.addTokens(tokens);
471
+ }),
472
+ removeTokens: (tokens: string | readonly string[]) =>
473
+ serial(async () => {
474
+ if (!stopped) await subscription.removeTokens(tokens);
475
+ }),
476
+ hasToken: (token: string) => !stopped && subscription.hasToken(token),
477
+ listTokens: () => (stopped ? [] : subscription.listTokens()),
478
+ close,
479
+ closed,
480
+ } satisfies TradeSubscription;
481
+ }
389
482
 
390
483
  export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
391
- const live = await liveConnection(options);
484
+ const live = await liveConnection(options);
392
485
  notifyReady(options, live);
393
486
  return await subscribeCoreLaunches({
394
487
  ...options,
@@ -397,44 +490,82 @@ export async function subscribeLaunches(options: SubscribeLaunchesOptions) {
397
490
  }
398
491
 
399
492
  export async function subscribeMigrations(options: SubscribeMigrationsOptions) {
400
- const live = await liveConnection(options);
493
+ const live = await liveConnection(options);
401
494
  notifyReady(options, live);
402
495
  return await subscribeCoreMigrations({
403
496
  ...options,
404
497
  connection: live.connection,
405
498
  });
406
499
  }
407
- export async function listenTrades(
500
+ export async function listenTrades(
408
501
  options: ListenTradesOptions,
409
502
  ): Promise<TradeListener> {
410
503
  const callbacks = new Set<(event: TradeEvent) => void | Promise<void>>();
411
- const pending: TradeEvent[] = [];
412
- const migrationCallbacks = new Set<(event: TradeListenerMigration) => void | Promise<void>>();
413
- const pendingMigrations: TradeListenerMigration[] = [];
414
- const watched = new Set(options.tokens);
415
- const lastPools = new Map<string, string | null>();
416
- let subscription: TradeSubscription;
417
- let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
418
- let stopped = false;
419
- let tail: Promise<unknown> = Promise.resolve();
420
- const serial = <T>(work: () => Promise<T>): Promise<T> => { const job = tail.then(work, work); tail = job.catch(() => undefined); return job; };
421
- const onMigration = async (event: MigrationEvent) => {
422
- if (stopped || !watched.has(event.mint)) return;
423
- const migration = { ...event, oldPool: lastPools.get(event.mint) ?? null, newPool: event.pool };
424
- lastPools.set(event.mint, event.pool);
425
- await serial(async () => {
426
- if (stopped || !watched.has(event.mint)) return;
427
- // Mint mentions cover the new pool; rebuilding resets cached identities,
428
- // decimals and supply without changing the caller's watched token list.
429
- await subscription.removeTokens(event.mint);
430
- await subscription.addTokens(event.mint);
431
- });
432
- if (!migrationCallbacks.size) { pendingMigrations.push(migration); if (pendingMigrations.length > 1000) pendingMigrations.shift(); }
433
- else await Promise.all([...migrationCallbacks].map(callback => callback(migration)));
434
- };
435
- const dispatch = async (event: TradeEvent) => {
436
- if (!watched.has(event.mint) || stopped) return;
437
- lastPools.set(event.mint, event.pool);
504
+ const history =
505
+ options.history === false
506
+ ? undefined
507
+ : createLiveTradeHistoryRecorder({
508
+ ...options.history,
509
+ dbPath: options.dbPath,
510
+ commitment: options.commitment,
511
+ onStatus: options.onStatus,
512
+ });
513
+ const pending: TradeEvent[] = [];
514
+ const migrationCallbacks = new Set<
515
+ (event: TradeListenerMigration) => void | Promise<void>
516
+ >();
517
+ const pendingMigrations: TradeListenerMigration[] = [];
518
+ const watched = new Set(options.tokens);
519
+ const lastPools = new Map<string, string | null>();
520
+ let subscription: TradeSubscription;
521
+ let migrations: Awaited<ReturnType<typeof subscribeMigrations>> | undefined;
522
+ let stopped = false;
523
+ let resolveClosed!: () => void;
524
+ let rejectClosed!: (error: unknown) => void;
525
+ const closed = new Promise<void>((resolve, reject) => {
526
+ resolveClosed = resolve;
527
+ rejectClosed = reject;
528
+ });
529
+ void closed.catch(() => undefined);
530
+ let tail: Promise<unknown> = Promise.resolve();
531
+ const serial = <T>(work: () => Promise<T>): Promise<T> => {
532
+ const job = tail.then(work, work);
533
+ tail = job.catch(() => undefined);
534
+ return job;
535
+ };
536
+ const onMigration = async (event: MigrationEvent) => {
537
+ if (stopped || !watched.has(event.mint)) return;
538
+ const migration = {
539
+ ...event,
540
+ oldPool: lastPools.get(event.mint) ?? null,
541
+ newPool: event.pool,
542
+ };
543
+ lastPools.set(event.mint, event.pool);
544
+ await serial(async () => {
545
+ if (stopped || !watched.has(event.mint)) return;
546
+ // Mint mentions cover the new pool; rebuilding resets cached identities,
547
+ // decimals and supply without changing the caller's watched token list.
548
+ await subscription.removeTokens(event.mint);
549
+ await subscription.addTokens(event.mint);
550
+ });
551
+ if (!migrationCallbacks.size) {
552
+ pendingMigrations.push(migration);
553
+ if (pendingMigrations.length > 1000) pendingMigrations.shift();
554
+ } else
555
+ await Promise.all(
556
+ [...migrationCallbacks].map((callback) => callback(migration)),
557
+ );
558
+ };
559
+ const dispatch = async (event: TradeEvent) => {
560
+ if (!watched.has(event.mint) || stopped) return;
561
+ lastPools.set(event.mint, event.pool);
562
+ try {
563
+ await history?.record(event);
564
+ } catch (error) {
565
+ options.onStatus?.("history-persistence-error", {
566
+ error: safeMessage(error),
567
+ });
568
+ }
438
569
  if (callbacks.size === 0) {
439
570
  pending.push(event);
440
571
  if (pending.length > 1_000) pending.shift();
@@ -446,30 +577,96 @@ export async function listenTrades(
446
577
  }),
447
578
  );
448
579
  };
449
- const onStatus = (event: string, data?: Record<string, unknown>) => {
450
- options.onStatus?.(event, data);
451
- if (event === "rpc-ready" && migrations && !stopped) void serial(async () => {
452
- if (stopped) return;
453
- const next = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus });
454
- await migrations?.close(); migrations = next;
455
- }).catch(error => options.onStatus?.("migration-resubscribe-error", { error: safeMessage(error) }));
456
- };
457
- subscription = await subscribeTrades({ ...options, signal: undefined, onStatus, onTrade: dispatch });
458
- try { migrations = await subscribeMigrations({ rpcUrls: options.rpcUrls, tokens: [...watched], commitment: options.commitment, metadata: options.metadata, onMigration, onStatus: options.onStatus }); }
459
- catch (error) { await subscription.close(); throw error; }
460
- const close = async () => {
461
- if (stopped) return;
462
- stopped = true; options.signal?.removeEventListener("abort", abort);
463
- await serial(async () => { await Promise.all([subscription.close(), migrations?.close()]); watched.clear(); });
464
- };
465
- const abort = () => { void close(); };
466
- if (options.signal?.aborted) await close();
467
- else options.signal?.addEventListener("abort", abort, { once: true });
468
- return {
469
- add: (tokens) => serial(async () => { if (stopped) throw new Error("Trade listener is closed"); const values = typeof tokens === "string" ? [tokens] : [...tokens]; await subscription.addTokens(values); await migrations!.addTokens(values); values.forEach(token => watched.add(token)); }),
470
- remove: (tokens) => serial(async () => { const values = typeof tokens === "string" ? [tokens] : [...tokens]; values.forEach(token => { watched.delete(token); lastPools.delete(token); }); await Promise.all([subscription.removeTokens(values), migrations!.removeTokens(values)]); }),
471
- has: (token) => !stopped && watched.has(token),
472
- list: () => stopped ? [] : [...watched],
580
+ const onStatus = (event: string, data?: Record<string, unknown>) => {
581
+ options.onStatus?.(event, data);
582
+ if (event === "rpc-ready" && migrations && !stopped)
583
+ void serial(async () => {
584
+ if (stopped) return;
585
+ const next = await subscribeMigrations({
586
+ rpcUrls: options.rpcUrls,
587
+ tokens: [...watched],
588
+ commitment: options.commitment,
589
+ metadata: options.metadata,
590
+ onMigration,
591
+ onStatus: options.onStatus,
592
+ });
593
+ await migrations?.close();
594
+ migrations = next;
595
+ }).catch((error) =>
596
+ options.onStatus?.("migration-resubscribe-error", {
597
+ error: safeMessage(error),
598
+ }),
599
+ );
600
+ };
601
+ try {
602
+ subscription = await subscribeTrades({
603
+ ...options,
604
+ signal: undefined,
605
+ onStatus,
606
+ onTrade: dispatch,
607
+ });
608
+ } catch (error) {
609
+ await history?.close();
610
+ throw error;
611
+ }
612
+ try {
613
+ migrations = await subscribeMigrations({
614
+ rpcUrls: options.rpcUrls,
615
+ tokens: [...watched],
616
+ commitment: options.commitment,
617
+ metadata: options.metadata,
618
+ onMigration,
619
+ onStatus: options.onStatus,
620
+ });
621
+ } catch (error) {
622
+ await subscription.close();
623
+ await history?.close();
624
+ throw error;
625
+ }
626
+ const close = async () => {
627
+ if (stopped) return await closed;
628
+ stopped = true;
629
+ options.signal?.removeEventListener("abort", abort);
630
+ try {
631
+ await serial(async () => {
632
+ await Promise.all([subscription.close(), migrations?.close()]);
633
+ watched.clear();
634
+ await history?.close();
635
+ });
636
+ resolveClosed();
637
+ } catch (error) {
638
+ rejectClosed(error);
639
+ throw error;
640
+ }
641
+ };
642
+ const abort = () => {
643
+ void close();
644
+ };
645
+ if (options.signal?.aborted) await close();
646
+ else options.signal?.addEventListener("abort", abort, { once: true });
647
+ return {
648
+ add: (tokens) =>
649
+ serial(async () => {
650
+ if (stopped) throw new Error("Trade listener is closed");
651
+ const values = typeof tokens === "string" ? [tokens] : [...tokens];
652
+ await subscription.addTokens(values);
653
+ await migrations!.addTokens(values);
654
+ values.forEach((token) => watched.add(token));
655
+ }),
656
+ remove: (tokens) =>
657
+ serial(async () => {
658
+ const values = typeof tokens === "string" ? [tokens] : [...tokens];
659
+ values.forEach((token) => {
660
+ watched.delete(token);
661
+ lastPools.delete(token);
662
+ });
663
+ await Promise.all([
664
+ subscription.removeTokens(values),
665
+ migrations!.removeTokens(values),
666
+ ]);
667
+ }),
668
+ has: (token) => !stopped && watched.has(token),
669
+ list: () => (stopped ? [] : [...watched]),
473
670
  onTrade(callback) {
474
671
  callbacks.add(callback);
475
672
  if (pending.length) {
@@ -480,14 +677,19 @@ export async function listenTrades(
480
677
  }
481
678
  return () => callbacks.delete(callback);
482
679
  },
483
- onMigration(callback) {
484
- migrationCallbacks.add(callback);
485
- if (pendingMigrations.length) { const rows = pendingMigrations.splice(0); void Promise.resolve().then(async () => { for (const event of rows) await callback(event); }); }
486
- return () => migrationCallbacks.delete(callback);
487
- },
488
- close,
680
+ onMigration(callback) {
681
+ migrationCallbacks.add(callback);
682
+ if (pendingMigrations.length) {
683
+ const rows = pendingMigrations.splice(0);
684
+ void Promise.resolve().then(async () => {
685
+ for (const event of rows) await callback(event);
686
+ });
687
+ }
688
+ return () => migrationCallbacks.delete(callback);
689
+ },
690
+ close,
489
691
  get closed() {
490
- return subscription.closed;
692
+ return closed;
491
693
  },
492
694
  };
493
695
  }