npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

restream-sdk

v0.1.3

Published

Plug-and-play headless React hooks to add multi-platform restreaming (connect socials, go live, live chat + viewers) with zero client backend.

Readme

restream-sdk

Add multi-platform restreaming to your app. Your streamers connect their Twitch / Kick / YouTube accounts once, and you fan an already-live stream out to all of them — with live chat and viewer counts aggregated back into your UI.

Your existing live stream  ──►  Twitch
        (one session)      ──►  Kick      + merged chat & viewer counts back to your UI
                           ──►  YouTube …
  • ⚡️ Zero backend. Drop in a publishable key and go. (An optional one-route token mode adds hard per-user security — recommended for production.)
  • 🎛 Headless. React hooks only — you build the UI with your own components.
  • 🔌 You already have the stream. This is a control plane. You hand it the publisherSessionId from your existing stream and it manages the restream. It does not capture or publish media.
  • 🔑 No OAuth setup. Connecting social accounts is fully hosted — your streamers click "Connect Twitch"; you register nothing on any platform and configure no webhooks.

Mental model. You're already streaming a session (e.g. via Cloudflare Realtime). This SDK is the thin layer that lets a streamer link their social accounts and mirror that session to them, controlled entirely from your frontend. The heavy lifting (OAuth, tokens, transcoding, RTMP fan-out, chat collection) lives server-side.


Table of contents


Install

npm install restream-sdk
# peer dependency: react >= 17 (optional — a framework-agnostic core ships too)

It's a normal build-time dependency bundled into your app. Your end users install nothing.


The integration contract (read this first)

To go from zero to live you provide exactly four things. Everything else is hosted.

| # | You provide | Where it comes from | Notes | |---|---|---|---| | 1 | apiBase | Your operator | e.g. https://restream-api.fomo.gg | | 2 | A credential | Your operator | A publishableKey (pk_live_…) for zero-backend, or a tokenEndpoint for hardened mode | | 3 | A stable userId | Your user system | The streamer's id in your database. Must be stable + unique — connections and jobs are keyed by it server-side. See the contract. | | 4 | A publisherSessionId | Your existing stream | The live media session you already produce, passed into goLive(). See below. |

You do not register OAuth apps, host OAuth callbacks, configure platform webhooks, or handle any platform tokens. That's all server-side.


What you get from your operator

Ask whoever runs the Restream Service for:

  1. API base URL — e.g. https://restream-api.fomo.gg.
  2. A publishable keypk_live_…, created in Admin → Clients → Keys, with:
    • Scopes: connect, restream, read, chat:write (see Scopes).
    • Allowed origins: every origin your app runs on, e.g. https://app.fomo.gg, http://localhost:3000. The key is origin-locked — it only works from those origins, which is what makes it safe to ship in your frontend.
  3. (Production) The secret API key — server-side only, for token mode. Never put this in the browser.

Quickstart (zero backend)

"use client";
import { useRestream } from "restream-sdk";

export default function RestreamPanel({ streamerId, publisherSessionId }) {
  const {
    connections, connect, disconnect,
    goLive, stopLive, job,
    viewers, comments, sendChat,
  } = useRestream({
    apiBase: "https://restream-api.fomo.gg",
    publishableKey: "pk_live_xxx",
    userId: streamerId,            // ← your stable id for this streamer
  });

  const isConnected = (p) => connections.some((c) => c.platform === p);

  return (
    <div>
      {/* 1 — link social accounts (hosted OAuth popup) */}
      {["twitch", "kick"].map((p) =>
        isConnected(p)
          ? <button key={p} onClick={() => disconnect(p)}>Disconnect {p}</button>
          : <button key={p} onClick={() => connect(p)}>Connect {p}</button>
      )}

      {/* 2 — mirror your existing session to the connected platforms */}
      {!job
        ? <button onClick={() => goLive({ publisherSessionId, destinations: ["twitch", "kick"] })}>
            Go live
          </button>
        : <button onClick={stopLive}>Stop ({job.status})</button>}

      {/* 3 — merged viewers + chat (both directions) */}
      <p>Viewers: {viewers?.total ?? 0}</p>
      <ul>{comments.map((c) => <li key={c.id}><b>{c.platform}/{c.author}:</b> {c.message}</li>)}</ul>
      <button onClick={() => sendChat("hello everyone 👋")}>Say hi to all chats</button>
    </div>
  );
}

