@solana/kit
Version:
Solana Javascript API
272 lines (256 loc) • 11.8 kB
text/typescript
import type { SolanaRpcResponse } from '@solana/rpc-types';
import type {
ReactiveActionSource,
ReactiveState,
ReactiveStreamSource,
ReactiveStreamStore,
} from '@solana/subscribable';
/**
* Configuration for {@link createReactiveStoreWithInitialValueAndSlotTracking}. Pairs a one-shot
* initial-value source with an ongoing stream source so the resulting store can hydrate from the
* initial response and keep up to date with notifications, slot-deduplicating the two sources.
*
* @typeParam TInitialValue - The value type produced by `initialValueSource` (inside the {@link SolanaRpcResponse} envelope).
* @typeParam TStreamValue - The value type emitted by `streamSource` (inside the {@link SolanaRpcResponse} envelope).
* @typeParam TItem - The unified item type the store holds, produced by the two value mappers.
*
* @see {@link createReactiveStoreWithInitialValueAndSlotTracking}
*/
export type CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<TInitialValue, TStreamValue, TItem> = Readonly<{
/**
* Maps the value from the initial-value source's response to the item type stored in the
* reactive store.
*/
initialValueMapper: (value: TInitialValue) => TItem;
/**
* A reactive action source whose dispatched value will be used to set the store's initial
* state. The value must be a {@link SolanaRpcResponse} so that its slot can be compared with
* stream notifications. Satisfied by `PendingRpcRequest`.
*/
initialValueSource: ReactiveActionSource<SolanaRpcResponse<TInitialValue>>;
/**
* A reactive stream source whose notifications will be used to keep the store up to date. Each
* notification must be a {@link SolanaRpcResponse} so that its slot can be compared with the
* initial value and with other notifications. Satisfied by `PendingRpcSubscriptionsRequest`.
*/
streamSource: ReactiveStreamSource<SolanaRpcResponse<TStreamValue>>;
/**
* Maps the value from a stream notification to the item type stored in the reactive store.
*/
streamValueMapper: (value: TStreamValue) => TItem;
}>;
const IDLE_STATE: ReactiveState<never> = Object.freeze({
data: undefined,
error: undefined,
status: 'idle',
});
/**
* Creates a {@link ReactiveStreamStore} that combines an initial one-shot fetch with an ongoing
* stream to keep its state up to date.
*
* The store uses slot-based comparison to ensure that only the most recent value is kept,
* regardless of whether it came from the initial value source or a stream notification. This
* prevents stale data from overwriting newer data when the two sources arrive out of order.
*
* The two sources are consumed via their {@link ReactiveActionSource.reactiveStore | `reactiveStore()`}
* methods rather than by calling `send()` / `subscribe()` directly, so any object satisfying the
* {@link ReactiveActionSource} / {@link ReactiveStreamSource} duck-types works — including
* `PendingRpcRequest` / `PendingRpcSubscriptionsRequest` and plugin-authored wrappers.
*
* Things to note:
*
* - The returned store starts in `status: 'idle'`. Call
* {@link ReactiveStreamStore.connect | `connect()`} to dispatch the initial-value source and open
* the stream.
* - The store transitions through `loading` until the first value or notification arrives,
* then to `loaded` with a {@link SolanaRpcResponse} containing the value and the slot context
* at which it was observed.
* - On error from either source, the store transitions to `status: 'error'` preserving the last
* known value. Only the first error per connection window is captured.
* - A subsequent `connect()` aborts the current connection, transitions back to
* `status: 'loading'` (preserving the last known `data` and `error` for stale-while-revalidate),
* and re-builds both inner stores with a fresh inner abort signal.
* - {@link ReactiveStreamStore.reset | `reset()`} aborts the current connection and returns the
* store to `idle`, clearing `data` and `error`.
* - Attach a caller-provided cancellation source via
* {@link ReactiveStreamStore.withSignal | `withSignal()`} — `store.withSignal(signal).connect()`
* composes the signal with the per-connection controller. Aborting the caller's signal
* transitions the store to `error` with that abort reason.
*
* @param config
*
* @example
* ```ts
* import {
* address,
* createReactiveStoreWithInitialValueAndSlotTracking,
* createSolanaRpc,
* createSolanaRpcSubscriptions,
* } from '@solana/kit';
*
* const rpc = createSolanaRpc('http://127.0.0.1:8899');
* const rpcSubscriptions = createSolanaRpcSubscriptions('ws://127.0.0.1:8900');
* const myAddress = address('FnHyam9w4NZoWR6mKN1CuGBritdsEWZQa4Z4oawLZGxa');
*
* const balanceStore = createReactiveStoreWithInitialValueAndSlotTracking({
* initialValueSource: rpc.getBalance(myAddress, { commitment: 'confirmed' }),
* initialValueMapper: lamports => lamports,
* streamSource: rpcSubscriptions.accountNotifications(myAddress),
* streamValueMapper: ({ lamports }) => lamports,
* });
*
* const unsubscribe = balanceStore.subscribe(() => {
* const state = balanceStore.getState();
* if (state.status === 'error') {
* console.error('Error:', state.error);
* balanceStore.connect();
* } else if (state.status === 'loaded') {
* console.log(`Balance at slot ${state.data.context.slot}:`, state.data.value);
* }
* });
* balanceStore.withSignal(AbortSignal.timeout(60_000)).connect();
* ```
*
* @see {@link ReactiveStreamStore}
*/
export function createReactiveStoreWithInitialValueAndSlotTracking<TInitialValue, TStreamValue, TItem>({
initialValueMapper,
initialValueSource,
streamSource,
streamValueMapper,
}: CreateReactiveStoreWithInitialValueAndSlotTrackingConfig<TInitialValue, TStreamValue, TItem>): ReactiveStreamStore<
SolanaRpcResponse<TItem>
> {
let currentState: ReactiveState<SolanaRpcResponse<TItem>> = IDLE_STATE;
let lastUpdateSlot = -1n;
let currentInnerController: AbortController | undefined;
const subscribers = new Set<() => void>();
function notify() {
subscribers.forEach(cb => cb());
}
function setState(next: ReactiveState<SolanaRpcResponse<TItem>>) {
if (
currentState.status === next.status &&
currentState.data === next.data &&
currentState.error === next.error
) {
return;
}
currentState = next;
notify();
}
function performConnect(callerSignal: AbortSignal | undefined) {
// Abort any currently active connection before starting a fresh one.
currentInnerController?.abort();
// If the caller's signal is already aborted, surface as error and bail.
if (callerSignal?.aborted) {
setState({ data: currentState.data, error: callerSignal.reason, status: 'error' });
return;
}
// Transition to `loading`, preserving the last known `data` and `error` for SWR. If
// already `loading` with the same data/error, `setState` no-ops — no spurious notify.
setState({ data: currentState.data, error: currentState.error, status: 'loading' });
const innerController = new AbortController();
currentInnerController = innerController;
const innerSignal = innerController.signal;
const signal = callerSignal ? AbortSignal.any([innerSignal, callerSignal]) : innerSignal;
// Caller's signal aborting (not just supersede via the inner controller) transitions the
// store to error with the caller's abort reason. Scoped to the inner signal so the
// listener is removed on reconnect / reset.
if (callerSignal) {
callerSignal.addEventListener(
'abort',
() => {
if (innerSignal.aborted) return;
setState({ data: currentState.data, error: callerSignal.reason, status: 'error' });
innerController.abort(callerSignal.reason);
},
{ signal: innerSignal },
);
}
function handleError(err: unknown) {
if (signal.aborted) return;
if (currentState.status === 'error') return;
setState({ data: currentState.data, error: err, status: 'error' });
innerController.abort(err);
}
// `lastUpdateSlot` persists across reconnects so the surfaced value never regresses.
function handleSlottedValue({ context: { slot }, value }: SolanaRpcResponse<TItem>) {
if (signal.aborted) return;
if (slot < lastUpdateSlot) {
// Stale slot: keep the newer `data` we already hold, but still set `loaded`.
if (currentState.data !== undefined) {
setState({ data: currentState.data, error: undefined, status: 'loaded' });
}
return;
}
lastUpdateSlot = slot;
setState({ data: { context: { slot }, value }, error: undefined, status: 'loaded' });
}
// Build fresh inner stores for this connection window and forward their state into the
// unified store. Drive both with the composed signal so they tear down when this connection
// window does.
const actionStore = initialValueSource.reactiveStore();
const streamStore = streamSource.reactiveStore();
const unsubscribeAction = actionStore.subscribe(() => {
const state = actionStore.getState();
if (state.status === 'success') {
const { context, value } = state.data;
handleSlottedValue({ context, value: initialValueMapper(value) });
} else if (state.status === 'error') {
handleError(state.error);
}
});
const unsubscribeStream = streamStore.subscribe(() => {
const state = streamStore.getState();
if (state.status === 'loaded') {
const { context, value } = state.data;
handleSlottedValue({ context, value: streamValueMapper(value) });
} else if (state.status === 'error') {
handleError(state.error);
}
});
// Stop observing this connection's inner stores once it is superseded or the caller
// aborts.
// Note: don't call reset here, causes a race with the abort reason
innerSignal.addEventListener(
'abort',
() => {
unsubscribeAction();
unsubscribeStream();
},
{ once: true },
);
streamStore.withSignal(signal).connect();
actionStore.withSignal(signal).dispatch();
}
function performReset() {
currentInnerController?.abort();
currentInnerController = undefined;
// `lastUpdateSlot` resets too — a fresh connect() starts a new slot window.
lastUpdateSlot = -1n;
setState(IDLE_STATE);
}
return {
connect(): void {
performConnect(undefined);
},
getState(): ReactiveState<SolanaRpcResponse<TItem>> {
return currentState;
},
reset: performReset,
subscribe(callback: () => void): () => void {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
},
withSignal(signal: AbortSignal) {
return {
connect(): void {
performConnect(signal);
},
};
},
};
}