{"name":"@playlive/twitch-charity","dist-tags":{"latest":"0.1.2"},"versions":{"0.1.0":{"name":"@playlive/twitch-charity","version":"0.1.0","description":"Twitch Charity REST proxy client + Twitch ↔ Tiltify converters (native fetch, zero framework deps).","type":"module","sideEffects":false,"main":"./index.js","types":"./index.d.ts","exports":{".":{"import":"./index.js","types":"./index.d.ts"},"./client":{"import":"./client.js","types":"./client.d.ts"},"./convert":{"import":"./convert.js","types":"./convert.d.ts"},"./errors":{"import":"./errors.js","types":"./errors.d.ts"},"./types":{"import":"./types/index.js","types":"./types/index.d.ts"}},"dependencies":{"@playlive/twitch-shared":"^0.1.0","@playlive/tiltify-core":"^0.1.0"},"peerDependencies":{},"playlive":{"target":"browser","frontendEligible":true,"coverageFloor":85},"publishConfig":{"access":"restricted","registry":"https://playlive-767397689694.d.codeartifact.us-east-1.amazonaws.com/npm/playlive/"},"integrity":"sha512-qwcXyldRjfvgyDPOZt01fjaIkB8zoFQr0Sxm636DqIl0FQC31kmFBpdrHLNVmt/wRjTee2ckDkZTnGDW1uGaTg==","shasum":"be8d585ef692c7c19dd57769bbaa7717bc58fefa","readme":"# @playlive/twitch-charity\n\nTwitch Charity REST proxy client + Twitch ↔ Tiltify converters. Zero framework\ndependencies. Native `fetch` only. Browser / Bun / Node / Lambda safe.\n\nThis package wraps the Play Live REST proxy that fronts Twitch's Helix\n`/helix/charity/*` endpoints (`/campaigns/twitch/{id}`, `/donations/twitch/{id}`),\nand ships pure converters that translate Twitch's charity wire shapes into the\ncanonical Tiltify v5 shapes the rest of the overlay UI is built around.\n\n![Coverage](./coverage-badge.svg)\n\n## Install\n\n```bash\nbun add @playlive/twitch-charity\n```\n\nWorkspace dependencies — `@playlive/twitch-shared` (types), `@playlive/tiltify-core`\n(for the target shape on the converters). No external runtime deps.\n\n## Quick start\n\n```ts\nimport {\n  fetchTwitchCampaign,\n  fetchTwitchCampaignDonations,\n  convertTwitchToTiltifyCampaign,\n  convertTwitchToTiltifyDonation,\n} from \"@playlive/twitch-charity\";\n\nconst baseUrl = \"https://twitch-charity-proxy.experience.stjude.org\";\n\nconst campaign = await fetchTwitchCampaign({ baseUrl, campaignId: \"abc-123\" });\nconst tiltifyCampaign = convertTwitchToTiltifyCampaign(campaign, {\n  causeId: \"29eef83c-ba1b-4ea6-86fa-31e26a107c0e\", // St. Jude\n});\n\nconst { data, metadata } = await fetchTwitchCampaignDonations({\n  baseUrl,\n  campaignId: \"abc-123\",\n  count: 50,\n});\nconst tiltifyDonations = data.map(convertTwitchToTiltifyDonation);\nconsole.log({ tiltifyCampaign, tiltifyDonations, nextPage: metadata.nextPage });\n```\n\n## Subpath exports\n\n| Subpath                            | Description                                                       |\n| ---------------------------------- | ----------------------------------------------------------------- |\n| `@playlive/twitch-charity`         | Default barrel — re-exports everything below.                     |\n| `@playlive/twitch-charity/client`  | `fetchTwitchCampaign`, `fetchTwitchCampaignDonations`.            |\n| `@playlive/twitch-charity/convert` | Pure converters Twitch → Tiltify shapes.                          |\n| `@playlive/twitch-charity/errors`  | `TwitchCharityApiError` class for type-narrowing failed requests. |\n| `@playlive/twitch-charity/types`   | Wire shapes (camelCase proxy + `RawTwitchCharity` re-export).     |\n\n## API reference\n\n| Export                            | Source       | Notes                                              |\n| --------------------------------- | ------------ | -------------------------------------------------- |\n| `fetchTwitchCampaign`             | `./client`   | Single GET. Throws `TwitchCharityApiError` on >=400. |\n| `fetchTwitchCampaignDonations`    | `./client`   | Paginated GET. Returns `PaginatedResponse<...>`.    |\n| `convertTwitchToTiltifyCampaign`  | `./convert`  | Pure. Null-safe. Accepts a `causeId` context.       |\n| `convertTwitchToTiltifyDonation`  | `./convert`  | Pure. Null-safe. Uses `Intl.NumberFormat` for value. |\n| `TwitchCharityApiError`           | `./errors`   | Carries `.status` + `.statusText`.                  |\n| `TwitchCampaign` / `TwitchDonation` | `./types`  | camelCase proxy wire shapes.                        |\n| `RawTwitchCharity`                | `./types`    | Re-exported from `@playlive/twitch-shared`.         |\n| `PaginatedResponse<T>`            | `./types`    | Generic `{ data, metadata }` envelope.              |\n| `PACKAGE_NAME` / `KNOWN_URLS`     | `./`         | Audit + runtime version-pin helpers.                |\n\nRun `bun run docs:build` to emit the TypeDoc site at `dist/docs/`.\n\n## Upstream spec\n\nWire shapes track:\n\n- Twitch Helix Charity reference — <https://dev.twitch.tv/docs/api/reference/#get-charity-campaign>\n- Play Live REST proxy — see `playlive-unified-data-pipeline/packages/lambdas/rest-lambda/src/routes/twitch/`\n\nLocal snapshots live at [`specs/twitch/`](../../../specs/twitch/). Re-sync via\nthe `/charity:sync` skill or `bun run scripts/sync-twitch-reference.ts`. See\n[`docs/charity-spec-sync.md`](../../../docs/charity-spec-sync.md). To add a new\nendpoint, use the `/twitch:add-charity-endpoint` agent skill.\n\n## Twitch Extension URL disclosure\n\nThe `KNOWN_URLS` export is empty because the base URL of the proxy is supplied\nby the consumer at call time (it differs per environment — see\n`@playlive/fundraiser-data` and `@playlive/realtime-pipeline` for the canonical\ndisclosure lists). See\n[`docs/twitch-extension-checklist.md`](../../../docs/twitch-extension-checklist.md).\n\n```ts\nimport { KNOWN_URLS } from \"@playlive/twitch-charity\";\nconsole.log(KNOWN_URLS); // []\n```\n\nIf you embed this package inside a Twitch Extension, add the proxy base URL\nyou actually call to the Extension's manifest URL allowlist yourself — the\nproxy host is environment-specific and not hard-coded.\n\n## Examples\n\nRealistic end-to-end scenarios (campaign fetch + donations pagination +\nTiltify conversion) land in `dev/greenroom` (phase 12) once the simulator\nharness is wired up.\n\n## Contributing\n\nSee [CONTRIBUTING.md](../../../CONTRIBUTING.md). For adding new endpoints, use\nthe `/twitch:add-charity-endpoint` skill in [`.agents/skills/`](../../../.agents/skills/).\nEvery new fetcher MUST accept an injected `fetch` override and an\n`AbortSignal` so consumers can plug in retries, tracing, or cancellation.\n\n## License\n\nMIT — see [LICENSE](../../../LICENSE). Distributed via Play Live CodeArtifact\n(PRD §6).\n","readmeFilename":"README.md","dist":{"tarball":"https://packages.playlive.experience.stjude.org/@playlive/twitch-charity/-/twitch-charity-0.1.0.tgz","shasum":"be8d585ef692c7c19dd57769bbaa7717bc58fefa","integrity":"sha512-qwcXyldRjfvgyDPOZt01fjaIkB8zoFQr0Sxm636DqIl0FQC31kmFBpdrHLNVmt/wRjTee2ckDkZTnGDW1uGaTg=="}},"0.1.1":{"name":"@playlive/twitch-charity","version":"0.1.1","description":"Twitch Charity REST proxy client + Twitch ↔ Tiltify converters (native fetch, zero framework deps).","type":"module","sideEffects":false,"main":"./index.js","types":"./index.d.ts","exports":{".":{"import":"./index.js","types":"./index.d.ts"},"./client":{"import":"./client.js","types":"./client.d.ts"},"./convert":{"import":"./convert.js","types":"./convert.d.ts"},"./errors":{"import":"./errors.js","types":"./errors.d.ts"},"./types":{"import":"./types/index.js","types":"./types/index.d.ts"}},"dependencies":{"@playlive/twitch-shared":"^0.1.1","@playlive/tiltify-core":"^0.4.18"},"peerDependencies":{},"playlive":{"target":"browser","frontendEligible":true,"coverageFloor":85},"publishConfig":{"access":"restricted","registry":"https://playlive-767397689694.d.codeartifact.us-east-1.amazonaws.com/npm/playlive/"},"integrity":"sha512-2Bpf7Rfa63zQRWYTEzHXBp5jquwitQCOKogsreedIpg1KcSp6DMwhWmSGW6sntaR6EpJBqiTE8+p+wcve8O3lA==","shasum":"f6183ad69fe558b67c2ac8ebfdcb8b3ea9e6140c","readme":"# @playlive/twitch-charity\n\nTwitch Charity REST proxy client + Twitch ↔ Tiltify converters. Zero framework\ndependencies. Native `fetch` only. Browser / Bun / Node / Lambda safe.\n\nThis package wraps the Play Live REST proxy that fronts Twitch's Helix\n`/helix/charity/*` endpoints (`/campaigns/twitch/{id}`, `/donations/twitch/{id}`),\nand ships pure converters that translate Twitch's charity wire shapes into the\ncanonical Tiltify v5 shapes the rest of the overlay UI is built around.\n\n![Coverage](./coverage-badge.svg)\n\n## Install\n\n```bash\nbun add @playlive/twitch-charity\n```\n\nNo peer dependencies. Two workspace dependencies are pulled in automatically —\n[`@playlive/twitch-shared`](../shared/) (the `RawTwitchCharity` type) and\n[`@playlive/tiltify-core`](../../tiltify/core/) (the target shapes for the\nconverters). No external runtime deps.\n\nThe client is stateless: every call takes the proxy `baseUrl`, so there is\nnothing to construct and nothing to configure globally.\n\n## Quick start\n\n```ts\nimport {\n  fetchTwitchCampaign,\n  fetchTwitchCampaignDonations,\n  convertTwitchToTiltifyCampaign,\n  convertTwitchToTiltifyDonation,\n} from \"@playlive/twitch-charity\";\n\nconst baseUrl = \"https://twitch-charity-proxy.experience.stjude.org\";\n\nconst campaign = await fetchTwitchCampaign({ baseUrl, campaignId: \"tw-c-1\" });\nconst tiltifyCampaign = convertTwitchToTiltifyCampaign(campaign, {\n  causeId: \"400f5687-6017-4d1a-a4d9-7c9166b984c2\", // St. Jude\n});\n\nconst { data, metadata } = await fetchTwitchCampaignDonations({\n  baseUrl,\n  campaignId: \"tw-c-1\",\n  count: 50,\n  pageNumber: 0,\n});\nconst tiltifyDonations = data.map((d) => convertTwitchToTiltifyDonation(d));\n\nconsole.log(tiltifyCampaign?.amount_raised); // { value: \"2847.5\", currency: \"USD\" }\nconsole.log(metadata.nextPage); // 1 — or null when the last page was served\n```\n\nBoth converters are null-safe (`null` in → `null` out) and therefore return a\nnullable type; narrow before use.\n\n## Subpath exports\n\n| Subpath                            | Description                                                       |\n| ---------------------------------- | ----------------------------------------------------------------- |\n| `@playlive/twitch-charity`         | Default barrel — re-exports everything below.                     |\n| `@playlive/twitch-charity/client`  | `fetchTwitchCampaign`, `fetchTwitchCampaignDonations` + options.  |\n| `@playlive/twitch-charity/convert` | Pure converters Twitch → Tiltify shapes.                          |\n| `@playlive/twitch-charity/errors`  | `TwitchCharityApiError` class for type-narrowing failed requests. |\n| `@playlive/twitch-charity/types`   | Wire shapes (camelCase proxy + `RawTwitchCharity` re-export).     |\n\n## API reference\n\n### Functions\n\n| Export                           | Kind     | Source      | Signature                                                                        |\n| -------------------------------- | -------- | ----------- | -------------------------------------------------------------------------------- |\n| `fetchTwitchCampaign`            | function | `./client`  | `(opts: FetchTwitchCampaignOptions) => Promise<TwitchCampaign>`                   |\n| `fetchTwitchCampaignDonations`   | function | `./client`  | `(opts: FetchTwitchCampaignDonationsOptions) => Promise<PaginatedResponse<TwitchDonation>>` |\n| `convertTwitchToTiltifyCampaign` | function | `./convert` | `(campaign: TwitchCampaign \\| null, ctx?: TwitchToTiltifyContext) => TiltifyCampaign \\| null` |\n| `convertTwitchToTiltifyDonation` | function | `./convert` | `(donation: TwitchDonation \\| null) => TiltifyDonation \\| null`                   |\n\nBoth fetchers issue a single `GET` and throw `TwitchCharityApiError` on any\nnon-2xx response. Both converters are pure — no `fetch`, no globals, no I/O.\n\n### Errors\n\n| Export                  | Kind  | Source     | Notes                                                                                 |\n| ----------------------- | ----- | ---------- | ------------------------------------------------------------------------------------- |\n| `TwitchCharityApiError` | class | `./errors` | `new (status: number, statusText: string, message?: string)`. Carries `.status`, `.statusText`, `.name === \"TwitchCharityApiError\"`. |\n\nOmitting `message` yields `Twitch Charity API error: <status> <statusText>`.\n\n### Types\n\n| Export                              | Kind      | Source     | Notes                                                                             |\n| ----------------------------------- | --------- | ---------- | --------------------------------------------------------------------------------- |\n| `TwitchCharityClientOptions`        | interface | `./client` | Shared base: `baseUrl`, optional `fetch` override, optional `signal`.             |\n| `FetchTwitchCampaignOptions`        | interface | `./client` | Adds `campaignId`.                                                                |\n| `FetchTwitchCampaignDonationsOptions` | interface | `./client` | Adds `campaignId`, `count` (default `100`), `pageNumber` (default `0`), `completedAfter`, `completedBefore`. |\n| `TwitchToTiltifyContext`            | interface | `./convert`| `{ causeId?: string }` — stamped onto the generated `TiltifyCampaign.cause_id`.   |\n| `TwitchCampaign`                    | interface | `./types`  | camelCase proxy campaign (`broadcaster`, `charity`, `currentAmount`, `targetAmount`). |\n| `TwitchDonation`                    | interface | `./types`  | camelCase proxy donation (`amount`, `campaignID`, `donatedAt`, `user`).           |\n| `TwitchPaginationMetadata`          | interface | `./types`  | `{ nextPage: number \\| null }` — `null` on the last page.                         |\n| `PaginatedResponse<T, M>`           | interface | `./types`  | Generic `{ data: T[]; metadata: M }` envelope; `M` defaults to `TwitchPaginationMetadata`. |\n| `RawTwitchCharity`                  | interface | `./types`  | Re-exported from [`@playlive/twitch-shared`](../shared/) — the snake_case Helix shape for callers hitting Twitch directly. |\n\n### Constants\n\n| Export         | Kind  | Source | Notes                                              |\n| -------------- | ----- | ------ | -------------------------------------------------- |\n| `PACKAGE_NAME` | const | `./`   | `\"@playlive/twitch-charity\"`, for version-pinning. |\n| `KNOWN_URLS`   | const | `./`   | Frozen empty array — see the disclosure section.   |\n\nAmount conventions: proxy amounts are integer minor units plus a\n`decimalPlaces` divisor. `convertTwitchToTiltifyCampaign` divides and calls\n`.toString()` (`284_750 / 10 ** 2` → `\"2847.5\"`);\n`convertTwitchToTiltifyDonation` formats via `Intl.NumberFormat(\"en-US\")`\n(`123_456_789` → `\"1,234,567.89\"`).\n\nRun `bun run docs:build` to emit the TypeDoc site at `dist/docs/`.\n\n## Upstream spec\n\nWire shapes track:\n\n- Twitch Helix Charity reference — <https://dev.twitch.tv/docs/api/reference/#get-charity-campaign>\n- Play Live REST proxy — see `playlive-unified-data-pipeline/packages/lambdas/rest-lambda/src/routes/twitch/`\n\nLocal snapshots live at [`specs/twitch/`](../../../specs/twitch/). Re-sync via\nthe `/charity:sync` skill or `bun run scripts/sync-twitch-reference.ts`. See\n[`docs/charity-spec-sync.md`](../../../docs/charity-spec-sync.md). To add a new\nendpoint, use the `/twitch:add-charity-endpoint` agent skill.\n\n## Twitch Extension URL disclosure\n\nThe `KNOWN_URLS` export is empty because the base URL of the proxy is supplied\nby the consumer at call time (it differs per environment — see\n[`@playlive/fundraiser-data`](../../fundraiser-data/) and\n[`@playlive/realtime-pipeline`](../../realtime-pipeline/) for the canonical\ndisclosure lists). See\n[`docs/twitch-extension-checklist.md`](../../../docs/twitch-extension-checklist.md).\n\n```ts\nimport { KNOWN_URLS } from \"@playlive/twitch-charity\";\nconsole.log(KNOWN_URLS); // []\n```\n\nIf you embed this package inside a Twitch Extension, add the proxy base URL\nyou actually call to the Extension's manifest URL allowlist yourself — the\nproxy host is environment-specific and not hard-coded.\n\n## Examples\n\n### Full donation crawl with retry, cancellation, and conversion\n\nThe pattern below is what the overlay data layer actually does: walk every page\nvia `metadata.nextPage`, retry transient 5xx failures, surface 4xx to the\ncaller, and convert the result into the Tiltify shapes the UI renders. Note\nthat `fetch` and `signal` are injectable on every call — that is the extension\npoint for retries, tracing, or cancellation.\n\n```ts\nimport {\n  convertTwitchToTiltifyCampaign,\n  convertTwitchToTiltifyDonation,\n  fetchTwitchCampaign,\n  fetchTwitchCampaignDonations,\n  TwitchCharityApiError,\n  type TwitchDonation,\n} from \"@playlive/twitch-charity\";\nimport type { TiltifyCampaign, TiltifyDonation } from \"@playlive/tiltify-core/types\";\n\nconst BASE_URL = \"https://twitch-charity-proxy.experience.stjude.org\";\nconst SJ_CAUSE_ID = \"400f5687-6017-4d1a-a4d9-7c9166b984c2\";\n\nasync function withRetry<T>(op: () => Promise<T>, attempts = 3): Promise<T> {\n  for (let i = 0; ; i++) {\n    try {\n      return await op();\n    } catch (err) {\n      // Only 5xx is worth retrying — 404 means the campaign is gone and\n      // 401/403 means the proxy rejected us, neither improves on retry.\n      const retryable = err instanceof TwitchCharityApiError && err.status >= 500;\n      if (!retryable || i >= attempts - 1) throw err;\n      await new Promise((r) => setTimeout(r, 250 * 2 ** i));\n    }\n  }\n}\n\nexport async function loadTwitchCampaign(\n  campaignId: string,\n  signal: AbortSignal,\n): Promise<{ campaign: TiltifyCampaign; donations: TiltifyDonation[] } | null> {\n  try {\n    const twitchCampaign = await withRetry(() =>\n      fetchTwitchCampaign({ baseUrl: BASE_URL, campaignId, signal }),\n    );\n\n    const raw: TwitchDonation[] = [];\n    let pageNumber: number | null = 0;\n    while (pageNumber !== null) {\n      const page = await withRetry(() =>\n        fetchTwitchCampaignDonations({\n          baseUrl: BASE_URL,\n          campaignId,\n          count: 100,\n          pageNumber,\n          signal,\n        }),\n      );\n      raw.push(...page.data);\n      pageNumber = page.metadata.nextPage;\n    }\n\n    const campaign = convertTwitchToTiltifyCampaign(twitchCampaign, {\n      causeId: SJ_CAUSE_ID,\n    });\n    if (!campaign) return null;\n\n    const donations = raw\n      .map((d) => convertTwitchToTiltifyDonation(d))\n      .filter((d): d is TiltifyDonation => d !== null);\n\n    return { campaign, donations };\n  } catch (err) {\n    if (err instanceof TwitchCharityApiError) {\n      // `.status` / `.statusText` are the branch points; `.message`\n      // defaults to `Twitch Charity API error: <status> <statusText>`.\n      if (err.status === 404) return null;\n      console.error(`proxy rejected the request: ${err.status} ${err.statusText}`);\n      throw err;\n    }\n    // AbortError, DNS failure, malformed JSON — not a proxy-level error.\n    throw err;\n  }\n}\n\nconst controller = new AbortController();\nsetTimeout(() => controller.abort(), 10_000);\nconst result = await loadTwitchCampaign(\"tw-c-1\", controller.signal);\nconsole.log(result?.campaign.name); // \"Playliver's Campaign for St. Jude\"\n```\n\n### Filtering donations by completion window\n\n`completedAfter` / `completedBefore` are ISO-8601 strings forwarded as the\n`completed_after` / `completed_before` query params:\n\n```ts\nconst today = await fetchTwitchCampaignDonations({\n  baseUrl: BASE_URL,\n  campaignId: \"tw-c-1\",\n  completedAfter: \"2025-01-01T00:00:00Z\",\n  completedBefore: \"2025-01-02T00:00:00Z\",\n  count: 25,\n});\n```\n\n### Testing without a network\n\nEvery fetcher accepts a `fetch` override, so tests need no interceptor:\n\n```ts\nimport { fetchTwitchCampaign } from \"@playlive/twitch-charity/client\";\n\nconst stub = async () =>\n  new Response(\n    JSON.stringify({\n      id: \"tw-c-1\",\n      broadcaster: { id: \"b-1\", login: \"playliver\", name: \"Playliver\" },\n      charity: { logo: \"\", name: \"St. Jude\", website: \"https://stjude.org\" },\n      currentAmount: { value: 284_750, currency: \"USD\", decimalPlaces: 2 },\n      targetAmount: { value: 500_000, currency: \"USD\", decimalPlaces: 2 },\n    }),\n    { status: 200, headers: { \"content-type\": \"application/json\" } },\n  );\n\nconst campaign = await fetchTwitchCampaign({\n  baseUrl: \"https://proxy.test\",\n  campaignId: \"tw-c-1\",\n  fetch: stub as typeof fetch,\n});\n```\n\nThe package's own integration suite goes one step further and mounts the two\nproxy routes on an in-process `dev/greenroom` instance, passing\n`harness.fetch` as the override — see `tests/integration/index.test.ts` and\n[`docs/greenroom-cookbook.md`](../../../docs/greenroom-cookbook.md).\n\n## Contributing\n\nSee [CONTRIBUTING.md](../../../CONTRIBUTING.md). For adding new endpoints, use\nthe `/twitch:add-charity-endpoint` skill in [`.agents/skills/`](../../../.agents/skills/).\nEvery new fetcher MUST accept an injected `fetch` override and an\n`AbortSignal` so consumers can plug in retries, tracing, or cancellation.\n\n## License\n\nMIT — see [LICENSE](../../../LICENSE). Distributed via Play Live CodeArtifact\n(PRD §6).\n","readmeFilename":"README.md","dist":{"tarball":"https://packages.playlive.experience.stjude.org/@playlive/twitch-charity/-/twitch-charity-0.1.1.tgz","shasum":"f6183ad69fe558b67c2ac8ebfdcb8b3ea9e6140c","integrity":"sha512-2Bpf7Rfa63zQRWYTEzHXBp5jquwitQCOKogsreedIpg1KcSp6DMwhWmSGW6sntaR6EpJBqiTE8+p+wcve8O3lA=="}},"0.1.2":{"name":"@playlive/twitch-charity","version":"0.1.2","description":"Twitch Charity REST proxy client + Twitch ↔ Tiltify converters (native fetch, zero framework deps).","type":"module","sideEffects":false,"main":"./index.js","types":"./index.d.ts","exports":{".":{"import":"./index.js","types":"./index.d.ts"},"./client":{"import":"./client.js","types":"./client.d.ts"},"./convert":{"import":"./convert.js","types":"./convert.d.ts"},"./errors":{"import":"./errors.js","types":"./errors.d.ts"},"./types":{"import":"./types/index.js","types":"./types/index.d.ts"}},"dependencies":{"@playlive/twitch-shared":"^0.1.2","@playlive/tiltify-core":"^0.4.19"},"peerDependencies":{},"playlive":{"target":"browser","frontendEligible":true,"coverageFloor":85},"publishConfig":{"access":"restricted","registry":"https://playlive-767397689694.d.codeartifact.us-east-1.amazonaws.com/npm/playlive/"},"integrity":"sha512-6zwiAsP9+sNi/AdFFPpOYQaUv5nudEgkDDczvx3F0mKO+iLOXDFhd32Zxi/v0rPmA22Be87gWVBRyqlT+elTbQ==","shasum":"ce77e58d792163a38f7d99a748df5d8557abffd8","readme":"# @playlive/twitch-charity\n\nTwitch Charity REST proxy client + Twitch ↔ Tiltify converters. Zero framework\ndependencies. Native `fetch` only. Browser / Bun / Node / Lambda safe.\n\nThis package wraps the Play Live REST proxy that fronts Twitch's Helix\n`/helix/charity/*` endpoints (`/campaigns/twitch/{id}`, `/donations/twitch/{id}`),\nand ships pure converters that translate Twitch's charity wire shapes into the\ncanonical Tiltify v5 shapes the rest of the overlay UI is built around.\n\n![Coverage](./coverage-badge.svg)\n\n## Install\n\n```bash\nbun add @playlive/twitch-charity\n```\n\nNo peer dependencies. Two dependencies are pulled in automatically —\n[`@playlive/twitch-shared`](../shared/) (the `RawTwitchCharity` type) and\n[`@playlive/tiltify-core`](../../tiltify/core/) (the target shapes for the\nconverters). No other external runtime deps.\n\nThe client is stateless: every call takes the proxy `baseUrl`, so there is\nnothing to construct and nothing to configure globally.\n\n## Quick start\n\n```ts\nimport {\n  fetchTwitchCampaign,\n  fetchTwitchCampaignDonations,\n  convertTwitchToTiltifyCampaign,\n  convertTwitchToTiltifyDonation,\n} from \"@playlive/twitch-charity\";\n\nconst baseUrl = \"https://twitch-charity-proxy.experience.stjude.org\";\n\nconst campaign = await fetchTwitchCampaign({ baseUrl, campaignId: \"tw-c-1\" });\nconst tiltifyCampaign = convertTwitchToTiltifyCampaign(campaign, {\n  causeId: \"400f5687-6017-4d1a-a4d9-7c9166b984c2\", // St. Jude\n});\n\nconst { data, metadata } = await fetchTwitchCampaignDonations({\n  baseUrl,\n  campaignId: \"tw-c-1\",\n  count: 50,\n  pageNumber: 0,\n});\nconst tiltifyDonations = data.map((d) => convertTwitchToTiltifyDonation(d));\n\nconsole.log(tiltifyCampaign?.amount_raised); // { value: \"2847.5\", currency: \"USD\" }\nconsole.log(metadata.nextPage); // 1 — or null when the last page was served\n```\n\nBoth converters are null-safe (`null` in → `null` out) and therefore return a\nnullable type; narrow before use.\n\n## Subpath exports\n\n| Subpath                            | Description                                                       |\n| ---------------------------------- | ----------------------------------------------------------------- |\n| `@playlive/twitch-charity`         | Default barrel — re-exports everything below.                     |\n| `@playlive/twitch-charity/client`  | `fetchTwitchCampaign`, `fetchTwitchCampaignDonations` + options.  |\n| `@playlive/twitch-charity/convert` | Pure converters Twitch → Tiltify shapes.                          |\n| `@playlive/twitch-charity/errors`  | `TwitchCharityApiError` class for type-narrowing failed requests. |\n| `@playlive/twitch-charity/types`   | Wire shapes (camelCase proxy + `RawTwitchCharity` re-export).     |\n\n## API reference\n\nFull generated API documentation:\n<https://packages.playlive.experience.stjude.org/p/@playlive/twitch-charity/docs/>\n\n### Functions\n\n| Export                           | Kind     | Source      | Signature                                                                        |\n| -------------------------------- | -------- | ----------- | -------------------------------------------------------------------------------- |\n| `fetchTwitchCampaign`            | function | `./client`  | `(opts: FetchTwitchCampaignOptions) => Promise<TwitchCampaign>`                   |\n| `fetchTwitchCampaignDonations`   | function | `./client`  | `(opts: FetchTwitchCampaignDonationsOptions) => Promise<PaginatedResponse<TwitchDonation>>` |\n| `convertTwitchToTiltifyCampaign` | function | `./convert` | `(campaign: TwitchCampaign \\| null, ctx?: TwitchToTiltifyContext) => TiltifyCampaign \\| null` |\n| `convertTwitchToTiltifyDonation` | function | `./convert` | `(donation: TwitchDonation \\| null) => TiltifyDonation \\| null`                   |\n\nBoth fetchers issue a single `GET` and throw `TwitchCharityApiError` on any\nnon-2xx response. Both converters are pure — no `fetch`, no globals, no I/O.\n\n### Errors\n\n| Export                  | Kind  | Source     | Notes                                                                                 |\n| ----------------------- | ----- | ---------- | ------------------------------------------------------------------------------------- |\n| `TwitchCharityApiError` | class | `./errors` | `new (status: number, statusText: string, message?: string)`. Carries `.status`, `.statusText`, `.name === \"TwitchCharityApiError\"`. |\n\nOmitting `message` yields `Twitch Charity API error: <status> <statusText>`.\n\n### Types\n\n| Export                              | Kind      | Source     | Notes                                                                             |\n| ----------------------------------- | --------- | ---------- | --------------------------------------------------------------------------------- |\n| `TwitchCharityClientOptions`        | interface | `./client` | Shared base: `baseUrl`, optional `fetch` override, optional `signal`.             |\n| `FetchTwitchCampaignOptions`        | interface | `./client` | Adds `campaignId`.                                                                |\n| `FetchTwitchCampaignDonationsOptions` | interface | `./client` | Adds `campaignId`, `count` (default `100`), `pageNumber` (default `0`), `completedAfter`, `completedBefore`. |\n| `TwitchToTiltifyContext`            | interface | `./convert`| `{ causeId?: string }` — stamped onto the generated `TiltifyCampaign.cause_id`.   |\n| `TwitchCampaign`                    | interface | `./types`  | camelCase proxy campaign (`broadcaster`, `charity`, `currentAmount`, `targetAmount`). |\n| `TwitchDonation`                    | interface | `./types`  | camelCase proxy donation (`amount`, `campaignID`, `donatedAt`, `user`).           |\n| `TwitchPaginationMetadata`          | interface | `./types`  | `{ nextPage: number \\| null }` — `null` on the last page.                         |\n| `PaginatedResponse<T, M>`           | interface | `./types`  | Generic `{ data: T[]; metadata: M }` envelope; `M` defaults to `TwitchPaginationMetadata`. |\n| `RawTwitchCharity`                  | interface | `./types`  | Re-exported from [`@playlive/twitch-shared`](../shared/) — the snake_case Helix shape for callers hitting Twitch directly. |\n\n### Constants\n\n| Export         | Kind  | Source | Notes                                              |\n| -------------- | ----- | ------ | -------------------------------------------------- |\n| `PACKAGE_NAME` | const | `./`   | `\"@playlive/twitch-charity\"`, for version-pinning. |\n| `KNOWN_URLS`   | const | `./`   | Frozen empty array — see the disclosure section.   |\n\nAmount conventions: proxy amounts are integer minor units plus a\n`decimalPlaces` divisor. `convertTwitchToTiltifyCampaign` divides and calls\n`.toString()` (`284_750 / 10 ** 2` → `\"2847.5\"`);\n`convertTwitchToTiltifyDonation` formats via `Intl.NumberFormat(\"en-US\")`\n(`123_456_789` → `\"1,234,567.89\"`).\n\n## Upstream spec\n\nThe wire shapes this package exposes track the Twitch Helix Charity API, whose\npublic reference is the authority:\n\n- Twitch API reference — <https://dev.twitch.tv/docs/api/reference/>\n- `GET /helix/charity/campaigns` — <https://dev.twitch.tv/docs/api/reference/#get-charity-campaign>\n- `GET /helix/charity/donations` — <https://dev.twitch.tv/docs/api/reference/#get-charity-campaign-donations>\n\nThose two Helix operations are the only ones covered. The Play Live REST proxy\nthis client calls re-serves them at `/campaigns/twitch/{id}` and\n`/donations/twitch/{id}` with camelCase field names and a\n`{ data, metadata }` pagination envelope; `RawTwitchCharity` (re-exported from\n[`@playlive/twitch-shared`](../shared/)) is the untouched snake_case Helix\nshape for callers going direct to Twitch.\n\nTwitch's charity endpoints have no versioned schema beyond Helix itself — when\nTwitch adds a field, it surfaces here as an additive, non-breaking change.\n\n## Twitch Extension URL disclosure\n\nThe `KNOWN_URLS` export is empty because the base URL of the proxy is supplied\nby the consumer at call time (it differs per environment — see\n[`@playlive/fundraiser-data`](../../fundraiser-data/) and\n[`@playlive/realtime-pipeline`](../../realtime-pipeline/) for the canonical\ndisclosure lists).\n\n```ts\nimport { KNOWN_URLS } from \"@playlive/twitch-charity\";\nconsole.log(KNOWN_URLS); // []\n```\n\nIf you embed this package inside a Twitch Extension, add the proxy base URL\nyou actually call to the Extension's manifest URL allowlist yourself — the\nproxy host is environment-specific and not hard-coded.\n\n## Examples\n\n### Full donation crawl with retry, cancellation, and conversion\n\nThe pattern below is what the overlay data layer actually does: walk every page\nvia `metadata.nextPage`, retry transient 5xx failures, surface 4xx to the\ncaller, and convert the result into the Tiltify shapes the UI renders. Note\nthat `fetch` and `signal` are injectable on every call — that is the extension\npoint for retries, tracing, or cancellation.\n\n```ts\nimport {\n  convertTwitchToTiltifyCampaign,\n  convertTwitchToTiltifyDonation,\n  fetchTwitchCampaign,\n  fetchTwitchCampaignDonations,\n  TwitchCharityApiError,\n  type TwitchDonation,\n} from \"@playlive/twitch-charity\";\nimport type { TiltifyCampaign, TiltifyDonation } from \"@playlive/tiltify-core/types\";\n\nconst BASE_URL = \"https://twitch-charity-proxy.experience.stjude.org\";\nconst SJ_CAUSE_ID = \"400f5687-6017-4d1a-a4d9-7c9166b984c2\";\n\nasync function withRetry<T>(op: () => Promise<T>, attempts = 3): Promise<T> {\n  for (let i = 0; ; i++) {\n    try {\n      return await op();\n    } catch (err) {\n      // Only 5xx is worth retrying — 404 means the campaign is gone and\n      // 401/403 means the proxy rejected us, neither improves on retry.\n      const retryable = err instanceof TwitchCharityApiError && err.status >= 500;\n      if (!retryable || i >= attempts - 1) throw err;\n      await new Promise((r) => setTimeout(r, 250 * 2 ** i));\n    }\n  }\n}\n\nexport async function loadTwitchCampaign(\n  campaignId: string,\n  signal: AbortSignal,\n): Promise<{ campaign: TiltifyCampaign; donations: TiltifyDonation[] } | null> {\n  try {\n    const twitchCampaign = await withRetry(() =>\n      fetchTwitchCampaign({ baseUrl: BASE_URL, campaignId, signal }),\n    );\n\n    const raw: TwitchDonation[] = [];\n    let pageNumber: number | null = 0;\n    while (pageNumber !== null) {\n      const page = await withRetry(() =>\n        fetchTwitchCampaignDonations({\n          baseUrl: BASE_URL,\n          campaignId,\n          count: 100,\n          pageNumber,\n          signal,\n        }),\n      );\n      raw.push(...page.data);\n      pageNumber = page.metadata.nextPage;\n    }\n\n    const campaign = convertTwitchToTiltifyCampaign(twitchCampaign, {\n      causeId: SJ_CAUSE_ID,\n    });\n    if (!campaign) return null;\n\n    const donations = raw\n      .map((d) => convertTwitchToTiltifyDonation(d))\n      .filter((d): d is TiltifyDonation => d !== null);\n\n    return { campaign, donations };\n  } catch (err) {\n    if (err instanceof TwitchCharityApiError) {\n      // `.status` / `.statusText` are the branch points; `.message`\n      // defaults to `Twitch Charity API error: <status> <statusText>`.\n      if (err.status === 404) return null;\n      console.error(`proxy rejected the request: ${err.status} ${err.statusText}`);\n      throw err;\n    }\n    // AbortError, DNS failure, malformed JSON — not a proxy-level error.\n    throw err;\n  }\n}\n\nconst controller = new AbortController();\nsetTimeout(() => controller.abort(), 10_000);\nconst result = await loadTwitchCampaign(\"tw-c-1\", controller.signal);\nconsole.log(result?.campaign.name); // \"Playliver's Campaign for St. Jude\"\n```\n\n### Filtering donations by completion window\n\n`completedAfter` / `completedBefore` are ISO-8601 strings forwarded as the\n`completed_after` / `completed_before` query params:\n\n```ts\nconst today = await fetchTwitchCampaignDonations({\n  baseUrl: BASE_URL,\n  campaignId: \"tw-c-1\",\n  completedAfter: \"2025-01-01T00:00:00Z\",\n  completedBefore: \"2025-01-02T00:00:00Z\",\n  count: 25,\n});\n```\n\n### Testing without a network\n\nEvery fetcher accepts a `fetch` override, so tests need no interceptor:\n\n```ts\nimport { fetchTwitchCampaign } from \"@playlive/twitch-charity/client\";\n\nconst stub = async () =>\n  new Response(\n    JSON.stringify({\n      id: \"tw-c-1\",\n      broadcaster: { id: \"b-1\", login: \"playliver\", name: \"Playliver\" },\n      charity: { logo: \"\", name: \"St. Jude\", website: \"https://stjude.org\" },\n      currentAmount: { value: 284_750, currency: \"USD\", decimalPlaces: 2 },\n      targetAmount: { value: 500_000, currency: \"USD\", decimalPlaces: 2 },\n    }),\n    { status: 200, headers: { \"content-type\": \"application/json\" } },\n  );\n\nconst campaign = await fetchTwitchCampaign({\n  baseUrl: \"https://proxy.test\",\n  campaignId: \"tw-c-1\",\n  fetch: stub as typeof fetch,\n});\n```\n\nThe same override is the seam for mounting the two proxy routes on any local\nstub server: pass its `fetch` and no traffic leaves the process.\n\n## License\n\nMIT © St. Jude Children's Research Hospital\n","readmeFilename":"README.md","dist":{"tarball":"https://packages.playlive.experience.stjude.org/@playlive/twitch-charity/-/twitch-charity-0.1.2.tgz","shasum":"ce77e58d792163a38f7d99a748df5d8557abffd8","integrity":"sha512-6zwiAsP9+sNi/AdFFPpOYQaUv5nudEgkDDczvx3F0mKO+iLOXDFhd32Zxi/v0rPmA22Be87gWVBRyqlT+elTbQ=="}}},"time":{"0.1.0":"2026-08-26T18:10:14.002Z","modified":"2026-08-26T20:08:19.689Z","0.1.1":"2026-08-26T19:44:14.594Z","0.1.2":"2026-08-26T20:08:19.689Z"}}