@zakkster/lite-twitch-ebs
v1.0.0
Published
Reactive RPC layer to a Twitch Extension's EBS. JWT auto-attached, typed HttpError, SSE via streamQuery. Built on @zakkster/lite-query 1.1. Zero-dep ESM.
Downloads
80
Maintainers
Readme
@zakkster/lite-twitch-ebs
Reactive RPC layer to a Twitch Extension's own backend (the "EBS", in Twitch's terminology). Wraps @zakkster/lite-query 1.1's query / mutation / streamQuery with three things every consumer otherwise rebuilds: bearer-token auto-attachment, typed HttpError, and Server-Sent Events parsing.
npm i @zakkster/lite-twitch-ebsPeer deps: @zakkster/lite-signal @zakkster/lite-await @zakkster/lite-stream @zakkster/lite-query ^1.1.0. Zero runtime dependencies. ESM only.
What this is
A Twitch Extension's iframe talks to two backends: Twitch's Helix API (for read-mostly Twitch-owned data) and your own EBS (for everything your extension does). The EBS is where your business logic lives. The iframe authenticates to it by sending the helper JWT (the one Twitch hands to Twitch.ext.onAuthorized) in the Authorization header; your EBS verifies the signature with the extension secret via @zakkster/lite-twitch-jwt.verify().
lite-twitch-ebs is the iframe-side adapter for that flow. It does three concrete things:
- Reads the token accessor on every request. Pass
token: () => sdk.token()from@zakkster/lite-twitch; the SDK's signal accessor is evaluated each fetch start, so token rotation (afterJWT_RECEIVED) reaches the next request without restarting anything. - Wraps non-2xx responses in
HttpError. Carriesstatus,statusText,url, and a best-effort parsed body. Surfaces through lite-query'serror()signal unchanged so consumers branch onerr.statusfor retry/UI decisions. - Parses SSE as a
streamQuery. Default parser yields{ event, data, id }withdataJSON-parsed when valid. Backed by lite-stream'spipeToSignal, so backpressure (mode: "buffer",maxBuffer: N) and abort-on-detach are inherited from lite-query/stream.
Usage
import {createTwitchSdk} from "@zakkster/lite-twitch";
import {queryClient} from "@zakkster/lite-query";
import {createEbs} from "@zakkster/lite-twitch-ebs";
import {effect} from "@zakkster/lite-signal";
const sdk = createTwitchSdk({helper: window.Twitch.ext});
const qc = queryClient({defaultStaleTime: 30_000, crossTab: true});
const ebs = createEbs({
qc,
baseUrl: "https://my-ebs.example.com",
token: () => sdk.token(),
});
await sdk.ready();Query (GET)
const poll = ebs.query({
key: () => ["poll", pollId()],
path: (key) => `/polls/${key[1]}`,
staleTime: 5_000,
});
effect(() => {
if (poll.loading()) return showSpinner();
if (poll.error()) return showError(poll.error());
renderPoll(poll.data());
});The query handle is a standard lite-query handle: data(), error(), status(), loading(), fetching(), refetch(), dispose(). Everything lite-query supports (reactive keys, enabled gates, staleTime, cacheTime, retry/backoff, cross-tab cache coherence) is available -- path and headers are the only options ebs consumes; everything else passes through.
Mutation (POST/PUT/PATCH/DELETE)
const vote = ebs.mutation({
method: "POST",
path: (vars) => `/polls/${vars.pollId}/votes`,
body: (vars) => ({choice: vars.choice}),
onSuccess: (_, vars) => qc.invalidate(["poll", vars.pollId]),
});
await vote.mutate({pollId: "abc", choice: 2});If you omit body, POST/PUT/PATCH default to sending vars as the JSON body -- the common case where the mutation's variables match the request shape gets zero boilerplate. DELETE sends no body by default. Content-Type: application/json is set automatically when serializing an object.
Stream (SSE)
const events = ebs.stream({
key: ["events", channelId],
path: `/events/${channelId}/stream`,
mode: "buffer",
maxBuffer: 100,
});
effect(() => {
const recent = events.data(); // array of SseEvent in buffer mode
if (Array.isArray(recent)) renderEventList(recent);
});The default event shape is { event, data, id }. data is JSON-parsed when valid, raw string when not, undefined for empty data: fields. To extract just the payload, pass a parse transform:
const ticks = ebs.stream({
key: ["ticks"], path: "/ticks",
parse: (ev) => ev.event === "tick" ? ev.data : undefined, // skip non-tick frames
});parse returning undefined skips the frame entirely (no signal write, no count bump). Throws inside parse are caught and routed to createEbs's onError hook without breaking the stream.
Error shapes
class LiteTwitchEbsError extends Error {
name: "LiteTwitchEbsError";
code: "misconfigured" | "no_token" | "closed";
cause?: unknown;
}
class HttpError extends Error {
name: "HttpError";
status: number;
statusText: string;
url: string;
body: unknown; // parsed JSON | raw string | undefined | null
}HttpError for anything the server said (4xx, 5xx); LiteTwitchEbsError for things the SDK refused to attempt (no token, SDK closed, misconfiguration).
The no_token case fires when the token accessor returns null or empty string. The cleanest fix is to gate the query with enabled:
const q = ebs.query({
key: () => ["user", userId()],
path: () => `/users/${userId()}`,
enabled: () => sdk.matches("authenticated"),
});With enabled false, lite-query skips the fetch entirely -- no no_token error fires, the query just sits in idle until authenticated.
Headers
const ebs = createEbs({
qc, baseUrl, token,
defaultHeaders: {"X-Extension-Version": "1.2.3"},
});
const q = ebs.query({
key: ["x"], path: "/x",
headers: {"X-Trace": "specific"}, // merged on top of defaultHeaders
});Precedence: defaultHeaders < per-request headers < Accept (auto, "application/json" for queries, "text/event-stream" for streams) < Content-Type (auto when serializing JSON) < Authorization (always wins; consumer cannot override the JWT).
Lifecycle
ebs.close() stops accepting new requests but does NOT dispose the queryClient or any in-flight queries -- those are owned by the consumer and may be shared with other adapters (e.g. @zakkster/lite-twitch-helix). To tear everything down:
ebs.close();
qc.clear(); // remove all cached entries and abort pending fetches
qc.dispose(); // release the queryClient's own signalsPer-handle: q.dispose() / m.dispose() / s.dispose() release the handle's signal node back to lite-signal's pool. Important for SPAs that mount/unmount many ebs queries over their lifetime.
Testing
createEbs accepts a fetch injectable. Wire a mock and assert exact request shapes:
import {createEbs} from "@zakkster/lite-twitch-ebs";
import {queryClient} from "@zakkster/lite-query";
const calls = [];
const mockFetch = async (url, init) => {
calls.push({url: String(url), method: init.method, headers: init.headers, body: init.body});
return new Response(JSON.stringify({ok: true}), {status: 200});
};
const qc = queryClient();
const ebs = createEbs({qc, baseUrl: "https://x", token: "test-jwt", fetch: mockFetch});
const m = ebs.mutation({path: "/action"});
await m.mutate({foo: 1});
assert.equal(calls[0].headers.Authorization, "Bearer test-jwt");For SSE the test fixtures in this package's test/_fixtures.js show how to build a streaming Response with a ReadableStream body -- copy/paste as needed.
What this package is NOT
- Not the Helix client. Helix uses
helixToken(the user's OAuth token, nullable for unlinked viewers); that's@zakkster/lite-twitch-helix. - Not the JWT verifier. Verification runs on your EBS via
@zakkster/lite-twitch-jwt. This SDK only attaches the existing helper JWT as the request bearer. - Not a websocket adapter. Stream support is SSE only in 1.0; ws can be added as a 1.x minor without changing the existing surface.
- Not a generic HTTP client. The auth model and error shape are specific to Twitch Extension EBS conventions. For Twitch-side APIs, use lite-twitch-helix; for non-Twitch backends, use lite-query directly.
License
MIT. Copyright (c) Zahary Shinikchiev <[email protected]>.
