← all packages

@playlive/tiltify-phoenix

v0.2.2

npm install @playlive/tiltify-phoenix

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


Tiltify Phoenix Channels realtime client. Drop-in replacement for the legacy @playlive/tiltify-tools/tiltify-websocket module — same surface, same wire-level behavior, same callback semantics.

Coverage

Install

bun add @playlive/tiltify-phoenix
# Peer dependency — the caller installs it
bun add phoenix
# TypeScript consumers also want the ambient types
bun add -d @types/phoenix

phoenix (^1.8.1) is the only entry in peerDependencies. @playlive/tiltify-core is a regular dependency and installs automatically — but you will usually want to import its tiltify singleton yourself to configure auth/logging before constructing a TiltifyWebSocket (see the caveat under Quick start).

Quick start

import { tiltify } from "@playlive/tiltify-core";
import { EVENTS_TO_WATCH, TiltifyWebSocket } from "@playlive/tiltify-phoenix";

// Configure the REST singleton used for the slug → fact-id lookups.
// NOTE: `startListeningToCampaignBySlug` calls `tiltify.UseProxy()` itself,
// which resets routing to the **prod** St. Jude proxy. To target qa/dev,
// resolve the campaign yourself and call `startListeningToFact` instead.
tiltify.UseProxy("prod");
tiltify.SetPublicMode();

const ws = new TiltifyWebSocket(
  (isInitial) => console.log("open", { isInitial }),
  (err)       => console.error("ws error", err),
  (isDisc)    => console.log("close", { isDisconnecting: isDisc }),
);

ws.addEventListener((factID, event, type, payload) => {
  console.log("event", { factID, event, type, payload });
});

// High-level: resolve slug → start listening to campaign + parent facts.
await ws.startListeningToCampaignBySlug("@playliver", "campaign-slug", EVENTS_TO_WATCH);

// …on unmount:
ws.disconnect();

Subpath exports

Subpath Description
@playlive/tiltify-phoenix Default barrel — TiltifyWebSocket, DEFAULT_TILTIFY_SOCKET_URL, EVENTS_TO_WATCH, EventType, FactType, the callback types, PACKAGE_NAME, KNOWN_URLS.
@playlive/tiltify-phoenix/client TiltifyWebSocket, DEFAULT_TILTIFY_SOCKET_URL, and the callback types only. Pulls the phoenix peer dep.
@playlive/tiltify-phoenix/events EVENTS_TO_WATCH, EventType, FactType. Zero runtime deps — safe for type-only imports in route handlers / dispatch tables.

The default barrel is a curated re-export (not export *), so these three lists are exactly the public surface.

API reference

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

Top-level exports:

Export Source Kind Notes
TiltifyWebSocket ./client class Phoenix Channel wrapper. Lifecycle, channel ack, per-event firehose, name cache.
DEFAULT_TILTIFY_SOCKET_URL ./client const "wss://websockets.tiltify.com/socket".
OnOpenCallback, OnErrorCallback, OnCloseCallback ./client type The three constructor callbacks.
ChannelListener ./client type { onOk, onError, onTimeout } per-channel join acks.
EVENTS_TO_WATCH ./events const EventType[] — every event suffix this client knows about. Order is stable.
EventType ./events type "donation", "donation_updated", "match", "fact", "reward", "milestone", "challenge", "poll", "poll_option".
FactType ./events type "campaign", "fundraising-event", "cause".
PACKAGE_NAME, KNOWN_URLS ./ const Package metadata / Twitch Extension URL disclosure.

TiltifyWebSocket

The constructor connects immediately: new TiltifyWebSocket(onOpen, onError, onClose, address?)address defaults to DEFAULT_TILTIFY_SOCKET_URL.

Method Notes
connect() Re-arm + re-open. The next onOpen reports isInitialConnection === true.
addEventListener(listener) Firehose: (factID, event, type, payload) => void for every event on every joined fact.
on(event, callback) Filtered form: (factID, type, payload) => void for one EventType.
addChannelListener(listener) Per-channel ok / error / timeout acks. Register before you start listening.
startListeningToCampaignBySlug(teamUserSlug, campaignSlug, events?) Resolves the slug pair via REST, then joins the campaign + its parent fundraising event. Throws if the campaign is not found.
startListeningToFact(factID, events, type, lookup?) Low-level join. Idempotent per factID. lookup (default true) fetches the display name + parent facts.
disconnect() Leaves every channel and closes the socket; next onClose reports isDisconnecting === true.
getCampaignName(id) / getFundraisingEventName(id) / getCauseName(id) Cached display names, or undefined.
getFactNameForType(factID, type) Dispatches to whichever of the three caches matches type.

events defaults to EVENTS_TO_WATCH on both startListening* methods, but on startListeningToFact it sits before the required type argument — pass it explicitly there.

Migration from @playlive/tiltify-tools/tiltify-websocket

The surface is identical at the call site. Only the import specifier (and the underlying REST singleton) changed:

- import {
-   EVENTS_TO_WATCH,
-   type EventType,
-   type FactType,
-   TiltifyWebSocket,
- } from "@playlive/tiltify-tools/tiltify-websocket";
+ import {
+   EVENTS_TO_WATCH,
+   type EventType,
+   type FactType,
+   TiltifyWebSocket,
+ } from "@playlive/tiltify-phoenix";

