restream-sdk 0.1.0 → 0.1.1
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 +384 -111
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,34 +1,49 @@
|
|
|
1
1
|
# restream-sdk
|
|
2
2
|
|
|
3
|
-
Add **multi-platform restreaming** to your app
|
|
4
|
-
|
|
5
|
-
all of them — with **live chat and viewer counts** aggregated back into your UI.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
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
|
-
- [
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
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
|
-
- [
|
|
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
|
-
- [
|
|
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
|
|
43
|
-
install nothing.
|
|
57
|
+
It's a normal build-time dependency bundled into your app. **Your end users install nothing.**
|
|
44
58
|
|
|
45
|
-
|
|
59
|
+
---
|
|
46
60
|
|
|
47
|
-
|
|
61
|
+
## The integration contract (read this first)
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
|
52
|
-
|
|
53
|
-
|
|
|
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
|
-
|
|
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
|
|
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.
|
|
101
|
+
apiBase: "https://restream-api.fomo.gg",
|
|
70
102
|
publishableKey: "pk_live_xxx",
|
|
71
|
-
userId: streamerId,
|
|
103
|
+
userId: streamerId, // ← your stable id for this streamer
|
|
72
104
|
});
|
|
73
105
|
|
|
74
|
-
const
|
|
106
|
+
const isConnected = (p) => connections.some((c) => c.platform === p);
|
|
75
107
|
|
|
76
108
|
return (
|
|
77
109
|
<div>
|
|
78
|
-
{/* 1 —
|
|
110
|
+
{/* 1 — link social accounts (hosted OAuth popup) */}
|
|
79
111
|
{["twitch", "kick"].map((p) =>
|
|
80
|
-
|
|
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 —
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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");
|
|
273
|
+
await connect("twitch"); // opens a hosted OAuth popup, resolves when linked
|
|
128
274
|
await disconnect("kick");
|
|
129
|
-
connections;
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
142
|
-
destinations: ["twitch", "kick"],
|
|
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,
|
|
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
|
-
|
|
153
|
-
|
|
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;
|
|
159
|
-
comments;
|
|
160
|
-
await sendChat("gg!");
|
|
161
|
-
await sendChat("ty", ["kick"]);
|
|
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` —
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
- `topBanner` / `bottomBanner` —
|
|
181
|
-
- Update PNG bars on a live job
|
|
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
|
-
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Auth modes: publishable key vs. token
|
|
184
358
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
//
|
|
191
|
-
app.get("/api/restream-token", async (req, res) => {
|
|
192
|
-
const r = await fetch("https://api.
|
|
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 }),
|
|
376
|
+
body: JSON.stringify({ userId: req.user.id }), // ← bound to the logged-in user, server-side
|
|
196
377
|
});
|
|
197
|
-
res.json(await r.json());
|
|
378
|
+
res.json(await r.json()); // { token, expiresIn }
|
|
198
379
|
});
|
|
199
380
|
```
|
|
200
381
|
|
|
201
382
|
```jsx
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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)
|
|
432
|
+
`useConnections(options)` → `{ connections, connect, disconnect, refresh }`,
|
|
433
|
+
`useLiveChat(studio)` → `{ comments, send }`,
|
|
434
|
+
`useViewers(studio)` → `{ viewers }`.
|
|
237
435
|
|
|
238
436
|
### Framework-agnostic core
|
|
239
|
-
|
|
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", (
|
|
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
|
-
|
|
486
|
+
---
|
|
250
487
|
|
|
251
488
|
## Scopes
|
|
252
489
|
|
|
253
|
-
A publishable key (or token) carries scopes; the operator sets
|
|
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
|
|
499
|
+
A `403 "Missing required scope"` means your key needs that scope added.
|
|
263
500
|
|
|
264
|
-
|
|
501
|
+
---
|
|
265
502
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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`
|
|
278
|
-
|
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|