UNPKG

@solana/rpc-subscriptions

Version:

A library for subscribing to Solana RPC notifications

337 lines (327 loc) • 12.5 kB
import { createSolanaRpcSubscriptionsApi } from '@solana/rpc-subscriptions-api'; export * from '@solana/rpc-subscriptions-api'; import { transformChannelInboundMessages, transformChannelOutboundMessages, createSubscriptionRpc } from '@solana/rpc-subscriptions-spec'; export * from '@solana/rpc-subscriptions-spec'; import { SolanaError, SOLANA_ERROR__RPC__INTEGER_OVERFLOW, safeCaptureStackTrace, isSolanaError, SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_CONNECTION_CLOSED } from '@solana/errors'; import { createWebSocketChannel } from '@solana/rpc-subscriptions-channel-websocket'; import { pipe } from '@solana/functional'; import { parseJsonWithBigInts, stringifyJsonWithBigints } from '@solana/rpc-spec-types'; import fastStableStringify from '@solana/fast-stable-stringify'; // src/index.ts function createSolanaJsonRpcIntegerOverflowError(methodName, keyPath, value) { let argumentLabel = ""; if (typeof keyPath[0] === "number") { const argPosition = keyPath[0] + 1; const lastDigit = argPosition % 10; const lastTwoDigits = argPosition % 100; if (lastDigit == 1 && lastTwoDigits != 11) { argumentLabel = argPosition + "st"; } else if (lastDigit == 2 && lastTwoDigits != 12) { argumentLabel = argPosition + "nd"; } else if (lastDigit == 3 && lastTwoDigits != 13) { argumentLabel = argPosition + "rd"; } else { argumentLabel = argPosition + "th"; } } else { argumentLabel = `\`${keyPath[0].toString()}\``; } const path = keyPath.length > 1 ? keyPath.slice(1).map((pathPart) => typeof pathPart === "number" ? `[${pathPart}]` : pathPart).join(".") : void 0; const error = new SolanaError(SOLANA_ERROR__RPC__INTEGER_OVERFLOW, { argumentLabel, keyPath, methodName, optionalPathLabel: path ? ` at path \`${path}\`` : "", value, ...path !== void 0 ? { path } : void 0 }); safeCaptureStackTrace(error, createSolanaJsonRpcIntegerOverflowError); return error; } // src/rpc-default-config.ts var DEFAULT_RPC_SUBSCRIPTIONS_CONFIG = { defaultCommitment: "confirmed", onIntegerOverflow(request, keyPath, value) { throw createSolanaJsonRpcIntegerOverflowError(request.methodName, keyPath, value); } }; // ../event-target-impl/dist/index.browser.mjs var o = globalThis.AbortController; // src/rpc-subscriptions-autopinger.ts var PING_PAYLOAD = { jsonrpc: "2.0", method: "ping" }; function getRpcSubscriptionsChannelWithAutoping({ abortSignal: callerAbortSignal, channel, intervalMs }) { let intervalId; function sendPing() { channel.send(PING_PAYLOAD).catch((e) => { if (isSolanaError(e, SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_CONNECTION_CLOSED)) { pingerAbortController.abort(); } }); } function restartPingTimer() { clearInterval(intervalId); intervalId = setInterval(sendPing, intervalMs); } const pingerAbortController = new o(); pingerAbortController.signal.addEventListener("abort", () => { clearInterval(intervalId); }); callerAbortSignal.addEventListener("abort", () => { pingerAbortController.abort(); }); channel.on( "error", () => { pingerAbortController.abort(); }, { signal: pingerAbortController.signal } ); channel.on("message", restartPingTimer, { signal: pingerAbortController.signal }); { restartPingTimer(); } return { ...channel, send(...args) { if (!pingerAbortController.signal.aborted) { restartPingTimer(); } return channel.send(...args); } }; } // src/rpc-subscriptions-channel-pool-internal.ts function createChannelPool() { return { entries: [], freeChannelIndex: -1 }; } // src/rpc-subscriptions-channel-pool.ts function getChannelPoolingChannelCreator(createChannel, { maxSubscriptionsPerChannel, minChannels }) { const pool = createChannelPool(); function recomputeFreeChannelIndex() { if (pool.entries.length < minChannels) { pool.freeChannelIndex = -1; return; } let mostFreeChannel; for (let ii = 0; ii < pool.entries.length; ii++) { const nextPoolIndex = (pool.freeChannelIndex + ii + 2) % pool.entries.length; const nextPoolEntry = ( // Start from the item two positions after the current item. This way, the // search will finish on the item after the current one. This ensures that, if // any channels tie for having the most capacity, the one that will be chosen is // the one immediately to the current one's right (wrapping around). pool.entries[nextPoolIndex] ); if (nextPoolEntry.subscriptionCount < maxSubscriptionsPerChannel && (!mostFreeChannel || mostFreeChannel.subscriptionCount >= nextPoolEntry.subscriptionCount)) { mostFreeChannel = { poolIndex: nextPoolIndex, subscriptionCount: nextPoolEntry.subscriptionCount }; } } pool.freeChannelIndex = mostFreeChannel?.poolIndex ?? -1; } return function getExistingChannelWithMostCapacityOrCreateChannel({ abortSignal }) { let poolEntry; function destroyPoolEntry() { const index = pool.entries.findIndex((entry) => entry === poolEntry); pool.entries.splice(index, 1); poolEntry.dispose(); recomputeFreeChannelIndex(); } if (pool.freeChannelIndex === -1) { const abortController = new o(); const newChannelPromise = createChannel({ abortSignal: abortController.signal }); newChannelPromise.then((newChannel) => { newChannel.on("error", destroyPoolEntry, { signal: abortController.signal }); }).catch(destroyPoolEntry); poolEntry = { channel: newChannelPromise, dispose() { abortController.abort(); }, subscriptionCount: 0 }; pool.entries.push(poolEntry); } else { poolEntry = pool.entries[pool.freeChannelIndex]; } poolEntry.subscriptionCount++; abortSignal.addEventListener("abort", function destroyConsumer() { poolEntry.subscriptionCount--; if (poolEntry.subscriptionCount === 0) { destroyPoolEntry(); } else if (pool.freeChannelIndex !== -1) { pool.freeChannelIndex--; recomputeFreeChannelIndex(); } }); recomputeFreeChannelIndex(); return poolEntry.channel; }; } function getRpcSubscriptionsChannelWithJSONSerialization(channel) { return pipe( channel, (c) => transformChannelInboundMessages(c, JSON.parse), (c) => transformChannelOutboundMessages(c, JSON.stringify) ); } function getRpcSubscriptionsChannelWithBigIntJSONSerialization(channel) { return pipe( channel, (c) => transformChannelInboundMessages(c, parseJsonWithBigInts), (c) => transformChannelOutboundMessages(c, stringifyJsonWithBigints) ); } // src/rpc-subscriptions-channel.ts function createDefaultSolanaRpcSubscriptionsChannelCreator(config) { return createDefaultRpcSubscriptionsChannelCreatorImpl({ ...config, jsonSerializer: getRpcSubscriptionsChannelWithBigIntJSONSerialization }); } function createDefaultRpcSubscriptionsChannelCreator(config) { return createDefaultRpcSubscriptionsChannelCreatorImpl({ ...config, jsonSerializer: getRpcSubscriptionsChannelWithJSONSerialization }); } function createDefaultRpcSubscriptionsChannelCreatorImpl(config) { if (/^wss?:/i.test(config.url) === false) { const protocolMatch = config.url.match(/^([^:]+):/); throw new DOMException( protocolMatch ? `Failed to construct 'WebSocket': The URL's scheme must be either 'ws' or 'wss'. '${protocolMatch[1]}:' is not allowed.` : `Failed to construct 'WebSocket': The URL '${config.url}' is invalid.` ); } const { intervalMs, ...rest } = config; const createDefaultRpcSubscriptionsChannel = ({ abortSignal }) => { return createWebSocketChannel({ ...rest, sendBufferHighWatermark: config.sendBufferHighWatermark ?? // Let 128KB of data into the WebSocket buffer before buffering it in the app. 131072, signal: abortSignal }).then(config.jsonSerializer).then( (channel) => getRpcSubscriptionsChannelWithAutoping({ abortSignal, channel, intervalMs: intervalMs ?? 5e3 }) ); }; return getChannelPoolingChannelCreator(createDefaultRpcSubscriptionsChannel, { maxSubscriptionsPerChannel: config.maxSubscriptionsPerChannel ?? /** * A note about this default. The idea here is that, because some RPC providers impose * an upper limit on the number of subscriptions you can make per channel, we must * choose a number low enough to avoid hitting that limit. Without knowing what provider * a given person is using, or what their limit is, we have to choose the lowest of all * known limits. As of this writing (October 2024) that is the public mainnet RPC node * (api.mainnet-beta.solana.com) at 100 subscriptions. */ 100, minChannels: config.minChannels ?? 1 }); } function getRpcSubscriptionsTransportWithSubscriptionCoalescing(transport) { const cache = /* @__PURE__ */ new Map(); return function rpcSubscriptionsTransportWithSubscriptionCoalescing(config) { const { request, signal } = config; const subscriptionConfigurationHash = fastStableStringify([request.methodName, request.params]); let cachedDataPublisherPromise = cache.get(subscriptionConfigurationHash); if (!cachedDataPublisherPromise) { const abortController = new o(); const dataPublisherPromise = transport({ ...config, signal: abortController.signal }); dataPublisherPromise.then((dataPublisher) => { dataPublisher.on( "error", () => { cache.delete(subscriptionConfigurationHash); abortController.abort(); }, { signal: abortController.signal } ); }).catch(() => { }); cache.set( subscriptionConfigurationHash, cachedDataPublisherPromise = { abortController, dataPublisherPromise, numSubscribers: 0 } ); } cachedDataPublisherPromise.numSubscribers++; signal.addEventListener( "abort", () => { cachedDataPublisherPromise.numSubscribers--; if (cachedDataPublisherPromise.numSubscribers === 0) { queueMicrotask(() => { if (cachedDataPublisherPromise.numSubscribers === 0) { cache.delete(subscriptionConfigurationHash); cachedDataPublisherPromise.abortController.abort(); } }); } }, { signal: cachedDataPublisherPromise.abortController.signal } ); return cachedDataPublisherPromise.dataPublisherPromise; }; } // src/rpc-subscriptions-transport.ts function createDefaultRpcSubscriptionsTransport({ createChannel }) { return pipe( createRpcSubscriptionsTransportFromChannelCreator( createChannel ), (transport) => getRpcSubscriptionsTransportWithSubscriptionCoalescing(transport) ); } function createRpcSubscriptionsTransportFromChannelCreator(createChannel) { return async ({ execute, signal }) => { const channel = await createChannel({ abortSignal: signal }); return await execute({ channel, signal }); }; } // src/rpc-subscriptions.ts function createSolanaRpcSubscriptionsImpl(clusterUrl, config) { const transport = createDefaultRpcSubscriptionsTransport({ createChannel: createDefaultSolanaRpcSubscriptionsChannelCreator({ ...config, url: clusterUrl }) }); return createSolanaRpcSubscriptionsFromTransport(transport); } function createSolanaRpcSubscriptions(clusterUrl, config) { return createSolanaRpcSubscriptionsImpl(clusterUrl, config); } function createSolanaRpcSubscriptions_UNSTABLE(clusterUrl, config) { return createSolanaRpcSubscriptionsImpl( clusterUrl, config ); } function createSolanaRpcSubscriptionsFromTransport(transport) { return createSubscriptionRpc({ api: createSolanaRpcSubscriptionsApi(DEFAULT_RPC_SUBSCRIPTIONS_CONFIG), transport }); } export { DEFAULT_RPC_SUBSCRIPTIONS_CONFIG, createDefaultRpcSubscriptionsChannelCreator, createDefaultRpcSubscriptionsTransport, createDefaultSolanaRpcSubscriptionsChannelCreator, createRpcSubscriptionsTransportFromChannelCreator, createSolanaRpcSubscriptions, createSolanaRpcSubscriptionsFromTransport, createSolanaRpcSubscriptions_UNSTABLE, getRpcSubscriptionsChannelWithJSONSerialization }; //# sourceMappingURL=index.native.mjs.map //# sourceMappingURL=index.native.mjs.map