The full streamer lifecycle

A complete, production-shaped component covering the whole journey — link accounts, go live with your session, watch links, viewers, two-way chat, stop:

"use client";
import { useRestream } from "restream-sdk";
import { useState } from "react";

const PLATFORMS = ["twitch", "kick"];

export default function Studio({ streamerId, getPublisherSessionId }) {
  const r = useRestream({
    apiBase: "https://restream-api.fomo.gg",
    publishableKey: process.env.NEXT_PUBLIC_RESTREAM_PK,
    userId: streamerId,                 // stable id from your user table
  });
  const [msg, setMsg] = useState("");
  const isConnected = (p) => r.connections.some((c) => c.platform === p);
  const live = r.job && !["stopped", "failed", "ended"].includes(r.job.status);

  async function start() {
    // Pull the session id from YOUR existing stream pipeline at go-live time.
    const publisherSessionId = await getPublisherSessionId();
    await r.goLive({ publisherSessionId, destinations: PLATFORMS.filter(isConnected) });
  }

  return (
    <section>
      {/* link accounts — persists across refreshes (keyed by userId) */}
      <div>
        {PLATFORMS.map((p) => (
          <button key={p} onClick={() => isConnected(p) ? r.disconnect(p) : r.connect(p)}>
            {isConnected(p) ? `✓ ${p}` : `Connect ${p}`}
          </button>
        ))}
      </div>

      {/* go live / stop */}
      {!live
        ? <button disabled={!PLATFORMS.some(isConnected)} onClick={start}>Go live</button>
        : <button onClick={r.stopLive}>Stop stream</button>}
      {r.job && <span> status: {r.job.status}</span>}

      {/* watch links (available once broadcasts are created) */}
      {(r.job?.broadcasts || []).map((b) => b.watchUrl &&
        <a key={b.platform} href={b.watchUrl} target="_blank" rel="noreferrer">Watch on {b.platform} ↗</a>)}

      {/* live stats + merged chat */}
      {live && (
        <>
          <p>Viewers: {r.viewers?.total ?? 0}
            {r.viewers?.platforms && Object.entries(r.viewers.platforms).map(([p, n]) => ` · ${p}: ${n}`)}</p>
          <ul>{r.comments.map((c) => <li key={c.id}><b>{c.platform}/{c.author}:</b> {c.message}</li>)}</ul>
          <form onSubmit={(e) => { e.preventDefault(); r.sendChat(msg); setMsg(""); }}>
            <input value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="message all chats" />
            <button>Send</button>
          </form>
        </>
      )}

      {r.error && <p role="alert">{r.error.message}</p>}
    </section>
  );
}

Note getPublisherSessionId() — you supply the session id from your stream at the moment you go live. The SDK never creates it.


Where publisherSessionId comes from

This is the one piece that ties into your infrastructure, so it's worth being precise.

The publisherSessionId is the live media session your platform already produces for the streamer. If your platform publishes streamer media to Cloudflare Realtime (Calls) — the typical setup — the session id Cloudflare returns when the streamer's broadcast starts is the publisherSessionId. You already have it; you just pass it in:

// when your streamer goes live on YOUR platform, you already create a session:
const publisherSessionId = "<the Cloudflare Calls session id for this broadcast>";

await goLive({ publisherSessionId, destinations: ["twitch", "kick"] });