Behavior deltas:

Twitch Extension URL disclosure

Every Extension-eligible package declares the hosts / absolute URLs it can reach over the network as KNOWN_URLS — the list a Twitch Extension submission must disclose verbatim.

import { KNOWN_URLS } from "@playlive/tiltify-phoenix";
console.log(KNOWN_URLS);
// ["wss://websockets.tiltify.com"]

REST hosts (https://v5api.tiltify.com, the St. Jude proxy URLs) are disclosed by @playlive/tiltify-core and inherited transitively — the Extension form needs the union of both lists.

Versioning

Follows the workspace's 0.x line. The actual realtime surface shipped in 0.2.0; 0.1.0 was an empty placeholder. See CHANGELOG.md for per-version detail.

Upstream spec

This package targets Tiltify's Phoenix Channels realtime gateway at wss://websockets.tiltify.com/socket (DEFAULT_TILTIFY_SOCKET_URL). Tiltify does not publish a specification for it: the channel topic format (fact.<fact-id>.<event>) and the event suffix list (EVENTS_TO_WATCH) are derived from observed wire traffic and frozen in this package, so pin the version if you depend on an exact event set.

Fact lookups (slug → ID, parent fundraising-event resolution) delegate to the documented Tiltify v5 REST API via @playlive/tiltify-core; upstream reference documentation for that surface is at https://developers.tiltify.com.

When Tiltify publishes new realtime event suffixes they are appended to EVENTS_TO_WATCH (and to the derived EventType union) in a minor release.

Examples

Overlay lifecycle: donation firehose with named facts

The pattern a live-events provider in an overlay app wants. Register channel and event listeners before starting to listen — addChannelListener only fires for channels opened after it was registered.

import { tiltify } from "@playlive/tiltify-core";
import {
  type EventType,
  type FactType,
  TiltifyWebSocket,
} from "@playlive/tiltify-phoenix";

interface DonationPayload {
  id: string;
  amount: { value: string; currency: string };
  donor_name: string;
  donor_comment: string | null;
}

export function startDonationFeed(teamUserSlug: string, campaignSlug: string) {
  tiltify.UseProxy("prod");
  tiltify.SetPublicMode();

  const ws = new TiltifyWebSocket(
    (isInitial) => {
      // Phoenix auto-reconnects; only the first open is "initial".
      if (!isInitial) console.info("tiltify socket reconnected");
    },
    (err) => console.error("tiltify socket error", err),
    (isDisconnecting) => {
      if (!isDisconnecting) console.warn("tiltify socket dropped unexpectedly");
    },
  );

  // Per-channel join acks — useful for surfacing "subscribed" state in a UI.
  ws.addChannelListener({
    onOk: (factID, event, type) => console.debug("joined", { factID, event, type }),
    onError: (factID, event, type, error) =>
      console.error("join failed", { factID, event, type, error }),
    onTimeout: (factID, event, type) => console.warn("join timed out", { factID, event, type }),
  });

  // Filtered subscription for the one event this feed cares about.
  ws.on("donation", (factID, type: FactType, payload) => {
    const donation = payload as DonationPayload;
    const factName = ws.getFactNameForType(factID, type) ?? factID;
    console.info(`💸 ${donation.donor_name} → ${factName}: ${donation.amount.value}`);
  });

  // Unfiltered firehose for logging / dev tooling.
  ws.addEventListener((factID, event: EventType, type: FactType) => {
    console.debug("tiltify event", { factID, event, type });
  });

  // Resolves the slug pair via REST, joins the campaign channel set, and —
  // when the campaign belongs to one — the parent fundraising event's too.
  const ready = ws
    .startListeningToCampaignBySlug(teamUserSlug, campaignSlug)
    .catch((err: unknown) => {
      // "Could not find campaign with the provided slugs" on a 404;
      // TiltifyLibError for transport failures.
      console.error("could not subscribe", err);
      ws.disconnect();
      throw err;
    });

  return { ws, ready, stop: () => ws.disconnect() };
}

Narrow the event set and skip the REST round-trip

When you already know the fact id (from @playlive/tiltify-core's FindCampaign, or @playlive/tiltify-graphql's getFactByVanityAndSlug), call startListeningToFact directly with lookup: false. The channel subscription happens immediately; getCampaignName() stays undefined until you populate it yourself.

import { TiltifyWebSocket } from "@playlive/tiltify-phoenix";

const ws = new TiltifyWebSocket(
  () => {},
  console.error,
  () => {},
  // Point at a local Phoenix simulator instead of Tiltify.
  "ws://localhost:4000/socket",
);

// Only milestone + poll traffic, no name lookup.
await ws.startListeningToFact(
  "9c1d0b7a-8f6e-4a2b-9c33-1f0e6a5b7c21",
  ["milestone", "poll", "poll_option"],
  "fundraising-event",
  false,
);

ws.on("milestone", (_factID, _type, payload) => {
  console.log("milestone changed", payload);
});

startListeningToFact is idempotent per factID, so calling it again for a fact you already joined is a no-op — even if the events list differs. Join the widest event set you need the first time.

License

MIT © St. Jude Children's Research Hospital