npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@reactoo/watchtogether-sdk-js

v2.8.105

Published

Javascript SDK for Reactoo

Downloads

6,357

Readme

Reactoo Watch Together SDK for JavaScript

Description

The Reactoo Watch Together SDK provides client-side functionality that:

  • Manages and joins Watch Together video rooms (WebRTC, via a Janus SFU)
  • Calls the Reactoo API directly, with the surface built at runtime from the live OpenAPI spec
  • Delivers real-time room state over AWS IoT (MQTT) and a WebRTC data channel
  • Synchronises an OTT live stream to the room, and captures participant reactions

Reactoo Watch Together lets you create a video room and invite participants to share the emotion of watching live video together — seeing, hearing and chatting while the action plays.

Browser support

| Chrome | Firefox | Opera | Android WebView | Safari | | --- | --- | --- | --- | --- | | 56+ ✔ | 44+ ✔ | 43+ ✔ | 67+ ✔ | 12.1+ ✔ |

The REST surface is not bundled: the SDK fetches the OpenAPI spec on load and builds client.apis.<tag>.<operationId> from it, so new endpoints appear without an SDK upgrade.

Installation

npm install --save @reactoo/watchtogether-sdk-js
<script src="https://unpkg.com/@reactoo/watchtogether-sdk-js"></script>

Quick start

// 1. Create an SDK instance. Outer call configures the SDK, inner call the tenant.
const wt = WatchTogetherSDK({ debug: false })({ instanceType: 'reactooDemo' });

// 2. Authenticate. deviceLogin() needs no credentials — it derives a stable
//    device id from a browser fingerprint and creates an anonymous user.
await wt.auth.deviceLogin();

// 3. Connect the real-time channel (room state, presence, attribute changes).
wt.iot.iotLogin();

// 4. Fetch the room, then subscribe to its IoT topic.
const room = (await wt.room.getRoomById(roomId)).data;
wt.iot.subscribe(room.iotTopic);

// 5. Create a session and wire your listeners BEFORE connecting — `connect()`
//    emits joined/feed events while it is still resolving.
const session = await wt.room.createSession({
  constructId: myUniqueId,   // your handle for this session
  roomId,
  pinHash: room.pinHash,     // only for pin-protected rooms
  role: 'participant',
  options: {},
});

session.$on('addRemoteParticipant', renderRemote);
session.$on('removeRemoteParticipant', dropRemote);
session.$on('error', (e) => console.error(e));

// 6. Join, then publish.
await session.connect();
await session.publishLocal(await wt.utils.getUserStream({ hasVideo: true, hasAudio: true }));

A complete, production-shaped integration lives in example/room.js — it is the bridge a shipping app uses, including the audio mixer, reconnection watchers and room-attribute handling.

Lifecycle

The SDK has two nested lifecycles: an instance (auth + IoT, one per app) and a session (one per room join). Keeping them separate is what lets you leave and rejoin a room, or move between rooms, without re-authenticating.

instance:  deviceLogin ──► iotLogin ──► getRoomById ──► iot.subscribe(room.iotTopic)
                                                              │
session:                       createSession ──► connect ──► publishLocal
                                                              │
                               disconnect ◄── leave           │
                               destroySession ◄── teardown ◄──┘
async function destroy() {
  await session?.disconnect();
  wt.iot.unsubscribe(room.iotTopic);
  await wt.room.destroySession(constructId);   // kills the session and unregisters it
}

destroySession is idempotent for an unknown id. The SDK also tears every session down on pagehide/beforeunload automatically.

Sessions and roles

role decides both what you publish and whose feeds you receive. The subscription matrix is keyed by [yourRole][roomType]:

| Role | Publishes | Typically subscribes to | | --- | --- | --- | | participant | camera + mic | other participants, talkback (plus host/observer in studio) | | talkback | camera + mic | participants, host, other talkback | | monitor | — | participants, host | | host | camera + mic | nothing (renders the program feed instead) | | observer, observerSolo1…5 | — | participants |

Room types: watchparty, studio, template, commentary, intercom, videowall, videowall-queue, videowall-queue-video. The room's own type arrives on the join response and is applied automatically.

Override the matrix per session with options.userRoleSubscriptionRules, and narrow it at runtime:

