UNPKG

@macalinao/grill

Version:

Modern Solana development kit for React applications with automatic account batching, caching, and transaction notifications

223 lines (198 loc) 6.29 kB
import type { Account, Address, EncodedAccount, Lamports } from "@solana/kit"; import type { QueryClient } from "@tanstack/react-query"; import type { RpcSubscriptions, SolanaRpcSubscriptionsApi } from "gill"; import { getBase64Encoder } from "@solana/kit"; import { createContext, useContext } from "react"; import { createAccountQueryKey } from "../query-keys.js"; /** * The data constraint for account types from @solana/kit */ export type AccountData = object | Uint8Array; /** * Function type for decoding account data from raw bytes to typed account. */ export type AccountDecoder<T extends AccountData> = ( encodedAccount: EncodedAccount, ) => Account<T>; /** * Internal subscription entry tracking the WebSocket subscription and reference count. */ interface SubscriptionEntry { abortController: AbortController; refCount: number; decoder: AccountDecoder<AccountData>; } /** * RPC subscriptions type from gill's SolanaClient */ type RpcSubscriptionsType = RpcSubscriptions<SolanaRpcSubscriptionsApi>; /** * Account notification value from subscriptions. * Based on the actual RPC response structure for accountNotifications. */ interface AccountNotificationValue { lamports: Lamports; data: readonly [string, string]; owner: Address; executable: boolean; space: bigint; } /** * Interface for the subscription manager that handles WebSocket subscriptions. */ export interface SubscriptionManager { /** * Subscribe to an account's changes via WebSocket. * Returns an unsubscribe function that must be called on cleanup. * * Multiple calls with the same address will share a single WebSocket subscription. * The subscription is automatically cleaned up when all subscribers unsubscribe. */ subscribe<T extends AccountData>( address: Address, decoder: AccountDecoder<T>, ): () => void; /** * Get the current reference count for an address (for debugging). */ getSubscriptionCount(address: Address): number; } /** * Parse a base64 encoded account notification into an EncodedAccount */ function parseAccountNotification( address: Address, value: AccountNotificationValue, ): EncodedAccount { // The data is [base64String, "base64"] const base64Data = value.data[0]; const data = getBase64Encoder().encode(base64Data); return { address, data, executable: value.executable, lamports: value.lamports, programAddress: value.owner, space: value.space, }; } /** * Creates a subscription manager instance. */ export function createSubscriptionManager( rpcSubscriptions: RpcSubscriptionsType, queryClient: QueryClient, ): SubscriptionManager { const subscriptions = new Map<string, SubscriptionEntry>(); const setupSubscription = async <T extends AccountData>( address: Address, decoder: AccountDecoder<T>, abortSignal: AbortSignal, ): Promise<void> => { try { const accountNotifications = await rpcSubscriptions .accountNotifications(address, { commitment: "confirmed", encoding: "base64", }) .subscribe({ abortSignal }); for await (const notification of accountNotifications) { try { const value = notification.value as unknown as AccountNotificationValue; // Handle account closure (lamports = 0) if (value.lamports === 0n) { queryClient.setQueryData(createAccountQueryKey(address), null); continue; } // Parse and decode, then update cache const encodedAccount = parseAccountNotification(address, value); const decoded = decoder(encodedAccount); queryClient.setQueryData(createAccountQueryKey(address), decoded); } catch (decodeError) { console.error( `[SubscriptionManager] Error decoding account ${address}:`, decodeError, ); } } } catch (error) { // AbortError is expected when unsubscribing if (error instanceof Error && error.name === "AbortError") { return; } console.error( `[SubscriptionManager] Subscription error for ${address}:`, error, ); } }; const subscribe = <T extends AccountData>( address: Address, decoder: AccountDecoder<T>, ): (() => void) => { const key = address; const existing = subscriptions.get(key); if (existing) { // Increment reference count for existing subscription existing.refCount++; return () => { existing.refCount--; if (existing.refCount === 0) { existing.abortController.abort(); subscriptions.delete(key); } }; } // Create new subscription const abortController = new AbortController(); const entry: SubscriptionEntry = { abortController, refCount: 1, decoder: decoder as AccountDecoder<AccountData>, }; subscriptions.set(key, entry); // Start the WebSocket subscription void setupSubscription(address, decoder, abortController.signal); // Return unsubscribe function return () => { const currentEntry = subscriptions.get(key); if (!currentEntry) { return; } currentEntry.refCount--; if (currentEntry.refCount === 0) { currentEntry.abortController.abort(); subscriptions.delete(key); } }; }; const getSubscriptionCount = (address: Address): number => { const entry = subscriptions.get(address); return entry?.refCount ?? 0; }; return { subscribe, getSubscriptionCount, }; } /** * React context for subscription manager. */ export const SubscriptionContext: React.Context<SubscriptionManager | null> = createContext<SubscriptionManager | null>(null); /** * Hook to access the subscription manager. * Must be used within a provider that sets up SubscriptionContext. * * @throws Error if used outside of a provider with SubscriptionContext */ export function useSubscriptionManager(): SubscriptionManager { const context = useContext(SubscriptionContext); if (!context) { throw new Error( "useSubscriptionManager must be used within a GrillProvider with subscriptions enabled", ); } return context; }