Requirements:

  • The session must be live and actively sending media at the moment you call goLive(). The worker pulls from it immediately.

  • It must carry the tracks you intend to restream (audio + video). If you publish video only (e.g. no mic), pass trackNames: ["video"] so the worker doesn't wait for an audio track that will never arrive:

    goLive({ publisherSessionId, destinations: ["twitch"], trackNames: ["video"] });

The SDK is a control plane: it does not open a camera, capture a screen, or publish RTMP/WebRTC. Producing the session stays entirely on your side, exactly as it works today — the only change is that your frontend now makes the goLive() call (instead of nothing happening after the session starts).


The userId contract & persistence

userId is the streamer's id in your system (e.g. your DB primary key). It is the join key for everything server-side, so:

  • Stable — use the same value for a given streamer forever. Don't use a random/session value.
  • Unique per streamer — one streamer = one userId.
  • Opaque is fine"user_8412", a UUID, etc. Avoid PII.

Why it matters — persistence is automatic and server-side:

  • When a streamer connects Twitch/Kick, the link is stored server-side, keyed by (yourClient, userId, platform). Nothing about it lives in the browser.
  • On mount (including after a page refresh, in a new tab, or on another device), the SDK auto-calls listConnections() and your connections state repopulates — the streamer sees "✓ Twitch" again with no re-auth.
  • Platform tokens are auto-refreshed server-side, so connections stay valid long-term. A streamer only needs to reconnect if they revoke access on the platform itself.

If you pass a different userId on the next load, you'll get a different (empty) connection set. A stable userId is the whole persistence story.

In token mode the userId is baked into the token your backend mints — so it's inherently stable per logged-in user and the browser can't stream as anyone else.


Connecting accounts

await connect("twitch");   // opens a hosted OAuth popup, resolves when linked
await disconnect("kick");
connections;               // [{ platform, platformUsername, ... }] — tokens never exposed
  • connect(platform) opens a popup to the service's hosted OAuth (using the operator's shared platform apps) and resolves on success. Allow popups for your origin.
  • Supported: twitch, kick, youtube, facebook, instagram — availability depends on what the operator has enabled.
  • Connections persist (see above); you don't store anything yourself.

Going live

const job = await goLive({
  publisherSessionId,                  // REQUIRED — your existing live session
  destinations: ["twitch", "kick"],    // which connected platforms to fan out to
  overlay: { htmlBanner: "https://cdn.you.com/banner.html", enabled: true }, // optional
  recording: false,                    // optional
  trackNames: ["audio", "video"],      // optional — match what your session publishes
});

await stopLive();
  • One encode → many destinations. Restreaming to 2 platforms or 5 costs the same on the source; it's a single encode fanned out.
  • One live stream per streamer. Calling goLive() again for the same userId supersedes the previous job (the new one takes over). This prevents duplicate streams if a streamer double-clicks or reloads mid-stream.
  • Watch links. Once broadcasts are created, job.broadcasts carries { platform, watchUrl } for each destination — handy for "Watch on Twitch ↗".
  • arm({ destinations }) pre-selects destinations so you can fire goLive (with just the publisherSessionId) the instant your session starts — useful for auto-start when a streamer goes live on your platform.

Live chat & viewers

viewers;                          // { total, platforms: { twitch: 12, kick: 5 } } — polled ~10s
comments;                         // merged incoming chat from all destinations (live via SSE)
await sendChat("gg!");            // fan a message OUT to every connected chat
await sendChat("ty", ["kick"]);  // …or only some platforms
  • comments is the merged inbound feed across all destinations, delivered over SSE and de-duplicated (history replays safely on reconnect). It's capped to the most recent 200 messages.
  • Each comment: { id, platform, author, authorAvatar, message, timestamp }.
  • sendChat is outbound fan-out — one message posted to every (or selected) connected chat. Requires the chat:write scope.

Banners / overlays