session.setRestrictToAudioOnly(true);              // drop remote video, keep audio
session.setRestrictSubscribeToUserIds([userId]);   // only these users ([] = all)
session.setListenIntercomChannels(['producers']);  // which intercom groups you hear
session.setTalkIntercomChannels(['producers']);    // which groups hear you

Publishing media

A session can publish several named sources at once (camera0, screen0, …). Each carries at most one audio and one video track.

const stream = await wt.utils.getUserStream({
  hasVideo: true, hasAudio: true,
  width: 1280, height: 720,
  aDeviceId, vDeviceId,          // optional, from enumerateDevices()
  echoCancellation: true, noiseSuppression: true, autoGainControl: false,
  muteAudio: false, muteVideo: false,
});

await session.publishLocal(stream);                  // defaults to source 'camera0'
await session.publishLocal(screenStream, 'screen0'); // a second source
await session.publishLocal(null, 'screen0');         // stop publishing that source

session.toggleAudio(false, 'camera0');  // false = mute, true = unmute, omit = flip
session.toggleVideo();
await session.unpublishLocal();

publishLocal resolves once the data channel is open and the local feed events have been emitted. Renegotiation with the SFU continues in the background; a failure there surfaces as a restartConnection event rather than a rejected promise.

Screen sharing uses wt.utils.getDisplayMedia(). When the user stops sharing from the browser's own UI the track ends, and the SDK republishes that source automatically.

Rendering remote participants

The identity model

A feed is not a user — it is a (userId, source) pair. One participant can publish camera0 and screen0 at once, and each is an independent stream. Most apps key their store on a composite id:

const userUid = `${participant.userId}|${source}`;

streamMap is the authority on which sources currently exist for that publisher: Object.keys(participant.streamMap) is the complete, current source list. Do not infer "they stopped publishing" from tracks being empty — roster events legitimately carry tracks: [] while streamMap is populated.

Reading an event

addRemote* fires for several different reasons, and the flags tell you which:

| Flags | Meaning | | --- | --- | | adding: true, source, track set | a track was added to that source — attach it | | removing: true | a track went away | | adding: false, removing: false, source: null | a roster event (someone joined / initial roster). tracks and sourceRelatedTracks are [] even if streamMap is populated |

Use sourceRelatedTracks — the SDK has already filtered tracks down to the source this event concerns.

Pattern A — rebuild (simple, stateless renderers)

Fine when a feed's UI is cheap to recreate and you hold no per-stream state:

function onRemote(data) {
  dropAllFeedsFor(data.id);
  for (const source of Object.keys(data.streamMap)) {
    const tracks = data.tracks.filter((t) => data.streamMap[source].includes(t.id));
    if (!tracks.length) continue;
    const stream = new MediaStream();
    tracks.forEach((t) => stream.addTrack(t));
    mount(`${data.id}|${source}`, stream);
  }
}

The cost: every event rebuilds every source for that publisher. When someone adds screen0, the camera0 element is torn down and recreated too — a visible flicker, and any per-stream state attached to it is lost.

Pattern B — incremental upsert (recommended)

Touch only the source the event is about, and derive removals by diffing the source list against what you already hold. This is what production apps use, because per-stream state — a <video> element, an audio GainNode, a background-removal pipeline — must survive unrelated events:

function onRemote(participant) {
  const { userId, source, streamMap } = participant;

  const previousSources = store.filter((p) => p.userId === userId).map((p) => p.source);
  const newSources      = Object.keys(streamMap);
  const sourcesToRemove = previousSources.filter((s) => !newSources.includes(s));

  // 1. add/replace only the source this event is about
  if (participant.adding) {
    let stream = null;
    const tracks = participant.sourceRelatedTracks;
    if (tracks.length) {
      stream = new MediaStream();
      tracks.forEach((t) => stream.addTrack(t));
    }

    const entry = { ...participant, stream, source, userUid: `${userId}|${source}` };
    const i = store.findIndex((p) => p.userId === userId && p.source === source);
    if (i > -1) store.splice(i, 1, entry);   // replace — keeps position
    else store.push(entry);
  }

  // 2. drop sources that are no longer in streamMap
  sourcesToRemove.forEach((gone) => {
    teardownPerStreamState(userId, gone);
    store.splice(store.findIndex((p) => p.userId === userId && p.source === gone), 1);
  });

  // 3. present but publishing nothing — keep a placeholder tile
  if (newSources.length === 0 && !participant.removing) {
    store.push({ ...participant, stream: null, source: 'camera0',
                 userUid: `${userId}|camera0` });
  }
}

