Skip to content

TypeScript ​

openwatchparty keeps players in different apps in sync in one room. Each person in the room plays their own copy of the title, and a relay keeps the copies together.

The SDK implements the client behaviour in spec/SDK.md. This page names the rules the SDK follows, and SDK.md explains them in full.

Install ​

sh
npm install openwatchparty

Requirements ​

The package runs in Node and in browsers from these versions on:

RuntimeMinimum
Node22
Chrome / Edge119
Firefox121
Safari17.4

The package is ESM only, with no require condition, and it has no runtime dependencies. TypeScript consumers need the dom lib, or skipLibCheck. A global WebSocket must exist, even when you pass a custom connect.

Quick start ​

This example runs in a browser page with a <video> element. It joins the room named in the page's room query parameter. Without one, it creates a room and shares the video as host. Whenever the SDK chooses a file, the example loads it into the <video> and attaches the element with attachVideo.

ts
import { Relays, WatchParty, attachVideo, formatCode, type Resolver } from "openwatchparty";

// One relay list for the whole app, passed to every party.
const relays = Relays.public();

// The app's search: the files the user could play for the room's title, best first.
const resolver: Resolver<string> = {
  async resolve(media, signal) {
    const res = await fetch(`/api/search?title=${encodeURIComponent(media.title)}`, { signal });
    const files: { id: string; url: string; durationMs: number }[] = await res.json();
    // ref: the absolute URL, as video.src reads it back
    return files.map((f) => ({ id: f.id, ref: new URL(f.url, document.baseURI).href, durationMs: f.durationMs }));
  },
};

const party = new WatchParty<string>({ relays, client: "Example/1.0", name: "Ann", resolver });
const video = document.querySelector("video")!;

// Open each choice, one at a time, and attach the <video> with its ref.
let attached: string | null = null; // the ref the <video> is attached with
let detach: (() => Promise<void>) | null = null;
let opening = false;
async function openChoice(): Promise<void> {
  if (opening) return; // the open under way goes on to the newest choice
  opening = true;
  try {
    while (true) {
      const ref = party.state.choice?.ref;
      if (ref === undefined || ref === attached) return; // no choice, or attached: it follows by itself
      if (video.src !== ref) {
        const { promise, resolve } = Promise.withResolvers<void>();
        const loading = new AbortController();
        for (const type of ["loadeddata", "error"]) video.addEventListener(type, () => resolve(), { signal: loading.signal });
        video.src = ref;
        await promise; // loaded, or failed to
        loading.abort(); // removes both listeners
      }
      if (party.state.choice?.ref !== ref) continue; // a newer choice came meanwhile: open that one
      const previous = detach;
      detach = attachVideo(party, video, ref);
      attached = ref;
      void previous?.(); // removes the earlier file's control listeners
    }
  } finally {
    opening = false;
  }
}
party.on("state", () => void openChoice());

party.on("event", (event) => {
  if (event.type !== "ended") return;
  console.log(`Out of the room: ${event.reason}`);
  void detach?.(); // the end released the <video>: its controls stop going to the party
  detach = attached = null;
});

const code = new URLSearchParams(location.search).get("room");
if (code !== null) {
  await party.join(code);
} else {
  console.log(`Room code: ${formatCode(await party.create())}`);
  // Host: share the file the <video> plays.
  party.setMedia(
    { kind: "movie", title: "Example Movie", year: 2024, ids: { tmdb: "12345" }, durationMs: Math.round(video.duration * 1000) },
    video.src,
    Math.floor(video.currentTime * 1000),
  );
}

examples/web is a full browser page built on attachVideo.

Relays ​

A Relays object tells your parties which relays exist and which one owns a room code. Create one for the whole app and pass it to every party, as the quick start does.

There are two kinds of list:

  • Relays.public() uses the public relays. It starts from the copy of relays.json compiled into the SDK. Each valid list a refresh reads from https://openwatch.party/relays.json replaces it in memory.
  • Relays.at(baseUrl) uses one self-hosted relay for every room. Its list is that relay alone, and it fetches nothing.

Relays.at needs a lower-case https:// or http:// URL with a host, such as https://relay.example.com. Any other base throws a PartyError with the code invalid_message.

MemberWhat it does
static public(options?: RelaysOptions): RelaysCreates a list of the public relays.
static at(baseUrl: string): RelaysCreates a list of one self-hosted relay.
list: readonly Relay[]The relays in use.
relayFor(code: string): Relay | nullFinds the relay that owns a room code.
refresh(): Promise<boolean>Fetches the public list now, and resolves with whether a valid list came back.

refresh() resolves false when no valid list came back, and the list in use stays. On a Relays.at list, it does nothing and resolves true.

RelaysOptions has one member, httpGet: (url: string, signal: AbortSignal) => Promise<string>. By default it is fetch with redirect: "error". A custom httpGet must follow these rules:

  • It stops when signal aborts.
  • It follows no redirects.
  • It fails on a non-200 status.