Pass an overlay to goLive to composite a banner onto the outgoing stream (rendered + burned in server-side, so it shows on every destination):

goLive({
  publisherSessionId,
  destinations: ["twitch"],
  overlay: { htmlBanner: "https://cdn.you.com/scoreboard.html", enabled: true },
});
  • htmlBanner — URL to a transparent HTML5 page. It can run its own JS/data feed (scoreboards, tickers, live stats); the service renders it headlessly and composites it full-frame every tick.
  • topBanner / bottomBanner — transparent PNG URLs for simple top/bottom bars.
  • Update PNG bars on a live job without restarting it:
    await updateOverlay({ topBanner, bottomBanner, hideTop, hideBottom });

Auth modes: publishable key vs. token

| | Publishable key (pk_live_…) | Token (tokenEndpoint) | |---|---|---| | Client backend | None | One small route | | userId | Passed from the browser | Bound server-side in the token | | Security | Origin-locked + scoped (good) | Hard per-user binding (best) | | Best for | Quick start, demos, trusted UIs | Production — anywhere the browser shouldn't pick which user it streams as |

The publishable key is origin-locked and scoped, which is enough for many apps. For production (and anywhere a user could tamper with a userId), mint a short-lived token on your server with your secret API key and point the SDK at it:

// YOUR backend — one route. The secret key never reaches the browser.
app.get("/api/restream-token", requireAuth, async (req, res) => {
  const r = await fetch("https://restream-api.fomo.gg/api/v1/auth/session", {
    method: "POST",
    headers: { "X-API-Key": process.env.RESTREAM_API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ userId: req.user.id }),   // ← bound to the logged-in user, server-side
  });
  res.json(await r.json());                           // { token, expiresIn }
});
// browser — no publishableKey, no userId; the token carries (and locks) the user.
useRestream({ apiBase: "https://restream-api.fomo.gg", tokenEndpoint: "/api/restream-token" });

The SDK fetches the token, attaches it to every request, and transparently re-mints it when it expires (including reopening the chat stream).


Output resolution & quality

The restream output matches the resolution of your publisher session (up to 1080p). Bitrate, H.264 profile, and encoding are handled server-side — you don't configure an encoder. To deliver 1080p, publish your session at 1080p; a 720p session restreams at 720p. Adding more destinations does not reduce quality (single encode, fanned out).


API reference

useRestream(options) → state + actions

Options: { apiBase, publishableKey?, tokenEndpoint?, userId?, timeoutMs? } — provide either publishableKey (+ userId) or tokenEndpoint. timeoutMs defaults to 15000. On mount it auto-loads connections; job.status is polled live (queued → running → stopped); incoming chat is de-duplicated.

Returns:

| Field | Type | Notes | |---|---|---| | connections | Connection[] | linked accounts (tokens redacted); auto-loaded on mount | | job | Job \| null | current restream job (status polled) | | viewers | { total, platforms } | polled viewer counts | | comments | Comment[] | live merged inbound chat (SSE), latest 200 | | error | Error \| null | last error (err.status carries the HTTP code) | | connect(platform) | Promise | hosted OAuth popup | | disconnect(platform) | Promise | | | listConnections() | Promise<Connection[]> | manual refresh | | arm({ destinations }) | — | pre-select destinations for goLive | | goLive(params) | Promise<Job> | see Going live | | stopLive() | Promise | | | refreshJob() | Promise<Job> | re-fetch job status | | sendChat(message, platforms?) | Promise | outbound chat fan-out | | updateOverlay(opts) | Promise | live banner update | | studio | RestreamStudio | the underlying controller |

Convenience hooks

useConnections(options){ connections, connect, disconnect, refresh }, useLiveChat(studio){ comments, send }, useViewers(studio){ viewers }.

Framework-agnostic core

Not using React? Import the controller directly:

import { RestreamStudio } from "restream-sdk/core";