removeRemote* is per-user, not per-source: drop every entry for that userId.

tid — the change token

Every feed event carries a fresh tid. It exists so you can force a re-render without a real SDK event: when you swap a stream yourself (a filter pipeline finishing, a track replacement), re-dispatch the same participant with a new tid and your view layer will see it as changed.

onPipelineReady(() => onRemote({ ...participant, tid: crypto.randomUUID() }));

constructId

Every payload carries the constructId of the session that emitted it. If your app can hold more than one session at a time, namespace your store by it — otherwise two rooms will write over each other.

Roles use different keys

The other feed events carry the same payload but are conventionally keyed differently, because their identity semantics differ:

| Event | Key on | | --- | --- | | addRemoteParticipant | userId + source | | addRemoteTalkback, addRemoteObserver | feed id + userId — the same user can appear as more than one feed | | addRemoteInstructor | fullUserId |

Events

Subscribe with session.$on(name, handler) / $off(name, handler). Session events and room events share one namespace.

Connection

| Event | Payload | | --- | --- | | connecting | bool — the REST join + SFU connect is in flight | | joining | bool | | joined | (bool, constructId) | | disconnect | bool — whether it had been connected | | reconnecting | bool — a restart() is running | | kicked | — | | changeRoomId | roomId — the backend rerouted you to another room |

Publishing

| Event | Payload | | --- | --- | | publishing / published | bool | | addLocalParticipant / removeLocalParticipant | feed payload (above) | | localHasAudio / localHasVideo | (bool, source) | | localMuted | {type, value, source, mid}value: true means muted | | dataChannel | bool |

Remote feedsadd…/remove… for RemoteParticipant, RemoteTalkback, RemoteObserver, RemoteInstructor (host), RemoteCompanionTV, RemoteCompanionPhone. Plus remoteTrackMuted {…, kind, muted}.

Diagnostics

| Event | Payload | | --- | --- | | connectionState / iceState | [handleId, isLocal, state] — W3C state strings | | iceRestart | {state, origin: 'local'\|'remote', status} | | reclaimingConnection | {state, status} | | rtcStats | [{id, mid, userId, source, stats: {currentBandwidth, frameRate, framesDecoded, framesDropped, framesReceived, freezeCount, jitter, packetLoss, rtt, totalFreezesDuration, selectedSubstream}}], every 3 s | | restartConnection | — the SDK asks you to restart() | | error | see Error handling |

ApplicationchatMessage, userUpdate, remoteMuted, customData, scaling, data, dataMessage.

Messaging

Two transports, both through the session:

// Over the SFU — reaches everyone in the room, survives renegotiation.
session.sendChatMessage('hello');                 // → peers' `chatMessage`
session.sendChatMessage('psst', [userId]);        // to specific users
session.sendSystemMessage('my_action', { any: 'json' });

// Over the WebRTC data channel — lower latency, peer-to-SFU.
session.sendMessageViaDataChannel(null, { hello: 'world' });   // → peers' `dataMessage`

sendSystemMessage is what the SDK itself uses for mute state (remote_muted), so custom actions should use a distinct action name.

Real-time room state (IoT)

Room metadata changes (layout, attributes, presence, recording) arrive over MQTT, not the SFU. Subscribe to the room's topic and filter:

wt.iot.iotLogin();                        // connects; retries and refreshes credentials itself
wt.iot.subscribe(room.iotTopic);

wt.iot.$on('message', (msg) => {
  if (msg.roomId !== room._id) return;    // late delivery from a previous room
  if (msg.event !== 'attribute_updated') return;

  if (msg.operation === 'multi') msg.value.forEach(applyAttribute);
  else applyAttribute(msg);
});

wt.iot.$on('connectionFailure', handleIotDown);
wt.iot.$on('connectionSuccess', handleIotUp);