WatchParty ​

A WatchParty is one participant's session. It connects to a relay, keeps the room's state, matches the room's media to the user's own files and drives your player.

Creating a party ​

The constructor, new WatchParty<T>, takes a WatchPartyOptions<T> object with these options. T is the type of your app's file ref, the value your app uses to open a file.

OptionTypeWhat it is
relaysRelaysThe app's relay list. Required.
clientstringYour app and its version, as <app>/<version>, such as Example/1.0. Required.
namestringThe user's display name, 1–32 characters. Required.
resolverResolver<T>Your search for the user's own files. There is none by default.
connect(url: string) => WebSocketOpens a socket. Defaults to new WebSocket(url).
nowMs() => numberThe monotonic local clock. Defaults to performance.now().
random() => number in [0, 1)Draws random numbers. Defaults to Math.random.

The constructor throws a PartyError with the code invalid_message for a bad client or name. Without a resolver, other participants' media has no candidates, so match is unavailable.

A custom connect must return a socket that behaves like a browser WebSocket. The SDK uses its onopen, onmessage, onclose and onerror handlers, its readyState, send(string) and close(code).

State and session ​

MemberWhat it does
state: PartyState<T>Everything the UI shows, as one value.
on("state", listener)Calls listener with every new state, and returns a function that unsubscribes.
on("event", listener)Calls listener with every event, and returns a function that unsubscribes.
create(): Promise<string>Creates a room, joins it as host and resolves with the room code.
join(code: string): Promise<void>Joins a room by its code, as the user typed or pasted it.
leave(): voidLeaves the room.
close(): Promise<void>Leaves the room and releases everything.
roomPositionMs(): number | nullThe room's position now.

leave() during create or join cancels the attempt. An ended party may create or join again, but after close() the party can't be used again.

Matching and the player ​

MemberWhat it does
acceptMismatch(): voidPlays the offered file, though its duration differs.
declineMismatch(): voidDeclines the offered file.
choose(candidate: Candidate<T>): Promise<boolean>Takes a file the user picked by hand.
resolveAgain(): voidSearches again for the current media.
attachPlayer(player: PlayerAdapter, ref: T): voidLets the SDK drive your player, which has the file of ref open.
detachPlayer(player?: PlayerAdapter): Promise<void>Stops driving the player when the user leaves it.
playerChanged(): voidTells the SDK the player's state changed, so it reads it now.

choose resolves false when it can't take the file, including when the room has no media. Player and resolver explains how to use the player members.

Playback ​

These commands act on the whole room. Send the user's presses through them, not straight to the player.

MemberWhat it does
play(): voidAsks the room to play.
pause(): voidPauses the room.
seek(positionMs: number): voidMoves the room to a position in the app's own file.
setRate(rate: number): voidSets the room's rate, from 0.5 to 2.0.
setOffset(offsetMs: number): voidSets your local offset from the room's position.

The offset you set with setOffset applies only to your own player. It is an integer of at most 604800000 ms, or 7 days, either way.

Host controls ​

Only the host may use these.

MemberWhat it does
setMedia(media: Media, ref: T, positionMs?: number): voidShares a title, with ref for your own file of it.
clearMedia(): voidStops the media.
setControl(mode: Control): voidSets who may play, pause, seek and set the rate.
transferHost(peerId: string): voidMakes another participant the host.
kick(peerId: string): voidRemoves a participant from the room.
lock(locked: boolean): voidRefuses or allows new joins.
end(): voidEnds the room for everyone.

Party state ​

party.state is a new frozen PartyState object on every change.

PartyState<T> fieldWhat it holds
connectionThe session status.
peerIdYour own peer ID.
roomThe last snapshot of the room.
mediaThe current descriptor.
matchHow the room's media matched the user's files.
choiceThe file to open.
mismatchThe file offered while the user decides.
candidatesThe ranked files.
offsetMsYour local offset.
playerThe player state reported to the room.
endReasonWhy the party ended.
meYour own entry among the participants.
isHostWhether you are the host.
canControlWhether you may control playback.

Events ​

Events tell you what happens in the party. Each has a type:

typeCarriesWhen it comes
actionkind, byPeerId, by, byMe, roomA room change carried an action, such as someone pressing play.
participantJoinedparticipantSomeone joined the room.
participantLeftparticipantSomeone left the room.
errorerror: PartyErrorThe relay refused a command.
resolveFailederrorThe resolver failed.
playBlockederrorThe player refused to start.
endedreason, errorThe party ended.

A resolveFailed event carries what the resolver threw. After 30 seconds without an answer, it carries a DOMException named TimeoutError instead.

A later 1.x may add types, so ignore the ones you don't know.

Media descriptors ​

A Media value is a media descriptor: it names a title, and each participant finds their own copy of it. It holds kind, title and durationMs. It can also hold year, ids, series, season, episode, file and ext. The keys of ext are reverse-DNS names, such as com.example.

