restream-sdk 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,34 +1,49 @@
1
1
  # restream-sdk
2
2
 
3
- Add **multi-platform restreaming** to your app in minutes. Your streamers connect
4
- their Twitch / Kick / YouTube accounts and you fan an existing live stream out to
5
- all of them — with **live chat and viewer counts** aggregated back into your UI.
6
-
7
- - ⚡️ **Zero backend.** Set a publishable key and go. (An optional one-route token
8
- mode is available when you want hard per-user security.)
9
- - 🎛 **Headless.** React hooks only you build the UI with your own design system.
10
- - 🔌 **You already have the stream.** This is a *control plane*: you hand it the
11
- `publisherSessionId` from your existing stream and it manages the restream. It
12
- does **not** capture or publish media.
13
- - 🔑 **No OAuth setup.** Connecting social accounts is hosted for youyour
14
- streamers click "Connect Twitch", you register nothing on any platform.
3
+ Add **multi-platform restreaming** to your app. Your streamers connect their
4
+ Twitch / Kick / YouTube accounts once, and you fan an **already-live stream** out
5
+ to all of them — with **live chat and viewer counts** aggregated back into your UI.
6
+
7
+ ```
8
+ Your existing live stream ──► Twitch
9
+ (one session) ──► Kick + merged chat & viewer counts back to your UI
10
+ ──► YouTube
11
+ ```
12
+
13
+ - ⚡️ **Zero backend.** Drop in a publishable key and go. (An optional one-route token mode adds hard per-user security recommended for production.)
14
+ - 🎛 **Headless.** React hooks only — you build the UI with your own components.
15
+ - 🔌 **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**.
16
+ - 🔑 **No OAuth setup.** Connecting social accounts is fully hosted — your streamers click "Connect Twitch"; you register nothing on any platform and configure no webhooks.
17
+
18
+ > **Mental model.** You're already streaming a session (e.g. via Cloudflare
19
+ > Realtime). This SDK is the thin layer that lets a streamer **link their social
20
+ > accounts** and **mirror that session** to them, controlled entirely from your
21
+ > frontend. The heavy lifting (OAuth, tokens, transcoding, RTMP fan-out, chat
22
+ > collection) lives server-side.
15
23
 
16
24
  ---
17
25
 
18
26
  ## Table of contents
