restream-sdk 0.1.9 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "restream-sdk",
3
- "version": "0.1.9",
3
+ "version": "0.3.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",
@@ -55,4 +55,4 @@
55
55
  "react",
56
56
  "sdk"
57
57
  ]
58
- }
58
+ }
package/src/core.js CHANGED
@@ -5,6 +5,12 @@
5
5
  // into goLive() — this module never captures or publishes media itself.
6
6
 
7
7
  export const PLATFORMS = ["twitch", "kick", "youtube", "facebook", "instagram", "tiktok", "restream"];
8
+ // FALLBACK only. The real routing (oauth | restream | off) is fetched per-client from
9
+ // GET /api/v1/connect/platforms → `routing`, so the platform team controls native↔Restream
10
+ // entirely from the admin with no SDK/app change. These statics are used solely if that
11
+ // fetch hasn't happened yet (e.g. a sync call before loadRouting()).
12
+ const NATIVE_CONNECT_PLATFORMS = new Set(["youtube", "facebook"]);
13
+ const RESTREAM_BRIDGE_PLATFORMS = new Set(["tiktok"]);
8
14
 
9
15
  const TERMINAL_STATUS = new Set(["stopped", "failed", "ended", "error", "completed"]);
10
16
 
@@ -33,6 +39,11 @@ export class RestreamStudio extends EventTarget {
33
39
  this.viewers = null;
34
40
  this.comments = [];
35
41
 
42
+ // Per-client routing map { <platform>: "oauth" | "restream" | "off" }, fetched from
43
+ // GET /connect/platforms. Null until loadRouting()/listOAuthPlatforms() runs; the
44
+ // connect helpers fall back to the static defaults above while it's null.
45
+ this._routing = null;
46
+
36
47
  this._token = null;
37
48
  this._tokenExp = 0;
38
49
  this._es = null;
@@ -131,9 +142,57 @@ export class RestreamStudio extends EventTarget {
131
142
  return this.connections;
132
143
  }
133
144
 
134
- /** Ask the backend which OAuth platforms are configured for this client. */
145
+ /** Ask the backend which OAuth platforms are configured for this client.
146
+ * Also caches the per-client `routing` map ({platform: oauth|restream|off}) that
147
+ * drives connectPlatform()/restreamAddChannelUrl() — so native↔Restream is decided
148
+ * by the admin, not hardcoded here. */
135
149
  async listOAuthPlatforms() {
136
- return this._req("GET", `/api/v1/connect/platforms${this._userQuery()}`);
150
+ const r = await this._req("GET", `/api/v1/connect/platforms${this._userQuery()}`);
151
+ if (r && r.routing && typeof r.routing === "object") this._routing = r.routing;
152
+ return r;
153
+ }
154
+
155
+ /** Load (and cache) the per-client routing map. Call once on init so the synchronous
156
+ * connect helpers route correctly. Returns { platform: "oauth"|"restream"|"off" }. */
157
+ async loadRouting() {
158
+ await this.listOAuthPlatforms();
159
+ return this._routing || {};
160
+ }
161
+
162
+ /** Effective route for a platform: "oauth" | "restream" | "off" | null (unknown). Uses
163
+ * the cached admin routing if loaded; otherwise the static fallbacks. */
164
+ routeFor(platform) {
165
+ const p = String(platform || "").toLowerCase();
166
+ if (this._routing && this._routing[p]) return this._routing[p];
167
+ if (NATIVE_CONNECT_PLATFORMS.has(p)) return "oauth";
168
+ if (RESTREAM_BRIDGE_PLATFORMS.has(p)) return "restream";
169
+ return null;
170
+ }
171
+
172
+ _isNativeConnect(platform) {
173
+ return this.routeFor(platform) === "oauth";
174
+ }
175
+
176
+ _isBridge(platform) {
177
+ return this.routeFor(platform) === "restream";
178
+ }
179
+
180
+ /**
181
+ * Single dynamic connect entry point — the app calls this for ANY platform and the
182
+ * SDK routes it per the admin's configuration (no app change when routing flips):
183
+ * "oauth" → native OAuth popup (connect)
184
+ * "restream" → Restream bridge add-channel flow (connectRestreamChannel)
185
+ * "off" → throws (platform disabled for this client)
186
+ * Loads routing first if it hasn't been cached yet. Must be called from a click.
187
+ * @param {string} platform
188
+ * @param {object} [opts] forwarded to connectRestreamChannel for bridge platforms
189
+ */
190
+ async connectPlatform(platform, opts = {}) {
191
+ if (!this._routing) { try { await this.loadRouting(); } catch { /* fall back to statics */ } }
192
+ const route = this.routeFor(platform);
193
+ if (route === "oauth") return this.connect(platform);
194
+ if (route === "restream") return this.connectRestreamChannel(platform, opts);
195
+ throw new Error(`"${platform}" is not available to connect for this client (routed "off").`);
137
196
  }
138
197
 
139
198
  /**
@@ -155,6 +214,15 @@ export class RestreamStudio extends EventTarget {
155
214
  * unknown params), so it safely falls back to the channel page.
156
215
  */
157
216
  restreamAddChannelUrl(platform) {
217
+ const normalized = String(platform || "").toLowerCase();
218
+ if (this._isNativeConnect(normalized) && this.publishableKey) {
219
+ const q = new URLSearchParams({ pk: this.publishableKey });
220
+ if (this.userId) q.set("userId", this.userId);
221
+ return `${this.apiBase}/api/v1/connect/${platform}/start?${q}`;
222
+ }
223
+ if (normalized && !this._isBridge(normalized)) {
224
+ throw new Error(`"${platform}" is not routed through the Restream bridge for this client. Use native OAuth (connect) instead.`);
225
+ }
158
226
  const base = "https://restream.io/channel";
159
227
  return platform ? `${base}?add=${encodeURIComponent(platform)}` : base;
160
228
  }
@@ -174,6 +242,23 @@ export class RestreamStudio extends EventTarget {
174
242
  * closed the popup without adding one.
175
243
  */
176
244
  async connectRestreamChannel(platform, { pollMs = 3000, timeoutMs = 180_000 } = {}) {
245
+ const normalized = String(platform || "").toLowerCase();
246
+ // Load routing so native↔bridge is decided by the admin, not the statics.
247
+ if (!this._routing) { try { await this.loadRouting(); } catch { /* fall back to statics */ } }
248
+ if (this._isNativeConnect(normalized)) {
249
+ const result = await this.connect(platform);
250
+ return {
251
+ id: `native:${platform}`,
252
+ platform,
253
+ displayName: null,
254
+ url: null,
255
+ active: !result?.closed,
256
+ };
257
+ }
258
+ if (!this._isBridge(normalized)) {
259
+ throw new Error(`"${platform}" is not routed through the Restream bridge for this client. Use native OAuth (connect) instead.`);
260
+ }
261
+
177
262
  // Open synchronously to preserve the click's user-activation (else it's blocked).
178
263
  let popup = window.open("", "restream_add_channel", "width=760,height=900");
179
264
  if (!popup) throw new Error("popup blocked — allow popups to connect accounts");
@@ -184,6 +269,7 @@ export class RestreamStudio extends EventTarget {
184
269
  await this.listConnections();
185
270
  if (!this.connections.some((c) => c.platform === "restream")) {
186
271
  const q = await this._authQuery();
272
+ q.platform = normalized;
187
273
  popup.location = `${this.apiBase}/api/v1/connect/restream/start?${new URLSearchParams(q)}`;
188
274
  await this._awaitRestreamConnected(timeoutMs);
189
275
  // Our OAuth callback page auto-closes the popup; reopen for the add step.
@@ -299,7 +385,7 @@ export class RestreamStudio extends EventTarget {
299
385
  * Restream channels. Any field omitted falls back to the
300
386
  * default pre-set for this creator on the platform.
301
387
  */
302
- async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels, streamMeta, destinationMeta } = {}) {
388
+ async goLive({ publisherSessionId, destinations, overlay, recording, trackNames, restreamChannels, streamMeta, destinationMeta, streamingProvider } = {}) {
303
389
  if (!publisherSessionId) throw new Error("publisherSessionId is required (from your existing stream session)");
304
390
  const body = {
305
391
  publisherSessionId,
@@ -321,6 +407,12 @@ export class RestreamStudio extends EventTarget {
321
407
  if (destinationMeta && typeof destinationMeta === "object" && Object.keys(destinationMeta).length) {
322
408
  body.destinationMeta = destinationMeta;
323
409
  }
410
+ // Streaming provider selection per destination.
411
+ // Example: { youtube: "youtube_native" } to use native YouTube instead of Restream bridge.
412
+ // Default is "restream" for all destinations.
413
+ if (streamingProvider && typeof streamingProvider === "object" && Object.keys(streamingProvider).length) {
414
+ body.streamingProvider = streamingProvider;
415
+ }
324
416
  if (this.publishableKey && this.userId) body.clientUserId = this.userId; // token mode binds server-side
325
417
  const job = await this._req("POST", "/api/v1/jobs", body);
326
418
  this.job = job;
@@ -457,9 +549,17 @@ export class RestreamStudio extends EventTarget {
457
549
  * Invite another creator (same client) to a split-screen collab. The composite
458
550
  * starts only when they accept. Pass your own publisherSessionId + destinations so
459
551
  * the server can begin the merged broadcast on accept (or rely on your active job).
552
+ *
553
+ * collaboratorRestreamOn defaults to TRUE: the invited creator's connected social
554
+ * accounts MUST receive the side-by-side composite unless they explicitly opt out
555
+ * (pass `collaboratorRestreamOn: false`). This matches the server product intent in
556
+ * routes/collab.js and CollabService.createInvite. The bug: defaulting the SDK to
557
+ * `false` here sent `false` in the request body, which the server honored, so the
558
+ * collaborator's broadcasts/fan-out were silently skipped and the collab frame never
559
+ * appeared on the collaborator's own social accounts.
460
560
  * @param {object} opts { collaboratorUserId, collaboratorRestreamOn?, publisherSessionId?, destinations?, metadata? }
461
561
  */
462
- async requestCollab({ collaboratorUserId, collaboratorRestreamOn = false, publisherSessionId, destinations, metadata } = {}) {
562
+ async requestCollab({ collaboratorUserId, collaboratorRestreamOn = true, publisherSessionId, destinations, metadata } = {}) {
463
563
  if (!collaboratorUserId) throw new Error("collaboratorUserId is required");
464
564
  const invite = await this._req("POST", "/api/v1/collab/request", this._withUser({
465
565
  collaboratorUserId, collaboratorRestreamOn, publisherSessionId, destinations, metadata,
package/src/react.js CHANGED
@@ -49,6 +49,8 @@ export function useRestream(opts) {
49
49
  listRestreamChannels: useCallback(() => studio.listRestreamChannels(), [studio]),
50
50
  connectRestreamChannel: useCallback((p, o) => studio.connectRestreamChannel(p, o), [studio]),
51
51
  restreamAddChannelUrl: useCallback((p) => studio.restreamAddChannelUrl(p), [studio]),
52
+ connectPlatform: useCallback((p, o) => studio.connectPlatform(p, o), [studio]),
53
+ loadRouting: useCallback(() => studio.loadRouting(), [studio]),
52
54
  arm: useCallback((o) => studio.arm(o), [studio]),
53
55
  goLive: useCallback((o) => studio.goLive(o), [studio]),
54
56
  stopLive: useCallback(() => studio.stopLive(), [studio]),
@@ -70,8 +72,8 @@ export function useRestream(opts) {
70
72
 
71
73
  /** Convenience: just the social-connection state + actions. */
72
74
  export function useConnections(opts) {
73
- const { connections, connect, disconnect, listConnections, listOAuthPlatforms, listRestreamChannels, connectRestreamChannel, restreamAddChannelUrl } = useRestream(opts);
74
- return { connections, connect, disconnect, refresh: listConnections, listOAuthPlatforms, listRestreamChannels, connectRestreamChannel, restreamAddChannelUrl };
75
+ const { connections, connect, disconnect, listConnections, listOAuthPlatforms, listRestreamChannels, connectRestreamChannel, restreamAddChannelUrl, connectPlatform, loadRouting } = useRestream(opts);
76
+ return { connections, connect, disconnect, refresh: listConnections, listOAuthPlatforms, listRestreamChannels, connectRestreamChannel, restreamAddChannelUrl, connectPlatform, loadRouting };
75
77
  }
76
78
 
77
79
  /** Convenience: live chat (incoming comments + outbound send). Pass an existing studio. */
package/types/index.d.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  export type Platform = "twitch" | "kick" | "youtube" | "facebook" | "instagram" | "tiktok" | "restream";
2
2
 
3
+ /** How a platform is reached for this client (admin-controlled): native OAuth, the
4
+ * Restream bridge, or disabled. */
5
+ export type RoutingValue = "oauth" | "restream" | "off";
6
+ /** Per-client routing map returned by GET /connect/platforms (`routing`). */
7
+ export type Routing = Partial<Record<Platform, RoutingValue>>;
8
+ /** Response of listOAuthPlatforms(). `routing` is the single source of truth for
9
+ * native↔Restream; `platforms` is the legacy natively-connectable set. */
10
+ export interface OAuthPlatformsResponse {
11
+ platforms: Platform[];
12
+ routing?: Routing;
13
+ frontendOrigin: string;
14
+ redirectOrigin: string;
15
+ }
16
+
3
17
  /** A channel the user connected inside their Restream account (a fan-out target). */
4
18
  export interface RestreamChannel {
5
19
  id: string;
@@ -178,20 +192,31 @@ export class RestreamStudio extends EventTarget {
178
192
  on(type: "error", cb: (e: Error) => void): () => void;
179
193
  on(type: "collab" | "collab-invite" | "collab-accepted" | "collab-declined" | "collab-revoked" | "collab-ended" | "collab-expired" | "collab-composite" | "collab-snapshot", cb: (e: CollabEvent) => void): () => void;
180
194
  listConnections(): Promise<Connection[]>;
181
- listOAuthPlatforms(): Promise<{ platforms: Platform[]; frontendOrigin: string; redirectOrigin: string }>;
195
+ listOAuthPlatforms(): Promise<OAuthPlatformsResponse>;
182
196
  listRestreamChannels(): Promise<RestreamChannel[]>;
183
- /** URL of Restream's add-channel page (best-effort deep-link to `platform`). */
197
+ /** URL of Restream's add-channel page; YouTube/Facebook return native OAuth URLs in publishable-key mode. */
184
198
  restreamAddChannelUrl(platform?: string): string;
185
199
  /**
186
200
  * One-call "Connect <platform> via Restream" — ensures the Restream account is
187
201
  * connected, opens Restream's add-channel page, and resolves once the new channel
188
- * appears. Returns the new channel, or null if the user closed the popup without
202
+ * appears. YouTube and Facebook are routed to native OAuth instead. Returns the
203
+ * new channel, a native placeholder, or null if the user closed the popup without
189
204
  * adding one. Must be called from a user gesture (opens a popup).
190
205
  */
191
206
  connectRestreamChannel(
192
207
  platform: string,
193
208
  opts?: { pollMs?: number; timeoutMs?: number }
194
209
  ): Promise<RestreamChannel | null>;
210
+ /** Load + cache the per-client routing map; call once on init. */
211
+ loadRouting(): Promise<Routing>;
212
+ /** Effective route for a platform (uses cached admin routing, else static fallback). */
213
+ routeFor(platform: string): RoutingValue | null;
214
+ /** Single dynamic connect entry point — routes to native OAuth or the Restream bridge
215
+ * per the admin's configuration. Must be called from a user gesture. */
216
+ connectPlatform(
217
+ platform: string,
218
+ opts?: { pollMs?: number; timeoutMs?: number }
219
+ ): Promise<{ platform: Platform; closed?: boolean } | RestreamChannel | null>;
195
220
  connect(platform: Platform): Promise<{ platform: Platform; closed?: boolean }>;
196
221
  disconnect(platform: Platform): Promise<Connection[]>;
197
222
  arm(opts: { destinations: Platform[] }): unknown;
@@ -230,13 +255,18 @@ export interface UseRestream {
230
255
  connect(platform: Platform): Promise<{ platform: Platform; closed?: boolean }>;
231
256
  disconnect(platform: Platform): Promise<Connection[]>;
232
257
  listConnections(): Promise<Connection[]>;
233
- listOAuthPlatforms(): Promise<{ platforms: Platform[]; frontendOrigin: string; redirectOrigin: string }>;
258
+ listOAuthPlatforms(): Promise<OAuthPlatformsResponse>;
234
259
  listRestreamChannels(): Promise<RestreamChannel[]>;
235
260
  restreamAddChannelUrl(platform?: string): string;
236
261
  connectRestreamChannel(
237
262
  platform: string,
238
263
  opts?: { pollMs?: number; timeoutMs?: number }
239
264
  ): Promise<RestreamChannel | null>;
265
+ connectPlatform(
266
+ platform: string,
267
+ opts?: { pollMs?: number; timeoutMs?: number }
268
+ ): Promise<{ platform: Platform; closed?: boolean } | RestreamChannel | null>;
269
+ loadRouting(): Promise<Routing>;
240
270
  arm(opts: { destinations: Platform[] }): unknown;
241
271
  goLive(opts: GoLiveParams): Promise<Job>;
242
272
  stopLive(): Promise<unknown>;
@@ -260,13 +290,18 @@ export function useConnections(opts: RestreamOptions): {
260
290
  connect(platform: Platform): Promise<{ platform: Platform; closed?: boolean }>;
261
291
  disconnect(platform: Platform): Promise<Connection[]>;
262
292
  refresh(): Promise<Connection[]>;
263
- listOAuthPlatforms(): Promise<{ platforms: Platform[]; frontendOrigin: string; redirectOrigin: string }>;
293
+ listOAuthPlatforms(): Promise<OAuthPlatformsResponse>;
264
294
  listRestreamChannels(): Promise<RestreamChannel[]>;
265
295
  restreamAddChannelUrl(platform?: string): string;
266
296
  connectRestreamChannel(
267
297
  platform: string,
268
298
  opts?: { pollMs?: number; timeoutMs?: number }
269
299
  ): Promise<RestreamChannel | null>;
300
+ connectPlatform(
301
+ platform: string,
302
+ opts?: { pollMs?: number; timeoutMs?: number }
303
+ ): Promise<{ platform: Platform; closed?: boolean } | RestreamChannel | null>;
304
+ loadRouting(): Promise<Routing>;
270
305
  };
271
306
  export function useLiveChat(studio: RestreamStudio): { comments: Comment[]; send(message: string, platforms?: Platform[]): Promise<unknown> };
272
307
  export function useViewers(studio: RestreamStudio): ViewerCounts | null;