@zakkster/lite-twitch
v1.0.1
Published
Signal-native, state-machine-backed iframe SDK for Twitch Extensions. Wraps Twitch.ext in lite-signal/lite-statechart/lite-await primitives. Zero-dep ESM.
Downloads
210
Maintainers
Readme
@zakkster/lite-twitch
Signal-native, state-machine-backed iframe SDK for Twitch Extensions. Wraps window.Twitch.ext in lite-signal / lite-statechart / lite-await primitives so the rest of your extension code stays in the same reactive vocabulary as everything else in the @zakkster/lite-* ecosystem.
npm i @zakkster/lite-twitchPeer deps: @zakkster/lite-signal @zakkster/lite-statechart @zakkster/lite-await @zakkster/lite-twitch-jwt. Zero runtime dependencies.
What this is
The Twitch Extensions iframe helper (window.Twitch.ext) is a callback-shaped API:
Twitch.ext.onAuthorized((auth) => { /* token reissue */ });
Twitch.ext.onContext((ctx, changed) => { /* player state */ });
Twitch.ext.onVisibilityChanged((isVisible) => { /* tab focus */ });
Twitch.ext.listen("broadcast", (target, contentType, msg) => { /* PubSub */ });It also has lifecycle quirks (JWT reissue every ~30min, expiry ~60min, viewers can minimize the extension) that every extension reinvents handling for, usually badly. lite-twitch lifts those callbacks into signals, models the auth lifecycle as a real state machine, and gives you one composition primitive: pubsubSignal(target).
import {createTwitchSdk} from "@zakkster/lite-twitch";
import {effect} from "@zakkster/lite-signal";
const sdk = createTwitchSdk({helper: window.Twitch.ext});
await sdk.ready(); // resolves once authenticated
const role = sdk.claims().role; // "viewer" | "broadcaster" | ...
const broadcast = sdk.pubsubSignal("broadcast");
effect(() => {
if (!sdk.visible()) return;
const msg = broadcast();
if (msg !== null) handle(msg);
});
// Later, on extension teardown:
sdk.close();Auth machine
The auth lifecycle is a @zakkster/lite-statechart flat FSM. The shape:
+-----------------------+
| anonymous | initial
+-----------+-----------+
JWT_RECEIVED---------|
v
+-----------------------+
VALID| validating |
+----+-------------+----+
+----+ +----INVALID--+ (or EXPIRED)
v v
+----------------------+ (back to anonymous)
| authenticated |
+---+--------------+---+
| JWT_RECEIVED | JWT_EXPIRING (timer)
v v
(validating) (anonymous)
Wildcard (applies from every non-final state):
CLOSE -> closed (final, terminal)
FATAL -> error (final, terminal)JWT_EXPIRING fires from an internal timer scheduled 60s before the current token's exp. It transitions to anonymous, not back to validating -- by the time the timer fires, Twitch should have already reissued via onAuthorized (typical reissue is at ~30min into a ~60min token's life). If JWT_EXPIRING fires, it means the reissue channel is broken, and dropping to anonymous tells consumers their auth is unreliable.
The authenticated entry action takes the lite-statechart 1.1 ctx.signal so the refresh timer is scoped to the state -- when the machine leaves authenticated for any reason (CLOSE, FATAL, JWT_RECEIVED, JWT_EXPIRING), ctx.signal aborts and the timer is cleared automatically. No leak windows.
Visibility is deliberately NOT in the machine -- it's orthogonal to auth. Use sdk.visible if you care, or compose: computed(() => sdk.matches("authenticated") && sdk.visible()).
Signals you get
sdk.auth // ReadSignal<TwitchAuthPayload | null> -- raw helper auth payload
sdk.context // ReadSignal<TwitchContextPayload | null> -- latest onContext
sdk.claims // ReadSignal<TwitchClaims | null> -- decoded JWT claims
sdk.token // ReadSignal<string | null> -- raw JWT string
sdk.visible // ReadSignal<boolean> -- from onVisibilityChanged
sdk.state // ReadSignal<TwitchAuthState> -- machine state nameAll read-only by convention. The SDK is the only writer.
ready()
await sdk.ready({timeout: 5000, signal: ctrl.signal});Resolves when the machine reaches authenticated. Rejects with:
LiteTwitchError("closed")ifsdk.close()is called before authentication completes, or if the machine is already closed whenready()is called.LiteTwitchError("fatal")if aFATALevent drives the machine toerror.TimeoutErrorifopts.timeoutelapses (from@zakkster/lite-await).AbortErrorifopts.signalaborts.
The implementation uses raceOf to race "reached authenticated" against "reached terminal", so terminal-state rejection is immediate rather than dependent on caller timeout.
pubsubSignal
const broadcast = sdk.pubsubSignal("broadcast");
effect(() => {
const msg = broadcast();
if (msg === null) return;
// msg is whatever the helper delivered: Uint8Array, ArrayBuffer, or string.
});The signal value is the most recent payload received on that PubSub target. Auto-unsubscribed on sdk.close(). Composes naturally with @zakkster/lite-twitch-room's transport, which expects to peel multi-packet envelopes -- you'd typically not consume pubsubSignal directly for room traffic, since the room transport owns that path.
Composition with @zakkster/lite-twitch-room
The intended Phase 2 pattern (released as lite-twitch-room v0.2.0):
import {createTwitchSdk} from "@zakkster/lite-twitch";
import {createRoom} from "@zakkster/lite-room";
import {createTwitchPubsubTransport} from "@zakkster/lite-twitch-room";
import {allOf} from "@zakkster/lite-await";
const sdk = createTwitchSdk({helper: window.Twitch.ext});
await sdk.ready();
const transport = createTwitchPubsubTransport({
helper: window.Twitch.ext,
ebsUrl: "https://my-ebs.example.com/room/" + sdk.auth().channelId + "/packets",
jwt: () => sdk.token(),
});
const room = createRoom({transport, username: "alice"});
await allOf([
[room.status, (s) => s === "connected"],
[sdk.state, (s) => s === "authenticated"],
]);Or, after lite-twitch-room v0.2.0 ships, createTwitchRoom(opts) will wrap all of this into a single factory.
Testing
Use createTwitchMock -- it's the canonical mock for the entire ecosystem.
import {test} from "node:test";
import {createTwitchSdk, createTwitchMock} from "@zakkster/lite-twitch";
test("my extension behaves on auth", async () => {
const mock = createTwitchMock();
const sdk = createTwitchSdk({helper: mock.helper});
mock.fire.authorized({
channelId: "12345",
clientId: "test-client",
token: makeValidJwt(),
userId: "UABCDEF",
});
await sdk.ready();
// ... assertions
sdk.close();
});mock.fire exposes authorized, context, broadcast, global, visibility for driving callbacks deterministically. mock.sentMessages records every helper.send call. mock.reset() clears all state between tests.
What this package is NOT
- Not the room transport. That's
@zakkster/lite-twitch-room. - Not the Helix client. That's
@zakkster/lite-twitch-helix(Phase 3). - Not the EBS RPC layer. That's
@zakkster/lite-twitch-ebs(Phase 3). - Not Bits commerce. That's
@zakkster/lite-twitch-bits(Phase 3). - Not a server-side library. JWT signature verification lives in
@zakkster/lite-twitch-jwt; this SDK only does local decode + freshness checks (sufficient for iframe-side code -- the EBS is the source of truth on signature validity).
License
MIT. Copyright (c) Zahary Shinikchiev <[email protected]>.
