← all packages

@playlive/react-pipeline

v0.4.20

npm install @playlive/react-pipeline

Requires @playlive:registry=https://packages.playlive.experience.stjude.org in your .npmrc. packument · tarball · API docs


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

Three tiers — take only what you need

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).

Install

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.

Quick start

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 exports

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.

API reference

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

Tier 1 — universal primitive

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 })),
);

Tier 2 — typed per-slice hooks

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.

Change observers

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] },
);

Tier 3 — kitchen-sink provider (/provider)

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.

Fusion — REST baseline + WS delta hooks (/fusion)

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.

Fusion — hybrid slice hooks (/fusion)

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:

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.

Legacy aliases (/legacy)

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.

Migration from playlive-overlay-data-layer

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:

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

Upstream spec

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.

Twitch Extension URL disclosure

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); // []

Examples

Live schedule-block total without polling

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>
  );
}

Raw baseline + delta with the Tier-2 primitive

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.

License

MIT © St. Jude Children's Research Hospital