@playlive/twitch-charity
    Preparing search index...

    @playlive/twitch-charity

    @playlive/twitch-charity

    Twitch Charity REST proxy client + Twitch ↔ Tiltify converters. Zero framework dependencies. Native fetch only. Browser / Bun / Node / Lambda safe.

    This package wraps the Play Live REST proxy that fronts Twitch's Helix /helix/charity/* endpoints (/campaigns/twitch/{id}, /donations/twitch/{id}), and ships pure converters that translate Twitch's charity wire shapes into the canonical Tiltify v5 shapes the rest of the overlay UI is built around.

    Coverage

    bun add @playlive/twitch-charity
    

    No peer dependencies. Two dependencies are pulled in automatically — @playlive/twitch-shared (the RawTwitchCharity type) and @playlive/tiltify-core (the target shapes for the converters). No other external runtime deps.

    The client is stateless: every call takes the proxy baseUrl, so there is nothing to construct and nothing to configure globally.

    import {
    fetchTwitchCampaign,
    fetchTwitchCampaignDonations,
    convertTwitchToTiltifyCampaign,
    convertTwitchToTiltifyDonation,
    } from "@playlive/twitch-charity";

    const baseUrl = "https://twitch-charity-proxy.experience.stjude.org";

    const campaign = await fetchTwitchCampaign({ baseUrl, campaignId: "tw-c-1" });
    const tiltifyCampaign = convertTwitchToTiltifyCampaign(campaign, {
    causeId: "400f5687-6017-4d1a-a4d9-7c9166b984c2", // St. Jude
    });

    const { data, metadata } = await fetchTwitchCampaignDonations({
    baseUrl,
    campaignId: "tw-c-1",
    count: 50,
    pageNumber: 0,
    });
    const tiltifyDonations = data.map((d) => convertTwitchToTiltifyDonation(d));

    console.log(tiltifyCampaign?.amount_raised); // { value: "2847.5", currency: "USD" }
    console.log(metadata.nextPage); // 1 — or null when the last page was served

    Both converters are null-safe (null in → null out) and therefore return a nullable type; narrow before use.

    Subpath Description
    @playlive/twitch-charity Default barrel — re-exports everything below.
    @playlive/twitch-charity/client fetchTwitchCampaign, fetchTwitchCampaignDonations + options.
    @playlive/twitch-charity/convert Pure converters Twitch → Tiltify shapes.
    @playlive/twitch-charity/errors TwitchCharityApiError class for type-narrowing failed requests.
    @playlive/twitch-charity/types Wire shapes (camelCase proxy + RawTwitchCharity re-export).

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

    Export Kind Source Signature
    fetchTwitchCampaign function ./client (opts: FetchTwitchCampaignOptions) => Promise<TwitchCampaign>
    fetchTwitchCampaignDonations function ./client (opts: FetchTwitchCampaignDonationsOptions) => Promise<PaginatedResponse<TwitchDonation>>
    convertTwitchToTiltifyCampaign function ./convert (campaign: TwitchCampaign | null, ctx?: TwitchToTiltifyContext) => TiltifyCampaign | null
    convertTwitchToTiltifyDonation function ./convert (donation: TwitchDonation | null) => TiltifyDonation | null

    Both fetchers issue a single GET and throw TwitchCharityApiError on any non-2xx response. Both converters are pure — no fetch, no globals, no I/O.

    Export Kind Source Notes
    TwitchCharityApiError class ./errors new (status: number, statusText: string, message?: string). Carries .status, .statusText, .name === "TwitchCharityApiError".

    Omitting message yields Twitch Charity API error: <status> <statusText>.

    Export Kind Source Notes
    TwitchCharityClientOptions interface ./client Shared base: baseUrl, optional fetch override, optional signal.
    FetchTwitchCampaignOptions interface ./client Adds campaignId.
    FetchTwitchCampaignDonationsOptions interface ./client Adds campaignId, count (default 100), pageNumber (default 0), completedAfter, completedBefore.
    TwitchToTiltifyContext interface ./convert { causeId?: string } — stamped onto the generated TiltifyCampaign.cause_id.
    TwitchCampaign interface ./types camelCase proxy campaign (broadcaster, charity, currentAmount, targetAmount).
    TwitchDonation interface ./types camelCase proxy donation (amount, campaignID, donatedAt, user).
    TwitchPaginationMetadata interface ./types { nextPage: number | null }null on the last page.
    PaginatedResponse<T, M> interface ./types Generic { data: T[]; metadata: M } envelope; M defaults to TwitchPaginationMetadata.
    RawTwitchCharity interface ./types Re-exported from @playlive/twitch-shared — the snake_case Helix shape for callers hitting Twitch directly.
    Export Kind Source Notes
    PACKAGE_NAME const ./ "@playlive/twitch-charity", for version-pinning.
    KNOWN_URLS const ./ Frozen empty array — see the disclosure section.

    Amount conventions: proxy amounts are integer minor units plus a decimalPlaces divisor. convertTwitchToTiltifyCampaign divides and calls .toString() (284_750 / 10 ** 2"2847.5"); convertTwitchToTiltifyDonation formats via Intl.NumberFormat("en-US") (123_456_789"1,234,567.89").

    The wire shapes this package exposes track the Twitch Helix Charity API, whose public reference is the authority:

    Those two Helix operations are the only ones covered. The Play Live REST proxy this client calls re-serves them at /campaigns/twitch/{id} and /donations/twitch/{id} with camelCase field names and a { data, metadata } pagination envelope; RawTwitchCharity (re-exported from @playlive/twitch-shared) is the untouched snake_case Helix shape for callers going direct to Twitch.

    Twitch's charity endpoints have no versioned schema beyond Helix itself — when Twitch adds a field, it surfaces here as an additive, non-breaking change.

    The KNOWN_URLS export is empty because the base URL of the proxy is supplied by the consumer at call time (it differs per environment — see @playlive/fundraiser-data and @playlive/realtime-pipeline for the canonical disclosure lists).

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

    If you embed this package inside a Twitch Extension, add the proxy base URL you actually call to the Extension's manifest URL allowlist yourself — the proxy host is environment-specific and not hard-coded.

    The pattern below is what the overlay data layer actually does: walk every page via metadata.nextPage, retry transient 5xx failures, surface 4xx to the caller, and convert the result into the Tiltify shapes the UI renders. Note that fetch and signal are injectable on every call — that is the extension point for retries, tracing, or cancellation.

    import {
    convertTwitchToTiltifyCampaign,
    convertTwitchToTiltifyDonation,
    fetchTwitchCampaign,
    fetchTwitchCampaignDonations,
    TwitchCharityApiError,
    type TwitchDonation,
    } from "@playlive/twitch-charity";
    import type { TiltifyCampaign, TiltifyDonation } from "@playlive/tiltify-core/types";

    const BASE_URL = "https://twitch-charity-proxy.experience.stjude.org";
    const SJ_CAUSE_ID = "400f5687-6017-4d1a-a4d9-7c9166b984c2";

    async function withRetry<T>(op: () => Promise<T>, attempts = 3): Promise<T> {
    for (let i = 0; ; i++) {
    try {
    return await op();
    } catch (err) {
    // Only 5xx is worth retrying — 404 means the campaign is gone and
    // 401/403 means the proxy rejected us, neither improves on retry.
    const retryable = err instanceof TwitchCharityApiError && err.status >= 500;
    if (!retryable || i >= attempts - 1) throw err;
    await new Promise((r) => setTimeout(r, 250 * 2 ** i));
    }
    }
    }

    export async function loadTwitchCampaign(
    campaignId: string,
    signal: AbortSignal,
    ): Promise<{ campaign: TiltifyCampaign; donations: TiltifyDonation[] } | null> {
    try {
    const twitchCampaign = await withRetry(() =>
    fetchTwitchCampaign({ baseUrl: BASE_URL, campaignId, signal }),
    );

    const raw: TwitchDonation[] = [];
    let pageNumber: number | null = 0;
    while (pageNumber !== null) {
    const page = await withRetry(() =>
    fetchTwitchCampaignDonations({
    baseUrl: BASE_URL,
    campaignId,
    count: 100,
    pageNumber,
    signal,
    }),
    );
    raw.push(...page.data);
    pageNumber = page.metadata.nextPage;
    }

    const campaign = convertTwitchToTiltifyCampaign(twitchCampaign, {
    causeId: SJ_CAUSE_ID,
    });
    if (!campaign) return null;

    const donations = raw
    .map((d) => convertTwitchToTiltifyDonation(d))
    .filter((d): d is TiltifyDonation => d !== null);

    return { campaign, donations };
    } catch (err) {
    if (err instanceof TwitchCharityApiError) {
    // `.status` / `.statusText` are the branch points; `.message`
    // defaults to `Twitch Charity API error: <status> <statusText>`.
    if (err.status === 404) return null;
    console.error(`proxy rejected the request: ${err.status} ${err.statusText}`);
    throw err;
    }
    // AbortError, DNS failure, malformed JSON — not a proxy-level error.
    throw err;
    }
    }

    const controller = new AbortController();
    setTimeout(() => controller.abort(), 10_000);
    const result = await loadTwitchCampaign("tw-c-1", controller.signal);
    console.log(result?.campaign.name); // "Playliver's Campaign for St. Jude"

    completedAfter / completedBefore are ISO-8601 strings forwarded as the completed_after / completed_before query params:

    const today = await fetchTwitchCampaignDonations({
    baseUrl: BASE_URL,
    campaignId: "tw-c-1",
    completedAfter: "2025-01-01T00:00:00Z",
    completedBefore: "2025-01-02T00:00:00Z",
    count: 25,
    });

    Every fetcher accepts a fetch override, so tests need no interceptor:

    import { fetchTwitchCampaign } from "@playlive/twitch-charity/client";

    const stub = async () =>
    new Response(
    JSON.stringify({
    id: "tw-c-1",
    broadcaster: { id: "b-1", login: "playliver", name: "Playliver" },
    charity: { logo: "", name: "St. Jude", website: "https://stjude.org" },
    currentAmount: { value: 284_750, currency: "USD", decimalPlaces: 2 },
    targetAmount: { value: 500_000, currency: "USD", decimalPlaces: 2 },
    }),
    { status: 200, headers: { "content-type": "application/json" } },
    );

    const campaign = await fetchTwitchCampaign({
    baseUrl: "https://proxy.test",
    campaignId: "tw-c-1",
    fetch: stub as typeof fetch,
    });

    The same override is the seam for mounting the two proxy routes on any local stub server: pass its fetch and no traffic leaves the process.

    MIT © St. Jude Children's Research Hospital