Write attributes back with wt.room.updateAttribute({ roomId, operation: 'set', attributeName: 'layoutAttributes', value: {...}, id, deviceId, deduplicationId }). The backend replaces the named attribute wholesale, so send it complete.

Connection health and recovery

The SDK recovers from most drops on its own: it reclaims the Janus session, restarts ICE, re-signs and reconnects IoT credentials, and resubscribes topics. What it cannot fix itself, it reports:

session.$on('restartConnection', () => session.restart());

const trouble = {};
const watch = (key) => (d) => {
  if (d.state === true || d.status === 'failed') trouble[key] = true;
  else if (d.status === 'success') trouble[key] = false;
  render(Object.values(trouble).some(Boolean));
};
session.$on('iceRestart', watch('ice'));
session.$on('reclaimingConnection', watch('reclaim'));
session.$on('connectionState', ([, , state]) => {
  trouble.pc = state === 'failed' || state === 'disconnected';
  render(Object.values(trouble).some(Boolean));
});

session.restart() disconnects, waits, reconnects and republishes whatever was live. It is a no-op if a restart is already running.

Error handling

Errors are objects, not Error instances:

{ type: 'error' | 'warning', id: <number>, message: <string>, data: <any> }

type: 'warning' is informational and recoverable; type: 'error' means the session is not usable and the conventional response is to disconnect:

session.$on('error', (e) => { if (e.type === 'error') session.disconnect(); });

The numeric id is stable, so you can branch on specific failures. Notable ones: 39 data channel did not open, 44 connection timeout, 53 reconnect failed, 54 subscription update failed, 60 unauthorized (403 — the SDK disconnects itself).

REST rejections are separate and carry an HTTP response:

catch (e) {
  if (e?.response?.status === 404) …                                    // no such room
  if (e?.response?.data?.errorCode === 'INVALID_PASSWORD') …            // wrong pin
}

API reference

FactoryWatchTogetherSDK({ debug, language, storagePrefix, apiUrl })({ instanceType, playerFactory, providerAuth })

| Namespace | Selected members | | --- | --- | | wt.auth | deviceLogin(), login(user, pass), logout(), isLoggedIn(), setLanguage(), $on/$off | | wt.user | getUserSelf(), updateUserSelf({...}), and the user endpoints | | wt.room | getRoomById(id, pinHash), getRoomBySlug(slug, password), createRoom({...}), updateRoom({...}), getRoomsList({...}), updateAttribute({...}), updateLayout({...}), queue({...}), roomRecorder({...}), getInviteUrl({...}), setUser({...}), createSession({...}), getSessions(), getSessionBy*(), destroySession(id), destroySessions() | | wt.iot | iotLogin(), iotLogout(), subscribe(topic), unsubscribe(topic), send(topic, msg), isConnected(), $on/$off/$once | | wt.asset, wt.sport, wt.liveBarn, wt.system | the corresponding REST namespaces | | wt.utils | getUserStream({...}), getHostStream({...}), getDisplayMedia(), applyConstraints({...}), decodeJanusDisplay(d), generateUUID() |

Sessionconnect({reactooRoomId}), disconnect(), restart({reactooRoomId}), publishLocal(stream, source), unpublishLocal(), toggleAudio(value, source), toggleVideo(value, source), selectSubStream(id, substream, source, mid), setRestrictToAudioOnly(v), setRestrictSubscribeToUserIds([]), setTalkIntercomChannels([]), setListenIntercomChannels([]), getUserTalkIntercomChannels(userId), sendChatMessage(text, to), sendSystemMessage(action, value, to), sendMessageViaDataChannel(label, data), requestKeyFrame(handleId, mid), setBitrateCap(bitrate), changeUserRole(role), kill(), destroy(), $on/$off/$clear. Getters: userId, roomId, sessionId, constructId, role.

Everything not listed maps 1:1 onto the OpenAPI specwt.<tag>.<operationId>.

Flutter / mobile

A Dart port for Android and iOS lives in flutter/ and mirrors this SDK file-for-file, with the same namespaces, method names and event strings. See flutter/README.md for the mobile-specific parts and flutter/PLATFORM_GAPS.md for what the mobile WebRTC stack cannot reproduce.

Questions and feedback

Open an issue, or reach the team at reactoo.com.