← all packages

@playlive/twitch-charity

v0.1.2

npm install @playlive/twitch-charity

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


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

Install

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.

Quick start

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 exports

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

API reference

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

Functions

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.

Errors

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

Types

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.

Constants

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

Upstream spec

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.

Twitch Extension URL disclosure

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.

Examples

Full donation crawl with retry, cancellation, and conversion

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"

Filtering donations by completion window

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

Testing without a network

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.

License

MIT © St. Jude Children's Research Hospital