restream-sdk 0.8.0 → 0.9.0

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
@@ -33,6 +33,8 @@ Your existing live stream ──► Twitch
33
33
  - [The `userId` contract & persistence](#the-userid-contract--persistence)
34
34
  - [Connecting accounts](#connecting-accounts)
35
35
  - [Going live](#going-live)
36
+ - [Screen share & live layout](#screen-share--live-layout)
37
+ - [Collaboration (split-screen)](#collaboration-split-screen)
36
38
  - [Live chat & viewers](#live-chat--viewers)
37
39
  - [Banners / overlays](#banners--overlays)
38
40
  - [Auth modes: publishable key vs. token](#auth-modes-publishable-key-vs-token)
@@ -72,6 +74,13 @@ To go from zero to live you provide exactly **four** things. Everything else is
72
74
  You do **not** register OAuth apps, host OAuth callbacks, configure platform
73
75
  webhooks, or handle any platform tokens. That's all server-side.
74
76
 
77
+ > **The golden rule: keep ONE publisher session for the whole stream.** Publish the
78
+ > camera once and reuse that `publisherSessionId`. **Never re-publish (a new session)
79
+ > or call `goLive()` again** on reconnect, screen toggle, or collaborator change — those
80
+ > are *live* changes: use `setStreamState()`, `startScreenShare()`, and `acceptCollab()`.
81
+ > `goLive()` **throws if a stream is already live**; call `stopLive()` to cleanly end one
82
+ > before starting another.
83
+
75
84
  ---
76
85
 
77
86
  ## What you get from your operator
@@ -301,9 +310,14 @@ await stopLive();
301
310
 
302
311
  - **One encode → many destinations.** Restreaming to 2 platforms or 5 costs the
303
312
  same on the source; it's a single encode fanned out.
304
- - **One live stream per streamer.** Calling `goLive()` again for the same `userId`
305
- **supersedes** the previous job (the new one takes over). This prevents duplicate
306
- streams if a streamer double-clicks or reloads mid-stream.
313
+ - **One live stream per streamer.** `goLive()` **throws** if a job is already live for
314
+ this streamer guarding against accidental double-clicks / re-go-lives (the #1 cause
315
+ of duplicate + stale jobs). Call `stopLive()` first to intentionally restart. Screen /
316
+ collab / layout are *live* changes — use `setStreamState()`, `startScreenShare()`,
317
+ `acceptCollab()`, never a second `goLive()`.
318
+ - **`participantOnly: true`** goes live as a compositing-only participant — a collab
319
+ guest with **no** socials: the job runs (pullable into a host's composite) but fans
320
+ out nowhere. Pass no `destinations`.
307
321
  - **Watch links.** Once broadcasts are created, `job.broadcasts` carries
308
322
  `{ platform, watchUrl }` for each destination — handy for "Watch on Twitch ↗".
309
323
  - **`arm({ destinations })`** pre-selects destinations so you can fire `goLive`
@@ -312,6 +326,62 @@ await stopLive();
312
326
 
313
327
  ---
314
328
 
329
+ ## Screen share & live layout
330
+
331
+ Screen share is a **separate layer** on your existing stream — never a re-publish or a new `goLive`:
332
+
333
+ ```js
334
+ await startScreenShare(); // publishes the screen session + composites it (solo → screen_camera, collab → collab_screen)
335
+ await stopScreenShare(); // reverts the layout
336
+ ```
337
+
338
+ Push presence/layout live (debounced, best-effort) with `setStreamState`:
339
+
340
+ ```js
341
+ setStreamState({ screenShare: true, camera: true, collab: false,
342
+ cameraPosition: "bottom-right", cameraSize: "small" });
343
+ ```
344
+
345
+ ---
346
+
347
+ ## Collaboration (split-screen)
348
+
349
+ Two creators appear side-by-side. Your operator picks the model per client:
350
+
351
+ **Per-creator (default)** — each creator keeps their **own** live stream; the composite is fanned to **both** creators' own socials:
352
+
353
+ ```js
354
+ // A (host, already live) invites B
355
+ await requestCollab({ collaboratorUserId, publisherSessionId, destinations: ["twitch", "kick"] });
356
+ // B (already live) accepts with their live session
357
+ await acceptCollab(inviteId, { publisherSessionId });
358
+ ```
359
+
360
+ When a collaborator leaves or drops, each still-live creator **automatically reverts to solo** — no stream stops. Send `setStreamState({ collab: false })` for an immediate clean transition.
361
+
362
+ **Merged (host + challenger)** — only the **host** creates the stream; the challenger joins as a **feed**, with **no job of their own**:
363
+
364
+ ```js
365
+ // Challenger: publish a camera, then join — NO goLive:
366
+ await acceptCollab(inviteId, { publisherSessionId }); // participantOnly guest
367
+ ```
368
+
369
+ - A **new** challenger calling `acceptCollab` while the host is live **swaps** the current one out — **live**, on the host's same session (the host never re-publishes).
370
+ - Live swap is **feed-only**: a swapped-in challenger must have no own socials. An audience-carrying swap is refused — end the collab and start fresh.
371
+
372
+ **Realtime** — open the collab stream to react to invites/starts/ends live:
373
+
374
+ ```js
375
+ studio.on("collab-composite", (e) => enterCollabUI(e.job)); // you're in the composite
376
+ studio.on("collab-reassigned", (e) => followJob(e.job)); // your job changed (reverted to solo / swapped out)
377
+ studio.on("collab-ended", () => exitCollabUI());
378
+ await startCollabRealtime();
379
+ ```
380
+
381
+ The SDK **auto-adopts** the new `job.id` on `collab-composite` / `collab-reassigned`, so your `job` state follows the right stream automatically.
382
+
383
+ ---
384
+
315
385
  ## Live chat & viewers
316
386
 
317
387
  ```js
@@ -426,6 +496,13 @@ defaults to `15000`. On mount it auto-loads connections; `job.status` is polled
426
496
  | `refreshJob()` | `Promise<Job>` | re-fetch job status |
427
497
  | `sendChat(message, platforms?)` | `Promise` | outbound chat fan-out |
428
498
  | `updateOverlay(opts)` | `Promise` | live banner update |
499
+ | `setStreamState(patch)` | — | push live presence/layout (screen/camera/collab, position/size) |
500
+ | `startScreenShare()` / `stopScreenShare()` | `Promise` | screen as a live layer (no re-publish) |
501
+ | `requestCollab(opts)` / `acceptCollab(id, opts)` | `Promise` | invite / join a collab — see [Collaboration](#collaboration-split-screen) |
502
+ | `declineCollab(id)` / `revokeCollab(id)` / `endCollab(id?)` | `Promise` | decline / cancel / end a collab |
503
+ | `setCollabRestream(id, on)` | `Promise` | toggle a guest's own-socials fan-out |
504
+ | `listCollabInvites()` · `collabInvites` | `Promise` · state | sent + received invites |
505
+ | `startCollabRealtime()` / `stopCollabRealtime()` | `Promise` | collab event stream (SSE) |
429
506
  | `studio` | `RestreamStudio` | the underlying controller |
430
507
 
431
508
  ### Convenience hooks
@@ -483,6 +560,12 @@ interface Viewers {
483
560
  }
484
561
  ```
485
562
 
563
+ **Events** — subscribe with `studio.on(type, cb)` (the hook subscribes for you):
564
+ `connections` · `job` · `viewers` · `comment` · `comments` · `error` · `state` ·
565
+ `screenshare` · and collab: `collab-invite` · `collab-accepted` · `collab-declined` ·
566
+ `collab-revoked` · `collab-ended` · `collab-expired` · `collab-composite` ·
567
+ **`collab-reassigned`** (your job id changed — the SDK adopts it) · `collab-snapshot`.
568
+
486
569
  ---
487
570
 
488
571
  ## Scopes
@@ -521,8 +604,9 @@ A `403 "Missing required scope"` means your key needs that scope added.
521
604
  catch (e) { if (e.status === 403) showUpgrade(); else toast(e.message); }
522
605
  ```
523
606
  - 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).
607
+ `403` (origin not allowed, or missing scope), `409` (a stream is already live for this
608
+ streamer, or a duplicate collab), `0` (network/timeout). Note: `goLive()` also **throws
609
+ client-side** if a job is already live — call `stopLive()` first.
526
610
  - Realtime is resilient: transient SSE drops reconnect automatically and chat
527
611
  history de-dupes on replay; job-status polling retries on transient failures.
528
612
 
@@ -538,7 +622,7 @@ A `403 "Missing required scope"` means your key needs that scope added.
538
622
  | Connections empty after refresh | You passed a **different `userId`** — it must be stable per streamer. |
539
623
  | Job goes `running` but nothing on the platforms | The `publisherSessionId` must be a **live** session actively sending media when you `goLive()`. |
540
624
  | Worker seems to wait / no audio | Your session has no audio track — pass `trackNames: ["video"]`. |
541
- | Starting a 2nd stream stops the first | Expected — **one live stream per streamer**; a new `goLive` supersedes the old. |
625
+ | `goLive()` throws "a stream is already live" | Expected (0.7.0+) — **one live stream per streamer**. Call `stopLive()` first; use `setStreamState()` / `startScreenShare()` / `acceptCollab()` for live changes (don't re-`goLive`). |
542
626
  | `Missing required scope` (403) | Key needs that scope (`connect` / `restream` / `read` / `chat:write`). |
543
627
 
544
628
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "restream-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Plug-and-play headless React hooks to add multi-platform restreaming (connect socials, go live, live chat + viewers, collaborative split-screen) with zero client backend.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/core.js CHANGED
@@ -102,6 +102,98 @@ export class RestreamStudio extends EventTarget {
102
102
  return { Authorization: `Bearer ${await this._getToken()}` };
103
103
  }
104
104
 
105
+ /**
106
+ * Wait until a published Cloudflare Realtime session is GENUINELY transmitting media — not merely
107
+ * ICE/SDP-connected. This closes the "the worker pulled before the publisher was actually sending"
108
+ * race: a CF `tracks/new` success returns the moment the SDP answer is accepted, but Cloudflare
109
+ * only registers a "live" remote peer once the publisher's RTCPeerConnection hits connectionState
110
+ * 'connected' AND every active sender starts emitting RTP packets (outbound-rtp.packetsSent > 0).
111
+ *
112
+ * Call this RIGHT AFTER your tracks/new / publish helper resolves and BEFORE you fan-out (goLive,
113
+ * requestCollab, acceptCollab, setMode). The restream worker pulls immediately at that point; if
114
+ * you signal before media is flowing, the pull fails transitently ("Track not found on remote
115
+ * peer") and the composite can drop.
116
+ *
117
+ * Resolves when BOTH are true (or rejects on timeout). Pure local getStats polling — no server
118
+ * round-trip. Picks senders dynamically so a replaceTrack mid-wait is tolerated.
119
+ *
120
+ * @param {RTCPeerConnection} pc the publisher PeerConnection (from your publish helper)
121
+ * @param {object} [opts]
122
+ * @param {number} [opts.timeoutMs=8000] hard cap; rejects with a clear error if media never flows
123
+ * @param {number} [opts.minPackets=1] per-sender packet count considered "transmitting"
124
+ * @param {number} [opts.pollMs=250] getStats interval
125
+ * @returns {Promise<{ok:true, senders:number}>}
126
+ * @throws {Error} on timeout or if the connection dies during the wait
127
+ */
128
+ async waitUntilPublishing(pc, { timeoutMs = 8000, minPackets = 1, pollMs = 250 } = {}) {
129
+ if (!pc || typeof pc.getStats !== "function") {
130
+ throw new Error("waitUntilPublishing: an RTCPeerConnection is required");
131
+ }
132
+ const deadline = Date.now() + Math.max(500, timeoutMs);
133
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
134
+
135
+ // A dead/failing connection is a terminal failure — surface it instead of waiting out the timer.
136
+ let dead = false;
137
+ const onFail = () => { dead = true; };
138
+ const wrap = () => {
139
+ try { pc.addEventListener("connectionstatechange", onFail); } catch {}
140
+ };
141
+ const unwrap = () => {
142
+ try { pc.removeEventListener("connectionstatechange", onFail); } catch {}
143
+ };
144
+ wrap();
145
+
146
+ try {
147
+ while (Date.now() < deadline) {
148
+ if (dead || pc.connectionState === "failed" || pc.connectionState === "closed") {
149
+ throw new Error(`waitUntilPublishing: connection ${pc.connectionState} before media flowed`);
150
+ }
151
+ const connected = pc.connectionState === "connected" || pc.iceConnectionState === "connected";
152
+ let transmitting = connected;
153
+ let senderCount = 0;
154
+ if (connected) {
155
+ try {
156
+ const stats = await pc.getStats();
157
+ // Map each sender to whether its outbound-rtp shows real packets. Every sender must pass,
158
+ // so a single stalled track holds the resolve (that's the whole point — the worker pulls
159
+ // every track and any one missing makes CF reject the pull).
160
+ const senders = pc.getSenders ? pc.getSenders() : [];
161
+ for (const s of senders) {
162
+ if (!s.track) continue; // a sender without a live track (e.g. stopped) doesn't count
163
+ senderCount++;
164
+ let trackReport = null;
165
+ stats.forEach((report) => {
166
+ if (report.type === "outbound-rtp" && report.track === s.track.id && !report.isReceiving) {
167
+ trackReport = report;
168
+ }
169
+ });
170
+ // Some UA variants expose the sender via report trackIdentifier / mediaSource — cover both.
171
+ if (!trackReport) {
172
+ stats.forEach((report) => {
173
+ if (report.type === "outbound-rtp" && (report.trackIdentifier === s.track.id || (report.id && s.track.id && report.track === s.track.id))) {
174
+ trackReport = report;
175
+ }
176
+ });
177
+ }
178
+ if (!trackReport || !Number.isFinite(trackReport.packetsSent) || trackReport.packetsSent < minPackets) {
179
+ transmitting = false;
180
+ break;
181
+ }
182
+ }
183
+ if (senderCount === 0) transmitting = false; // nothing to transmit yet
184
+ } catch {
185
+ transmitting = false; // transient getStats failure — keep polling
186
+ }
187
+ }
188
+ if (transmitting && senderCount > 0) return { ok: true, senders: senderCount };
189
+ await sleep(pollMs);
190
+ }
191
+ throw new Error(`waitUntilPublishing: timed out after ${timeoutMs}ms (connectionState=${pc.connectionState}) — media never flowed`);
192
+ } finally {
193
+ unwrap();
194
+ }
195
+ }
196
+
105
197
  async _authQuery() {
106
198
  if (this.publishableKey) {
107
199
  const q = { pk: this.publishableKey };
@@ -527,6 +619,41 @@ export class RestreamStudio extends EventTarget {
527
619
  return this._streamState;
528
620
  }
529
621
 
622
+ /**
623
+ * Switch a LIVE job's MODE (the new single-job model: one job holds the host's stream; modes
624
+ * change which feeds are composited + their positions — solo / host_screen / 1v1 / 1v1_screen / …).
625
+ * The ingest + RTMP fan-out NEVER restart on a mode change (immutable-job invariant); only the
626
+ * compositor layers/positions change. A guest may arrive later or a different guest swap in
627
+ * without a new job — the compositor is "always ready".
628
+ *
629
+ * On scene_engine='legacy' (default) this drives a brief restartWithFeeds internally; on
630
+ * 'live_v2' it is a true zero-restart sink mutation. The SDK call is identical either way —
631
+ * Stage 2 swaps the engine under you with no client change.
632
+ *
633
+ * @param {string} mode a preset key (solo / host_screen / 1v1 / 1v1_screen / solo_fullscreen_cam)
634
+ * or a per-creator override key configured in the admin
635
+ * @returns {Promise<{ok:boolean, mode:string, applied:boolean, engine:string}>}
636
+ */
637
+ async setMode(mode) {
638
+ if (!mode) throw new Error("mode is required (e.g. 'solo', '1v1', '1v1_screen')");
639
+ const jobId = this.job?.id;
640
+ if (!jobId) throw new Error("Not live — go live before switching modes");
641
+ const r = await this._req("POST", `/api/v1/jobs/${jobId}/mode`, this._withUser({ mode }));
642
+ if (r?.ok) this._emit("mode", { mode, engine: r.engine });
643
+ return r;
644
+ }
645
+
646
+ /**
647
+ * The job's CURRENTLY-active mode (resolved from its live state against the client's presets +
648
+ * per-creator overrides). Returns { mode, template, layoutState }. Read-only.
649
+ */
650
+ async getMode() {
651
+ const jobId = this.job?.id;
652
+ if (!jobId) throw new Error("Not live");
653
+ const r = await this._req("GET", `/api/v1/jobs/${jobId}/mode`, this._withUser({}));
654
+ return r;
655
+ }
656
+
530
657
  /** Last presence/layout state set via setStreamState() (local view). */
531
658
  get streamState() { return this._streamState; }
532
659
 
@@ -720,20 +847,27 @@ export class RestreamStudio extends EventTarget {
720
847
  * starts only when they accept. Pass your own publisherSessionId + destinations so
721
848
  * the server can begin the merged broadcast on accept (or rely on your active job).
722
849
  *
723
- * collaboratorRestreamOn defaults to TRUE: the invited creator's connected social
724
- * accounts MUST receive the side-by-side composite unless they explicitly opt out
725
- * (pass `collaboratorRestreamOn: false`). This matches the server product intent in
726
- * routes/collab.js and CollabService.createInvite. The bug: defaulting the SDK to
727
- * `false` here sent `false` in the request body, which the server honored, so the
728
- * collaborator's broadcasts/fan-out were silently skipped and the collab frame never
729
- * appeared on the collaborator's own social accounts.
850
+ * collaboratorRestreamOn controls whether the invited creator gets THEIR OWN job +
851
+ * fan-out (their own socials broadcast the composite), and is RESOLVED SERVER-SIDE
852
+ * by default so a single SDK works for both product models:
853
+ * - host/challenger products (Aurealone): only the HOST restreams. The guest is a
854
+ * feed-only participant inside the host's composite and creates NO job. The
855
+ * client's per-server default (collab_guest_restream=false) applies.
856
+ * - symmetric products (fomo.gg): both creators restream their own composite.
857
+ * The client's per-server default is true.
858
+ * Pass `collaboratorRestreamOn: true|false` ONLY to override the per-client default
859
+ * for this specific invite (rare). Omit it (do NOT default it client-side) so the
860
+ * server's per-client rule is the single source of truth.
730
861
  * @param {object} opts { collaboratorUserId, collaboratorRestreamOn?, publisherSessionId?, destinations?, metadata? }
731
862
  */
732
- async requestCollab({ collaboratorUserId, collaboratorRestreamOn = true, publisherSessionId, destinations, metadata } = {}) {
863
+ async requestCollab({ collaboratorUserId, collaboratorRestreamOn, publisherSessionId, destinations, metadata } = {}) {
733
864
  if (!collaboratorUserId) throw new Error("collaboratorUserId is required");
734
- const invite = await this._req("POST", "/api/v1/collab/request", this._withUser({
735
- collaboratorUserId, collaboratorRestreamOn, publisherSessionId, destinations, metadata,
736
- }));
865
+ const body = this._withUser({ collaboratorUserId, publisherSessionId, destinations, metadata });
866
+ // Only send the flag if the caller EXPLICITLY set it — otherwise omit so the server resolves
867
+ // the per-client default (host-only vs symmetric). Previously this defaulted to TRUE client-side
868
+ // and was sent on every call, forcing a guest fan-out for host-only products → duplicate jobs.
869
+ if (collaboratorRestreamOn === true || collaboratorRestreamOn === false) body.collaboratorRestreamOn = collaboratorRestreamOn;
870
+ const invite = await this._req("POST", "/api/v1/collab/request", body);
737
871
  this._emit("collab-invite", { type: "collab-invite", invite, mine: true });
738
872
  return invite;
739
873
  }
package/types/index.d.ts CHANGED
@@ -278,6 +278,14 @@ export class RestreamStudio extends EventTarget {
278
278
  stopScreenShare(): Promise<void>;
279
279
  /** Cloudflare session id of the live screen-share publish, or null. */
280
280
  readonly screenSessionId: string | null;
281
+ /** Wait until a published Cloudflare Realtime session is genuinely transmitting media
282
+ * (connectionState 'connected' AND every active sender's outbound-rtp.packetsSent > minPackets),
283
+ * not merely ICE/SDP-connected. Call this RIGHT AFTER your tracks/new/publish helper resolves
284
+ * and BEFORE you signal fan-out (goLive / requestCollab / acceptCollab / setMode) so the
285
+ * restream-side pull never races the publisher's first RTP packets ('Track not found on
286
+ * remote peer'). Pure local getStats polling; no server round-trip. Rejects on timeout /
287
+ * connection failure. New in 0.9.0. */
288
+ waitUntilPublishing(pc: RTCPeerConnection, opts?: { timeoutMs?: number; minPackets?: number; pollMs?: number }): Promise<{ ok: true; senders: number }>;
281
289
  startRealtime(jobId: string): Promise<void>;
282
290
  stopRealtime(): void;
283
291
  // collaborative split-screen