const studio = new RestreamStudio({ apiBase, publishableKey, userId });
const off = studio.on("comments", (list) => render(list));   // also: connections, job, viewers, comment, error
await studio.listConnections();
await studio.connect("twitch");
await studio.goLive({ publisherSessionId, destinations: ["twitch"] });
// ... studio.destroy() to tear down timers/streams

TypeScript types ship with the package (restream-sdk and restream-sdk/core).


Data shapes

type Platform = "twitch" | "kick" | "youtube" | "facebook" | "instagram";

interface Connection {
  platform: Platform;
  platformUsername: string;     // display name on the platform
  platformUserId?: string;
}

interface Job {
  id: string;
  status: "queued" | "running" | "stopping" | "stopped" | "failed" | "ended";
  broadcasts?: { platform: Platform; watchUrl?: string; broadcastId?: string }[];
}

interface Comment {
  id: string;
  platform: Platform;
  author: string;
  authorAvatar?: string | null;
  message: string;
  timestamp: number;
}

interface Viewers {
  total: number;
  platforms: Record<Platform, number>;   // e.g. { twitch: 12, kick: 5 }
}

Scopes

A publishable key (or token) carries scopes; the operator sets them at creation:

| Scope | Allows | |---|---| | connect | manage social connections (connect / list / disconnect) | | restream | start/stop a job + overlay updates | | read | job status, viewers, comments | | chat:write | send chat |

A 403 "Missing required scope" means your key needs that scope added.


Next.js / SSR notes

  • Use the hooks in a Client Component ("use client"). connect() uses popups and window, so it runs in the browser only.
  • Put the publishable key in a NEXT_PUBLIC_ env var (it's safe — origin-locked). The secret API key (token mode) stays server-side only.
  • The package is ESM and side-effect-free (tree-shakeable). It uses fetch, EventSource, and window.open — all standard browser APIs.

Error handling

  • Every action rejects on failure; the hook also surfaces the latest error as error. Errors carry err.status (the HTTP status, or 0 for network/timeout):

    try { await goLive({ publisherSessionId, destinations }); }
    catch (e) { if (e.status === 403) showUpgrade(); else toast(e.message); }
  • Common statuses: 401 (bad/expired credential — token mode re-mints automatically), 403 (origin not allowed, or missing scope), 409/supersede (a newer stream took over), 0 (network/timeout).

  • Realtime is resilient: transient SSE drops reconnect automatically and chat history de-dupes on replay; job-status polling retries on transient failures.


Troubleshooting

| Symptom | Cause / fix | |---|---| | connect() does nothing | Popup blocked — allow popups for your origin (call it from a click handler). | | Origin not allowed for this key (403) | Your app's origin isn't on the key's allowlist — ask the operator to add it. | | failed to fetch / network error | Wrong apiBase, or the origin/key mismatch blocked CORS. | | Connections empty after refresh | You passed a different userId — it must be stable per streamer. | | Job goes running but nothing on the platforms | The publisherSessionId must be a live session actively sending media when you goLive(). | | Worker seems to wait / no audio | Your session has no audio track — pass trackNames: ["video"]. | | Starting a 2nd stream stops the first | Expected — one live stream per streamer; a new goLive supersedes the old. | | Missing required scope (403) | Key needs that scope (connect / restream / read / chat:write). |


Integration checklist

  • [ ] Operator created a publishable key scoped connect, restream, read, chat:write and allowlisted your origin(s).
  • [ ] You pass a stable, unique userId per streamer.
  • [ ] You can supply a live publisherSessionId from your existing stream at go-live time (with trackNames if audio-less).
  • [ ] Popups are allowed for your origin (for connect).
  • [ ] (Production) You stood up the token endpoint and switched the SDK to tokenEndpoint so userId is server-bound.
  • [ ] You render connections, job.status, job.broadcasts watch links, viewers, and comments.

Questions, a key request, or an origin to allowlist? Contact your Restream Service operator.