19
27
  - [Install](#install)
20
- - [Prerequisites](#prerequisites)
21
- - [Quickstart (zero backend)](#quickstart-zero-backend)
22
- - [How it fits together](#how-it-fits-together)
28
+ - [The integration contract (read this first)](#the-integration-contract-read-this-first)
29
+ - [What you get from your operator](#what-you-get-from-your-operator)
30
+ - [Quickstart](#quickstart-zero-backend)
31
+ - [The full streamer lifecycle](#the-full-streamer-lifecycle)
32
+ - [Where `publisherSessionId` comes from](#where-publishersessionid-comes-from)
33
+ - [The `userId` contract & persistence](#the-userid-contract--persistence)
23
34
  - [Connecting accounts](#connecting-accounts)
24
35
  - [Going live](#going-live)
25
36
  - [Live chat & viewers](#live-chat--viewers)
26
37
  - [Banners / overlays](#banners--overlays)
27
- - [Hardened mode (optional backend)](#hardened-mode-optional-one-route-backend)
38
+ - [Auth modes: publishable key vs. token](#auth-modes-publishable-key-vs-token)
39
+ - [Output resolution & quality](#output-resolution--quality)
28
40
  - [API reference](#api-reference)
41
+ - [Data shapes](#data-shapes)
29
42
  - [Scopes](#scopes)
30
- - [Framework notes (Next.js / SSR)](#framework-notes)
43
+ - [Next.js / SSR notes](#nextjs--ssr-notes)
44
+ - [Error handling](#error-handling)
31
45
  - [Troubleshooting](#troubleshooting)
46
+ - [Integration checklist](#integration-checklist)
32
47
 
33
48
  ---
34
49
 
@@ -36,23 +51,40 @@ all of them — with **live chat and viewer counts** aggregated back into your U
36
51
 
37
52
  ```bash
38
53
  npm install restream-sdk
39
- # peer dependency: react >= 17
54
+ # peer dependency: react >= 17 (optional — a framework-agnostic core ships too)
40
55
  ```
41
56
 
42
- It's a normal dependency bundled into your app at build time. Your end users
43
- install nothing.
57
+ It's a normal build-time dependency bundled into your app. **Your end users install nothing.**
44
58
 
45
- ## Prerequisites
59
+ ---
46
60
 
47
- From whoever operates the Restream Service, you need:
61
+ ## The integration contract (read this first)
48
62
 
49
- | Thing | What it is |
50
- |---|---|
51
- | **API base URL** | e.g. `https://api.restream.example` where the service runs. |
52
- | **Publishable key** | `pk_live_…` created in the operator's **Admin → Clients → Keys**, scoped + locked to your app's origin(s). Safe to ship in your frontend. |
53
- | **A `publisherSessionId`** | Comes from **your existing stream session** (see [below](#where-does-publishersessionid-come-from)). |
63
+ To go from zero to live you provide exactly **four** things. Everything else is hosted.
64
+
65
+ | # | You provide | Where it comes from | Notes |
66
+ |---|---|---|---|
67
+ | 1 | **`apiBase`** | Your operator | e.g. `https://restream-api.fomo.gg` |
68
+ | 2 | **A credential** | Your operator | A `publishableKey` (`pk_live_…`) for zero-backend, **or** a `tokenEndpoint` for [hardened mode](#auth-modes-publishable-key-vs-token) |
69
+ | 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](#the-userid-contract--persistence). |
70
+ | 4 | **A `publisherSessionId`** | **Your existing stream** | The live media session you already produce, passed into `goLive()`. See [below](#where-publishersessionid-comes-from). |
71
+
72
+ You do **not** register OAuth apps, host OAuth callbacks, configure platform
73
+ webhooks, or handle any platform tokens. That's all server-side.
74
+
75
+ ---
76
+
77
+ ## What you get from your operator
78
+
79
+ Ask whoever runs the Restream Service for:
54
80
 
55
- You do **not** register any OAuth apps or configure any platform webhooks that's hosted.
81
+ 1. **API base URL**e.g. `https://restream-api.fomo.gg`.
82
+ 2. **A publishable key** — `pk_live_…`, created in **Admin → Clients → Keys**, with:
83
+ - **Scopes:** `connect`, `restream`, `read`, `chat:write` (see [Scopes](#scopes)).
84
+ - **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.
85
+ 3. *(Production)* The **secret API key** — server-side only, for [token mode](#auth-modes-publishable-key-vs-token). **Never** put this in the browser.
86
+
87
+ ---
56
88
 
57
89
  ## Quickstart (zero backend)
58
90
 
@@ -60,36 +92,36 @@ You do **not** register any OAuth apps or configure any platform webhooks — th
60
92
  "use client";
61
93
  import { useRestream } from "restream-sdk";
62
94
 
63
- export default function Studio({ streamerId, publisherSessionId }) {
95
+ export default function RestreamPanel({ streamerId, publisherSessionId }) {
64
96
  const {
65
97
  connections, connect, disconnect,
66
98
  goLive, stopLive, job,
67
99
  viewers, comments, sendChat,
68
100
  } = useRestream({
69
- apiBase: "https://api.restream.example",
101
+ apiBase: "https://restream-api.fomo.gg",
70
102
  publishableKey: "pk_live_xxx",
71
- userId: streamerId, // your own user id for this streamer
103
+ userId: streamerId, // your stable id for this streamer
72
104
  });
73
105
 
74
- const connected = (p) => connections.find((c) => c.platform === p);
106
+ const isConnected = (p) => connections.some((c) => c.platform === p);
75
107
 
76
108
  return (
77
109
  <div>
78
- {/* 1 — connect socials (hosted OAuth popup) */}
110
+ {/* 1 — link social accounts (hosted OAuth popup) */}
79
111
  {["twitch", "kick"].map((p) =>
80
- connected(p)
112
+ isConnected(p)
81
113
  ? <button key={p} onClick={() => disconnect(p)}>Disconnect {p}</button>
82
114
  : <button key={p} onClick={() => connect(p)}>Connect {p}</button>
83
115
  )}
84
116
 
85
- {/* 2 — start / stop restreaming your existing session */}
117
+ {/* 2 — mirror your existing session to the connected platforms */}
86
118
  {!job
87
119
  ? <button onClick={() => goLive({ publisherSessionId, destinations: ["twitch", "kick"] })}>
88
120
  Go live
89
121
  </button>
90
122
  : <button onClick={stopLive}>Stop ({job.status})</button>}
91
123
 
92
- {/* 3 — live stats + chat (both directions) */}
124
+ {/* 3 — merged viewers + chat (both directions) */}
93
125
  <p>Viewers: {viewers?.total ?? 0}</p>
94
126
  <ul>{comments.map((c) => <li key={c.id}><b>{c.platform}/{c.author}:</b> {c.message}</li>)}</ul>
95
127
  <button onClick={() => sendChat("hello everyone 👋")}>Say hi to all chats</button>
@@ -98,73 +130,210 @@ export default function Studio({ streamerId, publisherSessionId }) {
98
130
  }
99
131
  ```
100
132
 
101
- ## How it fits together
133
+ ---
134
+
135
+ ## The full streamer lifecycle
136
+
137
+ A complete, production-shaped component covering the whole journey — link accounts,
138
+ go live with your session, watch links, viewers, two-way chat, stop:
139
+
140
+ ```jsx
141
+ "use client";
142
+ import { useRestream } from "restream-sdk";
143
+ import { useState } from "react";
144
+
145
+ const PLATFORMS = ["twitch", "kick"];
146
+
147
+ export default function Studio({ streamerId, getPublisherSessionId }) {
148
+ const r = useRestream({
149
+ apiBase: "https://restream-api.fomo.gg",
150
+ publishableKey: process.env.NEXT_PUBLIC_RESTREAM_PK,
151
+ userId: streamerId, // stable id from your user table
152
+ });
153
+ const [msg, setMsg] = useState("");
154
+ const isConnected = (p) => r.connections.some((c) => c.platform === p);
155
+ const live = r.job && !["stopped", "failed", "ended"].includes(r.job.status);
156
+
157
+ async function start() {
158
+ // Pull the session id from YOUR existing stream pipeline at go-live time.
159
+ const publisherSessionId = await getPublisherSessionId();
160
+ await r.goLive({ publisherSessionId, destinations: PLATFORMS.filter(isConnected) });
161
+ }
102
162
 
163
+ return (
164
+ <section>
165
+ {/* link accounts — persists across refreshes (keyed by userId) */}
166
+ <div>
167
+ {PLATFORMS.map((p) => (
168
+ <button key={p} onClick={() => isConnected(p) ? r.disconnect(p) : r.connect(p)}>
169
+ {isConnected(p) ? `✓ ${p}` : `Connect ${p}`}
170
+ </button>
171
+ ))}
172
+ </div>
173
+
174
+ {/* go live / stop */}
175
+ {!live
176
+ ? <button disabled={!PLATFORMS.some(isConnected)} onClick={start}>Go live</button>
177
+ : <button onClick={r.stopLive}>Stop stream</button>}
178
+ {r.job && <span> status: {r.job.status}</span>}
179
+
180
+ {/* watch links (available once broadcasts are created) */}
181
+ {(r.job?.broadcasts || []).map((b) => b.watchUrl &&
182
+ <a key={b.platform} href={b.watchUrl} target="_blank" rel="noreferrer">Watch on {b.platform} ↗</a>)}
183
+
184
+ {/* live stats + merged chat */}
185
+ {live && (
186
+ <>
187
+ <p>Viewers: {r.viewers?.total ?? 0}
188
+ {r.viewers?.platforms && Object.entries(r.viewers.platforms).map(([p, n]) => ` · ${p}: ${n}`)}</p>
189
+ <ul>{r.comments.map((c) => <li key={c.id}><b>{c.platform}/{c.author}:</b> {c.message}</li>)}</ul>
190
+ <form onSubmit={(e) => { e.preventDefault(); r.sendChat(msg); setMsg(""); }}>
191
+ <input value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="message all chats" />
192
+ <button>Send</button>
193
+ </form>
194
+ </>
195
+ )}
196
+
197
+ {r.error && <p role="alert">{r.error.message}</p>}
198
+ </section>
199
+ );
200
+ }
103
201
  ```
104
- Your existing stream ──(produces a session)──► publisherSessionId
105
- you pass it to goLive()
106
- Your app + restream-sdk ────────────────────────►│ Restream Service API
107
- (publishable key in the browser) ├─ hosted OAuth (you register nothing)
108
- ├─ start/stop restream
109
- ├─ viewers · comments (SSE) · send chat
110
- └─ fans your stream out to Twitch/Kick/…
202
+
203
+ > Note `getPublisherSessionId()` — you supply the session id from **your** stream
204
+ > at the moment you go live. The SDK never creates it.
205
+
206
+ ---
207
+
208
+ ## Where `publisherSessionId` comes from
209
+
210
+ This is the one piece that ties into *your* infrastructure, so it's worth being precise.
211
+
212
+ **The `publisherSessionId` is the live media session your platform already
213
+ produces for the streamer.** If your platform publishes streamer media to
214
+ **Cloudflare Realtime (Calls)** — the typical setup — the **session id** Cloudflare
215
+ returns when the streamer's broadcast starts **is** the `publisherSessionId`. You
216
+ already have it; you just pass it in:
217
+
218
+ ```js
219
+ // when your streamer goes live on YOUR platform, you already create a session:
220
+ const publisherSessionId = "<the Cloudflare Calls session id for this broadcast>";
221
+
222
+ await goLive({ publisherSessionId, destinations: ["twitch", "kick"] });
111
223
  ```
112
224
 
113
- The browser talks to the service directly with the **publishable key** (origin‑locked
114
- + scoped). OAuth secrets, platform tokens, and the service's master API key never
115
- touch the browser.
225
+ Requirements:
226
+ - The session must be **live and actively sending media** at the moment you call `goLive()`. The worker pulls from it immediately.
227
+ - 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:
228
+
229
+ ```js
230
+ goLive({ publisherSessionId, destinations: ["twitch"], trackNames: ["video"] });
231
+ ```
232
+
233
+ The SDK is a control plane: it **does not** open a camera, capture a screen, or
234
+ publish RTMP/WebRTC. Producing the session stays entirely on your side, exactly as
235
+ it works today — the only change is that **your frontend** now makes the `goLive()`
236
+ call (instead of nothing happening after the session starts).
237
+
238
+ ---
239
+
240
+ ## The `userId` contract & persistence
241
+
242
+ `userId` is the streamer's id **in your system** (e.g. your DB primary key). It is
243
+ the join key for everything server-side, so:
244
+
245
+ - ✅ **Stable** — use the same value for a given streamer **forever**. Don't use a
246
+ random/session value.
247
+ - ✅ **Unique per streamer** — one streamer = one `userId`.
248
+ - ✅ **Opaque is fine** — `"user_8412"`, a UUID, etc. Avoid PII.
249
+
250
+ **Why it matters — persistence is automatic and server-side:**
251
+
252
+ - When a streamer connects Twitch/Kick, the link is stored **server-side**, keyed
253
+ by `(yourClient, userId, platform)`. Nothing about it lives in the browser.
254
+ - On mount (including **after a page refresh, in a new tab, or on another device**),
255
+ the SDK auto-calls `listConnections()` and your `connections` state repopulates —
256
+ the streamer sees "✓ Twitch" again with **no re-auth**.
257
+ - Platform tokens are **auto-refreshed** server-side, so connections stay valid
258
+ long-term. A streamer only needs to reconnect if they revoke access on the
259
+ platform itself.
116
260
 
117
- ### Where does `publisherSessionId` come from?
261
+ > If you pass a *different* `userId` on the next load, you'll get a different
262
+ > (empty) connection set. A stable `userId` is the whole persistence story.
118
263
 
119
- From **your existing streaming setup** whatever already produces your live session
120
- hands you that id, and you pass it straight into `goLive()`. The module is a control
121
- plane; it doesn't capture camera or publish media. (If you don't have a session
122
- pipeline yet, ask the operator for the recommended way to produce one.)
264
+ In [token mode](#auth-modes-publishable-key-vs-token) the `userId` is baked into
265
+ the token your backend mints so it's inherently stable per logged-in user and the
266
+ browser can't stream as anyone else.
267
+
268
+ ---
123
269
 
124
270
  ## Connecting accounts
125
271
 
126
272
  ```js
127
- await connect("twitch"); // opens a hosted OAuth popup, resolves when connected
273
+ await connect("twitch"); // opens a hosted OAuth popup, resolves when linked
128
274
  await disconnect("kick");
129
- connections; // [{ platform, platformUsername, ... }] (tokens are never exposed)
275
+ connections; // [{ platform, platformUsername, ... }]tokens never exposed
130
276
  ```
131
277
 
132
- `connect(platform)` opens a popup to the service's hosted OAuth (using the
133
- operator's shared platform apps) and resolves when the account is linked. **Allow
134
- popups** for your origin. Supported: `twitch`, `kick`, `youtube`, `facebook`
135
- (availability depends on what the operator has enabled).
278
+ - `connect(platform)` opens a popup to the service's **hosted OAuth** (using the
279
+ operator's shared platform apps) and resolves on success. **Allow popups** for
280
+ your origin.
281
+ - Supported: `twitch`, `kick`, `youtube`, `facebook`, `instagram` — availability
282
+ depends on what the operator has enabled.
283
+ - Connections **persist** (see [above](#the-userid-contract--persistence)); you
284
+ don't store anything yourself.
285
+
286
+ ---
136
287
 
137
288
  ## Going live
138
289
 
139
290
  ```js
140
291
  const job = await goLive({
141
- publisherSessionId, // required — your existing session
142
- destinations: ["twitch", "kick"], // which connected platforms to fan out to
292
+ publisherSessionId, // REQUIRED — your existing live session
293
+ destinations: ["twitch", "kick"], // which connected platforms to fan out to
143
294
  overlay: { htmlBanner: "https://cdn.you.com/banner.html", enabled: true }, // optional
144
- recording: false, // optional
295
+ recording: false, // optional
296
+ trackNames: ["audio", "video"], // optional — match what your session publishes
145
297
  });
146
- // ...
298
+
147
299
  await stopLive();
148
300
  ```
149
301
 
302
+ - **One encode → many destinations.** Restreaming to 2 platforms or 5 costs the
303
+ same on the source; it's a single encode fanned out.
150
304
  - **One live stream per streamer.** Calling `goLive()` again for the same `userId`
151
- supersedes the previous job (the new one takes over).
152
- - **`arm({ destinations })`** lets you pre-select destinations so you can fire
153
- `goLive` automatically the moment your session starts.
305
+ **supersedes** the previous job (the new one takes over). This prevents duplicate
306
+ streams if a streamer double-clicks or reloads mid-stream.
307
+ - **Watch links.** Once broadcasts are created, `job.broadcasts` carries
308
+ `{ platform, watchUrl }` for each destination — handy for "Watch on Twitch ↗".
309
+ - **`arm({ destinations })`** pre-selects destinations so you can fire `goLive`
310
+ (with just the `publisherSessionId`) the instant your session starts — useful for
311
+ auto-start when a streamer goes live on your platform.
312
+
313
+ ---
154
314
 
155
315
  ## Live chat & viewers
156
316
 
157
317
  ```js
158
- viewers; // { total, platforms: { twitch: n, kick: n } } (polled)
159
- comments; // merged incoming chat from all destinations (live via SSE)
160
- await sendChat("gg!"); // fan a message OUT to every connected chat
161
- await sendChat("ty", ["kick"]); // …or just some platforms
318
+ viewers; // { total, platforms: { twitch: 12, kick: 5 } } polled ~10s
319
+ comments; // merged incoming chat from all destinations (live via SSE)
320
+ await sendChat("gg!"); // fan a message OUT to every connected chat
321
+ await sendChat("ty", ["kick"]); // …or only some platforms
162
322
  ```
163
323
 
324
+ - `comments` is the **merged inbound** feed across all destinations, delivered over
325
+ SSE and **de-duplicated** (history replays safely on reconnect). It's capped to
326
+ the most recent 200 messages.
327
+ - Each comment: `{ id, platform, author, authorAvatar, message, timestamp }`.
328
+ - `sendChat` is **outbound fan-out** — one message posted to every (or selected)
329
+ connected chat. Requires the `chat:write` scope.
330
+
331
+ ---
332
+
164
333
  ## Banners / overlays
165
334
 
166
335
  Pass an `overlay` to `goLive` to composite a banner onto the outgoing stream
167
- (rendered + burned in server-side, so it shows on every destination):
336
+ (rendered + burned in server-side, so it shows on **every** destination):
168
337
 
169
338
  ```js
170
339
  goLive({
@@ -174,57 +343,84 @@ goLive({
174
343
  });
175
344
  ```
176
345
 
177
- - `htmlBanner` — a URL to a **transparent HTML5 banner**. Your banner can run its
178
- own JS/data feed (scoreboards, tickers, live stats) the service just renders
179
- and composites it.
180
- - `topBanner` / `bottomBanner` — image (PNG) URLs for simple top/bottom bars.
181
- - Update PNG bars on a live job with `updateOverlay({ topBanner, bottomBanner, hideTop, hideBottom })`.
346
+ - `htmlBanner` — URL to a **transparent HTML5 page**. It can run its own JS/data
347
+ feed (scoreboards, tickers, live stats); the service renders it headlessly and
348
+ composites it full-frame every tick.
349
+ - `topBanner` / `bottomBanner` — transparent **PNG** URLs for simple top/bottom bars.
350
+ - Update PNG bars on a live job without restarting it:
351
+ ```js
352
+ await updateOverlay({ topBanner, bottomBanner, hideTop, hideBottom });
353
+ ```
182
354
 
183
- ## Hardened mode (optional, one‑route backend)
355
+ ---
356
+
357
+ ## Auth modes: publishable key vs. token
184
358
 
185
- The publishable key is origin‑locked and scoped, which is enough for most apps. If
186
- you don't want to trust the browser with *which* `userId` it streams as, mint a
187
- short‑lived token on your server (one tiny route) and point the module at it:
359
+ | | **Publishable key** (`pk_live_…`) | **Token** (`tokenEndpoint`) |
360
+ |---|---|---|
361
+ | Client backend | **None** | One small route |
362
+ | `userId` | Passed from the browser | **Bound server-side** in the token |
363
+ | Security | Origin-locked + scoped (good) | Hard per-user binding (best) |
364
+ | Best for | Quick start, demos, trusted UIs | **Production** — anywhere the browser shouldn't pick *which* user it streams as |
365
+
366
+ The publishable key is origin-locked and scoped, which is enough for many apps. For
367
+ production (and anywhere a user could tamper with a `userId`), mint a **short-lived
368
+ token** on your server with your **secret** API key and point the SDK at it:
188
369
 
189
370
  ```js
190
- // your backend — call us with your SECRET api key (never sent to the browser)
191
- app.get("/api/restream-token", async (req, res) => {
192
- const r = await fetch("https://api.restream.example/api/v1/auth/session", {
371
+ // YOUR backend — one route. The secret key never reaches the browser.
372
+ app.get("/api/restream-token", requireAuth, async (req, res) => {
373
+ const r = await fetch("https://restream-api.fomo.gg/api/v1/auth/session", {
193
374
  method: "POST",
194
375
  headers: { "X-API-Key": process.env.RESTREAM_API_KEY, "Content-Type": "application/json" },
195
- body: JSON.stringify({ userId: req.user.id }), // bound server-side
376
+ body: JSON.stringify({ userId: req.user.id }), // bound to the logged-in user, server-side
196
377
  });
197
- res.json(await r.json()); // { token, expiresIn }
378
+ res.json(await r.json()); // { token, expiresIn }
198
379
  });
199
380
  ```
200
381
 
201
382
  ```jsx
202
- useRestream({ apiBase: "https://api.restream.example", tokenEndpoint: "/api/restream-token" });
203
- // no userId/publishableKey in the browser — the user is bound by the token.
383
+ // browser — no publishableKey, no userId; the token carries (and locks) the user.
384
+ useRestream({ apiBase: "https://restream-api.fomo.gg", tokenEndpoint: "/api/restream-token" });
204
385
  ```
205
386
 
387
+ The SDK fetches the token, attaches it to every request, and **transparently
388
+ re-mints** it when it expires (including reopening the chat stream).
389
+
390
+ ---
391
+
392
+ ## Output resolution & quality
393
+
394
+ The restream output **matches the resolution of your publisher session** (up to
395
+ **1080p**). Bitrate, H.264 profile, and encoding are handled server-side — you don't
396
+ configure an encoder. To deliver 1080p, publish your session at 1080p; a 720p
397
+ session restreams at 720p. Adding more destinations does **not** reduce quality
398
+ (single encode, fanned out).
399
+
400
+ ---
401
+
206
402
  ## API reference
207
403
 
208
404
  ### `useRestream(options) → state + actions`
209
405
 
210
406
  **Options:** `{ apiBase, publishableKey?, tokenEndpoint?, userId?, timeoutMs? }`
211
- (provide either `publishableKey`+`userId`, or `tokenEndpoint`.) `job.status` is
212
- polled live (`queued running stopped`); incoming chat is de-duplicated.
213
- See [ROADMAP.md](./ROADMAP.md) for planned additions.
407
+ provide either `publishableKey` (+ `userId`) **or** `tokenEndpoint`. `timeoutMs`
408
+ defaults to `15000`. On mount it auto-loads connections; `job.status` is polled live
409
+ (`queued → running → stopped`); incoming chat is de-duplicated.
214
410
 
215
411
  **Returns:**
216
412
 
217
413
  | Field | Type | Notes |
218
414
  |---|---|---|
219
- | `connections` | `Connection[]` | linked accounts (tokens redacted) |
220
- | `job` | `Job \| null` | current restream job |
415
+ | `connections` | `Connection[]` | linked accounts (tokens redacted); auto-loaded on mount |
416
+ | `job` | `Job \| null` | current restream job (status polled) |
221
417
  | `viewers` | `{ total, platforms }` | polled viewer counts |
222
- | `comments` | `Comment[]` | live merged chat (SSE) |
223
- | `error` | `Error \| null` | last error |
418
+ | `comments` | `Comment[]` | live merged inbound chat (SSE), latest 200 |
419
+ | `error` | `Error \| null` | last error (`err.status` carries the HTTP code) |
224
420
  | `connect(platform)` | `Promise` | hosted OAuth popup |
225
421
  | `disconnect(platform)` | `Promise` | |
226
- | `listConnections()` | `Promise<Connection[]>` | |
227
- | `arm({ destinations })` | — | pre-select destinations |
422
+ | `listConnections()` | `Promise<Connection[]>` | manual refresh |
423
+ | `arm({ destinations })` | — | pre-select destinations for `goLive` |
228
424
  | `goLive(params)` | `Promise<Job>` | see [Going live](#going-live) |
229
425
  | `stopLive()` | `Promise` | |
230
426
  | `refreshJob()` | `Promise<Job>` | re-fetch job status |
@@ -233,52 +429,129 @@ See [ROADMAP.md](./ROADMAP.md) for planned additions.
233
429
  | `studio` | `RestreamStudio` | the underlying controller |
234
430
 
235
431
  ### Convenience hooks
236
- `useConnections(options)`, `useLiveChat(studio)`, `useViewers(studio)`.
432
+ `useConnections(options)` `{ connections, connect, disconnect, refresh }`,
433
+ `useLiveChat(studio)` → `{ comments, send }`,
434
+ `useViewers(studio)` → `{ viewers }`.
237
435
 
238
436
  ### Framework-agnostic core
239
- Don't use React? Import the controller directly:
437
+ Not using React? Import the controller directly:
240
438
 
241
439
  ```js
242
440
  import { RestreamStudio } from "restream-sdk/core";
441
+
243
442
  const studio = new RestreamStudio({ apiBase, publishableKey, userId });
244
- studio.on("comments", (c) => );
443
+ const off = studio.on("comments", (list) => render(list)); // also: connections, job, viewers, comment, error
444
+ await studio.listConnections();
245
445
  await studio.connect("twitch");
246
446
  await studio.goLive({ publisherSessionId, destinations: ["twitch"] });
447
+ // ... studio.destroy() to tear down timers/streams
448
+ ```
449
+
450
+ TypeScript types ship with the package (`restream-sdk` and `restream-sdk/core`).
451
+
452
+ ---
453
+
454
+ ## Data shapes
455
+
456
+ ```ts
457
+ type Platform = "twitch" | "kick" | "youtube" | "facebook" | "instagram";
458
+
459
+ interface Connection {
460
+ platform: Platform;
461
+ platformUsername: string; // display name on the platform
462
+ platformUserId?: string;
463
+ }
464
+
465
+ interface Job {
466
+ id: string;
467
+ status: "queued" | "running" | "stopping" | "stopped" | "failed" | "ended";
468
+ broadcasts?: { platform: Platform; watchUrl?: string; broadcastId?: string }[];
469
+ }
470
+
471
+ interface Comment {
472
+ id: string;
473
+ platform: Platform;
474
+ author: string;
475
+ authorAvatar?: string | null;
476
+ message: string;
477
+ timestamp: number;
478
+ }
479
+
480
+ interface Viewers {
481
+ total: number;
482
+ platforms: Record<Platform, number>; // e.g. { twitch: 12, kick: 5 }
483
+ }
247
484
  ```
248
485
 
249
- TypeScript types ship with the package.
486
+ ---
250
487
 
251
488
  ## Scopes
252
489
 
253
- A publishable key (or token) carries scopes; the operator sets these when creating it:
490
+ A publishable key (or token) carries scopes; the operator sets them at creation:
254
491
 
255
492
  | Scope | Allows |
256
493
  |---|---|
257
- | `connect` | manage social connections |
494
+ | `connect` | manage social connections (connect / list / disconnect) |
258
495
  | `restream` | start/stop a job + overlay updates |
259
496
  | `read` | job status, viewers, comments |
260
497
  | `chat:write` | send chat |
261
498
 
262
- A 403 with *"Missing required scope"* means your key needs that scope added.
499
+ A `403 "Missing required scope"` means your key needs that scope added.
263
500
 
264
- ## Framework notes
501
+ ---
265
502
 
266
- - **React 17+** (hooks). The package is ESM and side-effect-free (tree-shakeable).
267
- - **Next.js / SSR:** use the hooks in a Client Component (`"use client"`). `connect()`
268
- uses popups + `window`, so it runs in the browser only.
269
- - The controller uses `fetch`, `EventSource`, and `window.open` all standard browser APIs.
503
+ ## Next.js / SSR notes
504
+
505
+ - **Use the hooks in a Client Component** (`"use client"`). `connect()` uses popups
506
+ and `window`, so it runs in the browser only.
507
+ - Put the publishable key in a **`NEXT_PUBLIC_`** env var (it's safe — origin-locked).
508
+ The **secret** API key (token mode) stays server-side only.
509
+ - The package is **ESM** and side-effect-free (tree-shakeable). It uses `fetch`,
510
+ `EventSource`, and `window.open` — all standard browser APIs.
511
+
512
+ ---
513
+
514
+ ## Error handling
515
+
516
+ - Every action rejects on failure; the hook also surfaces the latest error as
517
+ `error`. Errors carry `err.status` (the HTTP status, or `0` for network/timeout):
518
+
519
+ ```js
520
+ try { await goLive({ publisherSessionId, destinations }); }
521
+ catch (e) { if (e.status === 403) showUpgrade(); else toast(e.message); }
522
+ ```
523
+ - Common statuses: `401` (bad/expired credential — token mode re-mints automatically),
524
+ `403` (origin not allowed, or missing scope), `409`/supersede (a newer stream took
525
+ over), `0` (network/timeout).
526
+ - Realtime is resilient: transient SSE drops reconnect automatically and chat
527
+ history de-dupes on replay; job-status polling retries on transient failures.
528
+
529
+ ---
270
530
 
271
531
  ## Troubleshooting
272
532
 
273
533
  | Symptom | Cause / fix |
274
534
  |---|---|
275
- | `connect()` does nothing | Popup blocked — allow popups for your origin. |
535
+ | `connect()` does nothing | Popup blocked — allow popups for your origin (call it from a click handler). |
276
536
  | `Origin not allowed for this key` (403) | Your app's origin isn't on the key's allowlist — ask the operator to add it. |
277
- | `failed to fetch` on calls | Origin/key mismatch, or wrong `apiBase`. |
278
- | Job goes `running` but nothing on the platforms | The `publisherSessionId` must be a **live** session that's actively sending media when you `goLive`. |
537
+ | `failed to fetch` / network error | Wrong `apiBase`, or the origin/key mismatch blocked CORS. |
538
+ | Connections empty after refresh | You passed a **different `userId`** it must be stable per streamer. |
539
+ | Job goes `running` but nothing on the platforms | The `publisherSessionId` must be a **live** session actively sending media when you `goLive()`. |
540
+ | Worker seems to wait / no audio | Your session has no audio track — pass `trackNames: ["video"]`. |
279
541
  | Starting a 2nd stream stops the first | Expected — **one live stream per streamer**; a new `goLive` supersedes the old. |
280
542
  | `Missing required scope` (403) | Key needs that scope (`connect` / `restream` / `read` / `chat:write`). |
281
543
 
282
544
  ---
283
545
 
284
- Questions or a key request? Contact your Restream Service operator.
546
+ ## Integration checklist
547
+
548
+ - [ ] Operator created a **publishable key** scoped `connect, restream, read, chat:write` and allowlisted your origin(s).
549
+ - [ ] You pass a **stable, unique `userId`** per streamer.
550
+ - [ ] You can supply a **live `publisherSessionId`** from your existing stream at go-live time (with `trackNames` if audio-less).
551
+ - [ ] Popups are allowed for your origin (for `connect`).
552
+ - [ ] *(Production)* You stood up the **token endpoint** and switched the SDK to `tokenEndpoint` so `userId` is server-bound.
553
+ - [ ] You render `connections`, `job.status`, `job.broadcasts` watch links, `viewers`, and `comments`.
554
+
555
+ ---
556
+
557
+ Questions, a key request, or an origin to allowlist? Contact your Restream Service operator.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "restream-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Plug-and-play headless React hooks to add multi-platform restreaming (connect socials, go live, live chat + viewers) with zero client backend.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -16,7 +16,12 @@
16
16
  "import": "./src/core.js"
17
17
  }
18
18
  },
19
- "files": ["src", "types", "README.md", "LICENSE"],
19
+ "files": [
20
+ "src",
21
+ "types",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
20
25
  "sideEffects": false,
21
26
  "engines": {
22
27
  "node": ">=18"
@@ -25,7 +30,9 @@
25
30
  "react": ">=17"
26
31
  },
27
32
  "peerDependenciesMeta": {
28
- "react": { "optional": true }
33
+ "react": {
34
+ "optional": true
35
+ }
29
36
  },
30
37
  "repository": {
31
38
  "type": "git",
@@ -38,5 +45,14 @@
38
45
  },
39
46
  "author": "Ravant Technologies FZCO",
40
47
  "license": "MIT",
41
- "keywords": ["restream", "streaming", "twitch", "kick", "youtube", "multistream", "react", "sdk"]
42
- }
48
+ "keywords": [
49
+ "restream",
50
+ "streaming",
51
+ "twitch",
52
+ "kick",
53
+ "youtube",
54
+ "multistream",
55
+ "react",
56
+ "sdk"
57
+ ]
58
+ }
package/src/core.js CHANGED
@@ -39,6 +39,8 @@ export class RestreamStudio extends EventTarget {
39
39
  this._pollTimer = null;
40
40
  this._sseReopen = null;
41
41
  this._armed = null;
42
+ this._watchPoll = null;
43
+ this._watchUserId = null;
42
44
  }
43
45
 
44
46
  _emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); }
@@ -267,5 +269,43 @@ export class RestreamStudio extends EventTarget {
267
269
  if (this._sseReopen) { clearTimeout(this._sseReopen); this._sseReopen = null; }
268
270
  }
269
271
 
270
- destroy() { this.stopRealtime(); }
272
+ // ---- viewer mode (read-only) ----
273
+ /**
274
+ * Watch a streamer's LIVE social chat + viewer counts, read-only (no goLive).
275
+ * Resolves their active job and streams `comment`/`viewers` events just like the
276
+ * broadcaster sees. If the stream isn't live yet (or ends and restarts), it keeps
277
+ * polling and (re)attaches automatically. Needs only the `read` scope.
278
+ * @param {string} userId The streamer's external user id (e.g. wallet).
279
+ * @param {object} [opts] { pollMs=5000 } — 0 disables the keep-trying poll.
280
+ * @returns {Promise<{id:string}|null>} the active job if one is live now.
281
+ */
282
+ async watch(userId, { pollMs = 5000 } = {}) {
283
+ this.unwatch();
284
+ this._watchUserId = userId;
285
+ const attach = async () => {
286
+ if (this._watchUserId !== userId) return;
287
+ if (this._es || this._pollTimer) return; // already attached to a live job
288
+ try {
289
+ const r = await this._req("GET", `/api/v1/jobs/active?userId=${encodeURIComponent(userId)}`);
290
+ if (r?.jobId && this._watchUserId === userId) {
291
+ this.job = { id: r.jobId, status: r.status };
292
+ this._emit("job", this.job);
293
+ this.startRealtime(r.jobId);
294
+ }
295
+ } catch { /* transient — retry next tick */ }
296
+ };
297
+ await attach();
298
+ if (pollMs > 0) this._watchPoll = setInterval(attach, pollMs);
299
+ return this.job;
300
+ }
301
+
302
+ /** Stop watching (clears the keep-trying poll + the realtime streams). */
303
+ unwatch() {
304
+ this._watchUserId = null;
305
+ if (this._watchPoll) { clearInterval(this._watchPoll); this._watchPoll = null; }
306
+ this.stopRealtime();
307
+ this.job = null;
308
+ }
309
+
310
+ destroy() { this.unwatch(); }
271
311
  }
package/src/react.js CHANGED
@@ -69,3 +69,29 @@ export function useViewers(studio) {
69
69
  useEffect(() => (studio ? studio.on("viewers", setViewers) : undefined), [studio]);
70
70
  return viewers;
71
71
  }
72
+
73
+ /**
74
+ * Read-only viewer mode — watch a streamer's live social chat + viewer counts.
75
+ * Any viewer (not just the broadcaster) can call this; needs only the `read` scope.
76
+ *
77
+ * @param {object} opts { apiBase, publishableKey } (no userId needed)
78
+ * @param {string} userId The streamer to watch.
79
+ */
80
+ export function useWatch(opts, userId) {
81
+ const { apiBase, publishableKey, tokenEndpoint } = opts || {};
82
+ const [comments, setComments] = useState([]);
83
+ const [viewers, setViewers] = useState(null);
84
+ const [job, setJob] = useState(null);
85
+ useEffect(() => {
86
+ if (!userId || !apiBase) return undefined;
87
+ const studio = new RestreamStudio({ apiBase, publishableKey, tokenEndpoint });
88
+ const offs = [
89
+ studio.on("comments", setComments),
90
+ studio.on("viewers", setViewers),
91
+ studio.on("job", setJob),
92
+ ];
93
+ studio.watch(userId);
94
+ return () => { offs.forEach((off) => off()); studio.destroy(); };
95
+ }, [apiBase, publishableKey, tokenEndpoint, userId]);
96
+ return { comments, viewers, job };
97
+ }
package/types/index.d.ts CHANGED
@@ -94,6 +94,10 @@ export class RestreamStudio extends EventTarget {
94
94
  updateOverlay(opts: OverlayUpdate): Promise<unknown>;
95
95
  startRealtime(jobId: string): Promise<void>;
96
96
  stopRealtime(): void;
97
+ /** Read-only viewer mode: stream a streamer's live social chat + viewer counts. */
98
+ watch(userId: string, opts?: { pollMs?: number }): Promise<{ id: string } | null>;
99
+ /** Stop watching (clears the poll + realtime streams). */
100
+ unwatch(): void;
97
101
  destroy(): void;
98
102
  }
99
103
 
@@ -124,3 +128,7 @@ export function useConnections(opts: RestreamOptions): {
124
128
  };
125
129
  export function useLiveChat(studio: RestreamStudio): { comments: Comment[]; send(message: string, platforms?: Platform[]): Promise<unknown> };
126
130
  export function useViewers(studio: RestreamStudio): ViewerCounts | null;
131
+ export function useWatch(
132
+ opts: { apiBase: string; publishableKey?: string; tokenEndpoint?: string },
133
+ userId: string
134
+ ): { comments: Comment[]; viewers: ViewerCounts | null; job: Job | null };