# @reactoo/watchtogether-sdk-js

> Javascript SDK for Reactoo

Latest version **2.8.118** (published 2026-10-08) · MIT license · 0 weekly downloads

## Install

```sh
npm install @reactoo/watchtogether-sdk-js
pnpm add @reactoo/watchtogether-sdk-js
yarn add @reactoo/watchtogether-sdk-js
bun add @reactoo/watchtogether-sdk-js
```

## Health

**Score 60/100 (C)** — status: active.

Positive: esm support; no vulnerabilities; recently updated; high maintenance score.

Warnings: low downloads; no types; large bundle.

## Facts

| | |
|---|---|
| Version | 2.8.118 |
| Published | 2026-10-08 |
| First published | 2019-03-13 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | none |
| Module format | ESM + CommonJS |
| Dependencies | 4 |
| Unpacked size | 93.7 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Author | Roman Labis |
| Maintainers | picitujeromanov |
| Keywords | watchtogether, reactoo, sdk, library |

## Links

- npm: https://www.npmjs.com/package/@reactoo/watchtogether-sdk-js
- Repository: https://github.com/picitujeromanov/WR-SDK
- Issues: https://github.com/picitujeromanov/WR-SDK/issues
- npm.io page: https://npm.io/package/@reactoo/watchtogether-sdk-js

## Dependencies (4)

- [swagger-client](https://npm.io/package/swagger-client.md) 3.18.0
- [webrtc-adapter](https://npm.io/package/webrtc-adapter.md) ^9.0.1
- [serialize-error](https://npm.io/package/serialize-error.md) 9.1.0
- [aws-iot-device-sdk-v2](https://npm.io/package/aws-iot-device-sdk-v2.md) ^1.23.1

## Recent versions

- 2.8.118 (latest) — 2026-10-08
- 2.8.15-beta.2 (beta) — 2025-06-12
- 2.8.117 — 2026-10-03
- 2.8.116 — 2026-10-03
- 2.8.115 — 2026-09-30
- 2.8.114 — 2026-09-28
- 2.8.113 — 2026-09-22
- 2.8.112 — 2026-09-15
- 2.8.111 — 2026-09-07
- 2.8.110 — 2026-08-28
- 2.8.109 — 2026-08-27
- 2.8.108 — 2026-08-21
- 2.8.107 — 2026-08-20
- 2.8.106 — 2026-08-12
- 2.8.105 — 2026-08-11
- … 887 more at https://npm.io/package/@reactoo/watchtogether-sdk-js/versions

## README

# Reactoo Watch Together SDK for JavaScript

- [Description](#description)
- [Browser support](#browser-support)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Lifecycle](#lifecycle)
- [Sessions and roles](#sessions-and-roles)
- [Publishing media](#publishing-media)
- [Rendering remote participants](#rendering-remote-participants)
- [Events](#events)
- [Messaging](#messaging)
- [Real-time room state (IoT)](#real-time-room-state-iot)
- [Connection health and recovery](#connection-health-and-recovery)
- [Error handling](#error-handling)
- [API reference](#api-reference)
- [Flutter / mobile](#flutter--mobile)
- [Questions and feedback](#questions-and-feedback)

## 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](https://reactoo.com) 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](https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_24x24.png) | ![Firefox](https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_24x24.png) | ![Opera](https://raw.githubusercontent.com/alrra/browser-logos/master/src/opera/opera_24x24.png) | ![Android WebView](https://raw.githubusercontent.com/alrra/browser-logos/master/src/android-webview-beta/android-webview-beta_24x24.png) | ![Safari](https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_24x24.png) |
| --- | --- | --- | --- | --- |
| 56+ ✔ | 44+ ✔ | 43+ ✔ | 67+ ✔ | 12.1+ ✔ |

The REST surface is **not** bundled: the SDK fetches
[the OpenAPI spec](https://api.reactoo.com/v3/swagger.json) on load and builds
`client.apis.<tag>.<operationId>` from it, so new endpoints appear without an SDK upgrade.

## Installation

```bash
npm install --save @reactoo/watchtogether-sdk-js
```

```html
<script src="https://unpkg.com/@reactoo/watchtogether-sdk-js"></script>
```

## Quick start

```js
// 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`](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 ◄──┘
```

```js
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:

```js
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.

```js
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:

```js
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:

```js
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:

```js
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.

```js
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 feeds** — `add…`/`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](#error-handling) |

**Application** — `chatMessage`, `userUpdate`, `remoteMuted`, `customData`, `scaling`,
`data`, `dataMessage`.

## Messaging

Two transports, both through the session:

```js
// 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:

```js
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:

```js
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:

```js
{ 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:

```js
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`:

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

## API reference

**Factory** — `WatchTogetherSDK({ 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()` |

**Session** — `connect({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 spec](https://api.reactoo.com/v3/swagger.json) — `wt.<tag>.<operationId>`.

## Flutter / mobile

A Dart port for Android and iOS lives in [`flutter/`](flutter/) and mirrors this SDK
file-for-file, with the same namespaces, method names and event strings. See
[`flutter/README.md`](flutter/README.md) for the mobile-specific parts and
[`flutter/PLATFORM_GAPS.md`](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](https://reactoo.com).

---
_Source: https://npm.io/package/@reactoo/watchtogether-sdk-js · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