Player and resolver ​

Your app gives the party two pieces of its own: a player adapter for your player, and a resolver for your library.

Video and audio elements ​

For a <video> or <audio> element, use attachVideo(party, video, ref) instead of writing an adapter. Call it once per file, once the element has the file open. It does three things:

  1. It wraps the element in an adapter.
  2. It calls party.attachPlayer(adapter, ref), with the ref of the file the element has open.
  3. It routes the element's own controls to the party, and tells the user's presses apart from the SDK's commands.

A rate the SDK set on the element is never sent back to the room. While the user drags the seek bar, attachVideo sends at most one seek per 250 milliseconds, with the latest position. The position where the drag stops goes out within 250 milliseconds.

attachVideo returns a cleanup function, () => Promise<void>. The cleanup removes the listeners and calls party.detachPlayer(adapter), so it never detaches a newer player. It returns that call's completion: await it before you dispose of the element. After close(), it resolves at once.

videoAdapter(video) is the adapter alone, with no control routing. Use it on pages your app doesn't own, such as a browser extension's. The adapters of both functions throw from play when the element refuses to start.

The player adapter ​

For any other player, implement PlayerAdapter. Its reads must be cheap and synchronous.

MemberWhat it is
positionMsThe current position in your own file.
durationMsThe file's duration, or null when unknown.
pausedWhether the player is paused.
bufferingWhether the player stalled while trying to play.
firstFrameShownWhether the file is open and shows a frame.
play()Starts playing, and resolves once the player plays.
pause()Pauses, and resolves once the player has paused.
seek(positionMs)Seeks, and resolves once the seek has landed.
setRate(rate)Optional. Changes the speed.
seekAccuracyMsOptional. How far from the target a seek may land, such as your player's keyframe distance.

A command must finish or throw. Without setRate, the SDK keeps your player in step with seeks alone.

When the player refuses to start, play throws. The common case is a browser blocking autoplay. The party then reports loading and emits a playBlocked event carrying what play threw. Your app then asks the user to press play on the player itself.

Attaching and detaching ​

Call attachPlayer(player, ref) each time you open a file. Call it again after the party ends with an ended event, because the end releases the player.

As host, you can call setMedia and attach in either order. If a setMedia is lost to a dropped connection, the SDK sends it again after the resume, as long as the party is still host.

Call detachPlayer when the user leaves the player or the app goes to the background. Await it, or close(), before you dispose of a player.

When your player's state changes between ticks, call playerChanged() so the SDK reads it at once.

While the user is away, the quick start's openChoice() must not load a file or attach the element, because attaching would bring the user back. Add a flag that it checks before it loads a file and again before it attaches:

ts
let away = false;

async function leavePlayer(): Promise<void> {
  away = true;
  await detach?.();
}

function returnToPlayer(): void {
  away = false;
  detach = attached = null;
  void openChoice();
}

The resolver ​

The resolver finds the user's own copy of the room's title. A Resolver<T> has one method, resolve(media, signal), which returns Candidate<T>[] best first.

A candidate is { id, ref, durationMs?, sizeBytes?, oshash?, name?, probe? }. Its probe(signal) opens the file to learn its duration.

  • Deciding which files are the same title is the resolver's job, for every kind of media. The SDK ranks the candidates and never drops one.
  • Treat every value in the descriptor as an untrusted search term. Never use one as a path, URL, host or query fragment.

Helpers ​

HelperWhat it does
formatCodeFormats a room code for display, such as K7M-Q2X.
sameTitleTells whether two descriptors name the same title.
checkMediaChecks a descriptor before you send it, and returns the first broken rule or null.
parseMediaDecodes a descriptor and applies the checks a receiver makes.

Threading ​

Listeners run synchronously. on("state", …) and on("event", …) replay nothing, so a listener gets only what happens after it subscribes.

Errors ​

The SDK reports failures as a PartyError. Show the user your own text for each code, because message is meant for logs only.

FieldWhat it holds
codeThe error code.
messageText for logs only.
reThe type of the refused message.

The relay's error codes pass through as they are. The SDK adds three of its own: invalid_code, unreachable and cancelled.

Errors reach your app in different ways:

  • create and join reject with a PartyError.
  • Commands throw it synchronously.
  • The relay's refusals of commands arrive as error events.
  • The loss of the session arrives as an ended event.

When no answer comes within 10 seconds, the call fails with unreachable. The code invalid_message also covers a bad Relays.at base, a malformed joined and bad parseMedia input.

Build and test ​

Run these commands from sdk/typescript in a checkout:

sh
npm ci
npm run typecheck
npm run test:unit
npm run test:integration
npm test
npm run build

test:unit runs the unit, vector and embedded-list tests, and needs no Go. test:integration and npm test build owp-relay from relay/ with Go 1.26.