@playlive/react-pipeline
    Preparing search index...

    @playlive/react-pipeline

    @playlive/react-pipeline

    React bindings for @playlive/realtime-pipeline. Successor to playlive-overlay-data-layer/src/websocket/* with zero dependency on react-use-websocket-lite — built directly on Zustand v5's useStore + useSyncExternalStore.

    Coverage

    Pick the surface that matches how much of the legacy UnifiedDataPipelineProvider API you actually want. Each tier is strictly larger than the last and lives at its own subpath so unused code is tree-shaken out.

    Tier Subpath Surface Use when
    1 @playlive/react-pipeline RealtimePipelineProvider + usePipelineValue (universal selector) Greenfield app; you want one selector primitive and nothing else.
    2 @playlive/react-pipeline Tier 1 plus the typed per-slice hooks (usePipelineDonations(id), …) You want stable, typed one-liners for the common reads.
    3 @playlive/react-pipeline/provider Tier 2 plus UnifiedDataPipelineProvider, useUnifiedDataPipeline, etc. Porting an existing overlay-data-layer consumer with minimal churn.
    @playlive/react-pipeline/fusion REST + WS composition hooks (needs the optional TanStack peers) You want "REST baseline + live WS delta" without hand-rolling it.
    @playlive/react-pipeline/legacy Tier 3 plus useUDPStore / useUDPStoreApi / createUDPStore aliases Soft-deprecated shim for the legacy symbol names (one-release window).
    bun add @playlive/react-pipeline
    bun add react react-dom zustand @playlive/realtime-pipeline @playlive/tiltify-core

    Peer dependencies (jose-style — the consumer brings their own):

    Peer Range Required?
    react ^19.0.0 yes
    react-dom ^19.0.0 yes
    zustand ^5.0.0 yes
    @playlive/realtime-pipeline workspace:* yes — store, connection, protocol types
    @playlive/tiltify-core workspace:* yes (type-only — stripped at compile time)
    @tanstack/react-query ^5.0.0 optional — only for /fusion
    @playlive/react-query workspace:* optional — only for /fusion
    @playlive/fundraiser-data workspace:* optional — only for /fusion

    The three optional peers are declared optional: true in peerDependenciesMeta; you only need them if you import from @playlive/react-pipeline/fusion.

    This package has no runtime dependencies — nothing but your own peers ends up in the bundle.

    import {
    RealtimePipelineProvider,
    usePipelineCampaigns,
    usePipelineConnectionState,
    usePipelineDonations,
    } from "@playlive/react-pipeline";

    const CAMPAIGN_ID = "5d4b0d3c-9f31-4a5c-8fd1-0a2b3c4d5e6f";

    function App() {
    return (
    <RealtimePipelineProvider
    url="wss://main.playlive.ws.api.experience.stjude.org"
    initialCampaignIDs={[CAMPAIGN_ID]}
    initialOverlayName="donation-bar"
    initialTeamUserSlug="@playliver"
    initialOverlayPath="/overlays/donation-bar"
    autoConnect
    >
    <DonationBar campaignID={CAMPAIGN_ID} />
    </RealtimePipelineProvider>
    );
    }

    function DonationBar({ campaignID }: { campaignID: string }) {
    const { connected, connecting, hasReceivedInitialData } = usePipelineConnectionState();
    const campaigns = usePipelineCampaigns();
    const donations = usePipelineDonations(campaignID);

    if (connecting) return <p>Connecting…</p>;
    if (!connected) return <p>Disconnected — reconnecting…</p>;
    if (!hasReceivedInitialData) return <p>Waiting for first tick…</p>;

    const campaign = campaigns.find((c) => c.id === campaignID);
    const raised = Number(campaign?.total_amount_raised?.value ?? 0);
    const goal = Number(campaign?.goal?.value ?? 0);

    return (
    <section>
    <progress value={raised} max={goal || 1} />
    <p>
    ${raised.toFixed(2)} of ${goal.toFixed(2)}
    </p>
    <ul>
    {donations.slice(0, 5).map((d) => (
    <li key={d.id}>
    {d.donor_name} — ${d.amount.value}
    </li>
    ))}
    </ul>
    </section>
    );
    }

    RealtimePipelineProvider builds one Zustand store per provider instance (from the initial* props, which are read once at first construction) and opens a single PipelineConnection. connection.destroy() runs on unmount. Children subscribe with any Tier 1 / Tier 2 hook.

    Every hook that reads the store throws when no provider is mounted above it; the four useOn* observer hooks deliberately soft-fail (they no-op) so you can drop them into components that sometimes render outside a pipeline tree.

    Subpath Description
    @playlive/react-pipeline Default barrel — Tier 1 + Tier 2 (provider, usePipelineValue, typed slice hooks, observers).
    @playlive/react-pipeline/provider Tier 3 — UnifiedDataPipelineProvider, useUnifiedDataPipeline, useAddPipeline*, useIdentify, …
    @playlive/react-pipeline/fusion REST + WS composition hooks (useCurrentBlockRaised, useLiveSchedule, the useHybrid* family, …).
    @playlive/react-pipeline/legacy Soft-deprecated alias bundle (useUDPStore, useUDPStoreApi, createUDPStore, UDPStore, …).

    Each subpath ships an ESM bundle, a bun source condition, and .d.ts declarations. Frontend-eligible bundles are not minified so stack traces stay readable in production.

    Full generated API documentation: https://packages.playlive.experience.stjude.org/p/@playlive/react-pipeline/docs/

    Export Signature
    RealtimePipelineProvider (props: RealtimePipelineProviderProps) => ReactNode
    RealtimePipelineProviderProps Type. InitialProps + children, url?, autoConnect? (default true), webSocketCtor?, log?, store?, connection?.
    usePipelineValue <T>(selector: (s: PipelineStore) => T) => T
    PACKAGE_NAME "@playlive/react-pipeline" — for runtime version-pinning.
    KNOWN_URLS readonly string[] — frozen, empty. See the disclosure section.

    RealtimePipelineProviderProps extends InitialProps, so every initial* key (initialCampaignIDs, initialTeamCampaignIDs, initialCauseIDs, initialFundraisingEventIDs, initialOverlayName, initialTeamUserSlug, initialOverlayPath, initialOverlayConfig, initialAdminApiKey, initialDonationTrainsEnabled, …) is a valid prop. There is no charityType prop — set the charity type through the store's setCurrentType action or via useCommonContextHooks (Tier 3).

    usePipelineValue is single-arg by design. Zustand v5 dropped the third equalityFn parameter on useStore to keep the package free of the use-sync-external-store shim peer. For shallow / custom equality, wrap your selector with useShallow from zustand/shallow (zero extra dep):

    import { useShallow } from "zustand/shallow";
    import { usePipelineValue } from "@playlive/react-pipeline";

    const { connected, connecting } = usePipelineValue(
    useShallow((s) => ({ connected: s.connected, connecting: s.connecting })),
    );

    Stable wrappers over usePipelineValue with a typed selector + memo-stable empty-array/object fallbacks (no infinite-render churn from fresh [] allocations — Decision §3 in CHANGELOG.md).

    Hook Returns
    usePipelineCampaigns() TiltifyCampaign[]
    usePipelineTeamCampaigns() TiltifyTeamCampaign[]
    usePipelineFundraisingEvents() TiltifyFundraisingEvent[]
    usePipelineCauses() TiltifyCause[]
    usePipelineDonations() Record<string, TiltifyDonationWithTestFlag[]>
    usePipelineDonations(id) TiltifyDonationWithTestFlag[] (memo-stable [] if unknown)
    usePipelineDonationTrains(id, withStatus?) DonationTrain[]withStatus is "ACTIVE" | "ENDED" | "ENDING" | "PENDING"
    usePipelineSubathonTimer(id, withStatus?) SubathonTimer[]withStatus is "ACTIVE" | "INACTIVE"
    usePipelineDonorSpotlight(id) ComputedSpotlight | null
    usePipelineDonorSpotlightSettings() Record<string, DonorSpotlightSettings>
    usePipelinePolls(id) TiltifyPoll[]
    usePipelinePollDeltas(pollID) PollOptionDelta[] — server-computed "+$X on option Y" deltas
    usePipelineRewards(id) TiltifyReward[]
    usePipelineTargets(id) TiltifyTarget[]
    usePipelineMilestones(id) TiltifyMilestone[]
    usePipelineLeaderboardEntries(id) TiltifyLeaderboardEntry[]
    usePipelineLeaderboardExclusions(id) LeaderboardExclusion[]
    usePipelineLeaderboardExclusionsMap() Record<string, LeaderboardExclusion[]>
    usePipelineSchedule(id) ScheduleTransitionBlock[] — full updateSchedule WS push
    usePipelineSchedules() Record<string, ScheduleTransitionBlock[]>
    usePipelineLastScheduleTransition(id) ScheduleTransitionPayload | null — latest block crossing
    usePipelineLastScheduleTransitions() Record<string, ScheduleTransitionPayload>
    usePipelineDonationSum(id, options?) PipelineDonationSumResult — live per-currency sum over [start, end)
    usePipelineAuctionHouses() AuctionHouse[]
    usePipelineTwitchChat() TwitchChatMessage[]
    usePipelineConnectionState() PipelineConnectionState (10 split selectors — no false re-renders)
    usePipelineConnectionActions() PipelineConnectionActions (stable connect/disconnect/…)
    usePipelineSubscriptions() Record<string, TiltifyWebhookSubscription>
    usePipelineTiltifySettings() TiltifyWebSocketSettings
    usePipelineLastRefreshResponse() RefreshResponseMessage | null

    Companion types exported alongside them: PipelineConnectionState (connected, connecting, charityType, connectionType, wsURL, hasReceivedInitialData, lastIdentified, lastIdentityMessage, lastFirehoseMessage, lastMessage), PipelineConnectionActions (connect, disconnect, identify, refresh, reset, setAutoConnect, setAdminApiKey), PipelineDonationSumOptions, PipelineDonationSumByCurrency, PipelineDonationSumResult.

    Push-style subscriptions that diff the store outside React's render path. Each stashes handler in a ref, subscribes exactly once, and treats whatever is already on the store at mount as the baseline (so they never fire for pre-existing data). All four soft-fail without a provider.

    Hook Handler payload
    useOnScheduleTransition(handler, options?) ScheduleTransitionPayload
    useOnScheduleUpdate(handler, options?) { campaignID, schedule: ScheduleTransitionBlock[] }
    useOnLeaderboardEntriesChange(handler, options?) { campaignID, entries: TiltifyLeaderboardEntry[] }
    useOnLeaderboardExclusionsChange(handler, options?) { campaignID, exclusions: LeaderboardExclusion[] }

    options is { campaignIDs?: readonly string[] } — omit it (or pass an empty list) to observe every campaign.

    useOnLeaderboardEntriesChange(
    ({ campaignID }) => {
    queryClient.invalidateQueries({
    queryKey: ["leaderboard-with-exclusions", "tiltify", campaignID],
    });
    },
    { campaignIDs: [campaignID] },
    );
    Export Notes
    UnifiedDataPipelineProvider Superset of RealtimePipelineProvider; adds legacy reloadOnErrorReconnect (default true) + debug (wires console.debug logging).
    UnifiedDataPipelineProviderProps Type. Omit<RealtimePipelineProviderProps, "children"> & { children, reloadOnErrorReconnect?, debug? }.
    useUnifiedDataPipeline() One-call kitchen-sink hook → UnifiedDataPipelineState. Re-renders on every store change.
    UnifiedDataPipelineState Type. PipelineStore + allRewards / allMilestones / allTargets / allPolls / allFundraisingEventSupportingCampaigns / tiltifySubscriptions legacy aliases.
    useAddPipelineCampaignIDs(params) Layout-effect hook. { mode: "ws" | "http"; initialCampaign: { id }; isTeam: boolean; causeID? } — no-op in "http" mode.
    useAddPipelineCampaigns(params) Layout-effect hook. { campaign?: TiltifyCampaign | null; teamCampaign?: TiltifyTeamCampaign | null }. Idempotent by id.
    useAddPipelineFundraisingEventIDs(params) Layout-effect hook. { mode; initialCampaign: { id; fundraising_event_id: string | null } }.
    useCommonContextHooks(params) { url: string; mode: "ws" | "http"; charityType: CharityType } — syncs type + URL and connects when mode === "ws".
    useIdentify() Returns the store-bound identify action. The 11-positional-arg legacy overload is kept as @deprecated; the args are ignored and a one-shot console.warn fires.

    RealtimePipelineProvider / RealtimePipelineProviderProps are also re-exported from /provider so a Tier-3 consumer needs exactly one import specifier.

    These fuse the WebSocket firehose with REST reads from @playlive/react-query / @playlive/fundraiser-data. Importing this subpath means you must also install the three optional peers.

    Hook Returns
    useCurrentBlockRaised(campaignID, start, end, opts?) { raised, data, isLoading, isError, refetch } — REST baseline + WS delta over [start, end). opts: { enabled?, queryOptions?, demoMode? }.
    useLiveSchedule(params, opts?) { schedule, isLoading, isError, refetch }. params: { charityType, campaignID, isTeam? }; opts: { enabled?, queryOptions?, demoMode?, demoIntervalMs? }.
    useCurrentScheduleItem(schedule, opts?) { currentItem, nextItem } — pure client-side rollover projection with a self-scheduling boundary timer.
    useLinkedTeamCampaign(params, opts?) { shouldFetchLinkedTeamCampaign, resolvedLinkedTeamCampaign, linkedTeamTotalAmount, linkedTeamGoalAmount, isLoadingLinkedTeamCampaign, debug }.
    useDonorSpotlight(params, opts?) { spotlight, isLoading, refetch }. params: { campaignID, isTeam, live, mode }; opts: { pollingIntervalMs?, enabled?, queryOptions? }.

    useLiveSchedule resolves in a strict fallback order: full updateSchedule WS push → lastScheduleTransitions synthesis (demo only) → REST useSchedule → demo fixture.

    Overlay-parity hooks — the ones every Play Live overlay used to keep a local copy of — shipped here so they can be imported instead. Each takes a mode: "ws" | "http" that picks between:

    • "ws" — read the Tier-2 store selector, latch a local hasLoadedX boolean on first arrival (or on the server's "feature disabled" signal), and call updateTiltifySettings({ <sliceKey> }) whenever the local xEnabled toggle flips so the pipeline dynamically un/subscribes.
    • "http" — delegate to the matching @playlive/react-query REST hook (useRewards, useTargets, …) and ignore the WS slice.

    All of them share useLoadingTimeout — a 15 s safety net that force-completes the WS branch when the store never delivers.

    Hook Params → Returns
    useHybridRewards({ campaignID, isTeam, mode, initialRewardsEnabled? }) { currentRewards, hasLoadedRewards, setHasLoadedRewards, isLoadingRewards, isLoading, rewardsEnabled, setRewardsEnabled }
    useHybridTargets({ campaignID, isTeam, mode }) { currentTargets, hasLoadedTargets, setHasLoadedTargets, isLoadingTargets, isLoading, targetsEnabled, setTargetsEnabled }
    useHybridMilestones({ campaignID, isTeam, mode }) { currentMilestones, isLoadingMilestones, milestonesEnabled, setMilestonesEnabled }
    useHybridPolls({ campaignID, isTeam, mode }) { currentPolls, isLoadingPolls, pollsEnabled, setPollsEnabled }
    useHybridCause({ mode, initialCampaign }) { cause, isLoading } — single-slot semantics.
    useHybridFundraisingEvent({ mode, initialCampaign }) { fundraisingEvent, isLoading } — single-slot semantics.
    useHybridFundraisingEventSupportingCampaigns({ mode, initialFundraisingEvent, initialSupportingCampaignsEnabled? }) { supportingCampaigns, isLoading, isLoadingSupportingCampaigns, hasLoadedSupportingCampaigns, supportingCampaignsEnabled, setSupportingCampaignsEnabled }
    useHybridDonationTrains({ campaignID, mode, initialDonationTrainsEnabled?, withStatus?, http?, auth? }) { currentDonationTrains, hasLoadedDonationTrains, setHasLoadedDonationTrains, isLoadingDonationTrains, isLoading, donationTrainsEnabled, setDonationTrainsEnabled }
    useHybridAuctionHouses({ mode, enabled, campaignID }) { auctionHouses, auctionHouseTotal, auctionHousesEnabled, setAuctionHousesEnabled } — WS-only (no REST fallback yet).
    useHybridLeaderboardWithExclusions({ charityType, campaignID, isTeam?, timeType?, count?, startDate?, endDate?, enabled?, continuouslyUpdate? }) { entries, isLoading } — WS-driven invalidation over the REST leaderboard, with a LEADERBOARD_SAFETY_POLL_MS (60 s) backstop.
    useLoadingTimeout({ mode, isLoading, onFinishedLoading, timeoutAmount? }) void — the shared safety-net timer primitive.

    LEADERBOARD_SAFETY_POLL_MS is exported alongside the hooks. Return shapes are preserved verbatim from the original overlay hooks, so existing call sites can collapse to a re-export.

    Soft-deprecated. Behavior is identical to the canonical exports — rename the import specifier once and the rest of your code base keeps working:

    Legacy name Canonical replacement
    useUDPStore usePipelineValue (Tier 1) / typed slice hooks (Tier 2)
    useUDPStoreApi Use a typed slice hook, or reach for the raw store API
    createUDPStore createPipelineStore from @playlive/realtime-pipeline/store
    UDPStore (type) PipelineStore from @playlive/realtime-pipeline/store
    UDPStoreApi (type) PipelineStoreApi from @playlive/realtime-pipeline/store
    CharityTypes Re-exported from @playlive/realtime-pipeline/protocol

    useUDPStore / useUDPStoreApi throw with a "must be called inside <UnifiedDataPipelineProvider>" message when no provider is mounted. The subpath also re-exports every Tier 1 + 2 + 3 symbol under its canonical name, plus the PipelineStore / PipelineStoreApi types.

    One-shot, sed-friendly rewrite of every import specifier (no body edits needed for the happy path):

    # Tier 3 — kitchen-sink (drop-in replacement)
    rg -l '@playlive/overlay-data-layer/websocket' . \
    | xargs sed -i '' \
    -e 's|@playlive/overlay-data-layer/websocket|@playlive/react-pipeline/provider|g'

    For the soft-deprecated names (useUDPStore, createUDPStore, …) substitute /legacy instead of /provider to opt into the alias bundle, then migrate at your leisure — the aliases are kept for a one-release window.

    Two call-site changes are not covered by the sed:

    • useIdentify(sendJsonMessage, campaignIDs, …) — the positional args are now ignored (the connection reads identify inputs off the store). Drop them; the deprecated overload still compiles and warns once.
    • useWebSocket from react-use-websocket-lite — replaced by createPipelineConnection in @playlive/realtime-pipeline/connection, which RealtimePipelineProvider mounts for you.

    The Legacy aliases table above is the full per-symbol mapping.

    No external API surface — this package fetches nothing itself. It binds React to @playlive/realtime-pipeline, which owns the WebSocket connection and frames every byte on the wire; the message catalogue and its types are exported from @playlive/realtime-pipeline/protocol. The /fusion hooks additionally read REST baselines through @playlive/react-query and @playlive/fundraiser-data — see those packages for the endpoints and URL knobs involved.

    KNOWN_URLS enumerates every absolute URL or host this package can reach. It is empty. This package only opens the WebSocket URL the consumer hands to <RealtimePipelineProvider url={…}> (or the store's DEFAULT_WEBSOCKET_URL, wss://main.playlive.ws.api.experience.stjude.org). Disclose that URL — plus the KNOWN_URLS of @playlive/realtime-pipeline and, if you import /fusion, @playlive/fundraiser-data — on your Extension submission.

    import { KNOWN_URLS } from "@playlive/react-pipeline";
    console.log(KNOWN_URLS); // []

    The canonical fusion use case: fetch a REST baseline once, then add the WebSocket donation delta on top so the number ticks up on every donation instead of every 30-second poll. Requires the optional peers plus a <QueryClientProvider> above the pipeline provider.

    import { configure } from "@playlive/fundraiser-data/config";
    import { getConfigForEnv } from "@playlive/fundraiser-data/environments";
    import { RealtimePipelineProvider } from "@playlive/react-pipeline";
    import {
    useCurrentBlockRaised,
    useCurrentScheduleItem,
    useLiveSchedule,
    } from "@playlive/react-pipeline/fusion";
    import { makeQueryClient } from "@playlive/react-query";
    import { QueryClientProvider } from "@tanstack/react-query";

    configure(getConfigForEnv("prod", { tiltifyProxyUrl: import.meta.env.VITE_TILTIFY_PROXY_URL }));
    const queryClient = makeQueryClient();

    const CAMPAIGN_ID = "5d4b0d3c-9f31-4a5c-8fd1-0a2b3c4d5e6f";

    export function App() {
    return (
    <QueryClientProvider client={queryClient}>
    <RealtimePipelineProvider
    url="wss://main.playlive.ws.api.experience.stjude.org"
    initialCampaignIDs={[CAMPAIGN_ID]}
    initialOverlayName="schedule-block"
    initialTeamUserSlug="@playliver"
    >
    <BlockTotal campaignID={CAMPAIGN_ID} />
    </RealtimePipelineProvider>
    </QueryClientProvider>
    );
    }

    function BlockTotal({ campaignID }: { campaignID: string }) {
    const { schedule, isLoading: scheduleLoading, isError } = useLiveSchedule({
    charityType: "tiltify",
    campaignID,
    });
    const { currentItem, nextItem } = useCurrentScheduleItem(schedule);

    const { raised, isLoading } = useCurrentBlockRaised(
    campaignID,
    currentItem?.starts_at,
    currentItem?.ends_at,
    );

    if (scheduleLoading) return <p>Loading schedule…</p>;
    if (isError) return <p>Schedule unavailable.</p>;
    if (!currentItem) return <p>Up next: {nextItem?.name ?? "nothing scheduled"}</p>;

    return (
    <section>
    <h2>{currentItem.name}</h2>
    <p>{isLoading ? "…" : `$${raised.toFixed(2)} raised this block`}</p>
    </section>
    );
    }

    If you already have the baseline (from a route loader, say), skip the fusion hook and reduce the firehose yourself:

    import { usePipelineDonationSum } from "@playlive/react-pipeline";

    function BlockDelta({
    campaignID,
    baseline,
    }: {
    campaignID: string;
    // `GET /schedules/campaigns/{id}/raised`
    baseline: { raised: number; currency: string; asOf: string };
    }) {
    const delta = usePipelineDonationSum(campaignID, {
    start: baseline.asOf,
    end: "2025-09-20T23:00:00.000Z",
    currency: baseline.currency,
    });

    const total = baseline.raised + (delta.raised ?? 0);
    return (
    <p>
    ${total.toFixed(2)} ({delta.donationCount} live donations since baseline)
    </p>
    );
    }

    usePipelineDonationSum matches start <= completed_at < end, dedupes by donation id (last write wins, so post-edit corrections propagate), and excludes test: true donations unless you pass includeTest: true — which keeps it consistent with the REST baseline.

    Both examples above are complete and runnable: point <RealtimePipelineProvider url={…}> at your pipeline WebSocket and the rest is copy-paste.

    MIT © St. Jude Children's Research Hospital