> Fetch clean Markdown by appending `.md` to any page URL under https://signalwire.com/docs or requesting it with the HTTP header `Accept: text/markdown`. The root index at https://signalwire.com/docs/llms.txt lists the available documentation indexes. # Migrate from v3 This guide walks through moving an existing v3 (`@signalwire/js@3.x`) integration to v4. v3 was built around `RoomSession` and event emitters for video conferencing. v4 unifies calling and conferencing under a single `Call` API, replaces event emitters with RxJS observables, and introduces a credential-provider auth model with automatic token refresh. ## At a glance | Concern | v3 | v4 | | --------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Initialization | `await SignalWire({ host, token })` (async factory) | `new SignalWire(credentialProvider)` (class constructor) | | Authentication | Token passed directly | `CredentialProvider` with auto-refresh (`StaticCredentialProvider`, custom) | | State | Event emitters — `roomObj.on('event', handler)` | RxJS observables — `call.status$.subscribe(handler)` | | Call controls | `roomObj.audioMute()`, `roomObj.videoMute()` | `call.self.toggleMute()`, `call.self.toggleMuteVideo()` | | Media rendering | `rootElement` passed to `dial()` — SDK manages the DOM | `` / `` components, or `localStream$`/`remoteStream$` | | Devices | `getCameraDevicesWithPermissions()`, `roomObj.updateCamera()` | `client.audioInputDevices$`, `self.selectAudioInputDevice()` | | Directory | Paginated API — `client.address.getAddresses({...})` | Observable directory — `client.directory.addresses$`, `loadMore()` | | Inbound calls | `client.online({ incomingCallHandlers })` | `client.session.incomingCalls$` (always active after register) | | Messaging | `client.conversation.sendMessage()` / `subscribe()` | `callAddress.sendText()` / `callAddress.textMessages$` | ## Feature compatibility v4 covers the bulk of v3, but some features are still in progress. Check this table before migrating. | Feature | v4 Status | Alternative | | --------------------- | --------------- | ---------------------------- | | Video rooms & calling | Implemented | — | | Participants & events | Implemented | — | | Layouts | Implemented | — | | Screen sharing | Implemented | — | | Mute/unmute | Implemented | — | | Device selection | Implemented | — | | DTMF | Implemented | — | | Hold/unhold | Implemented | — | | Recording | Not implemented | Use SWML or the REST API | | Streaming (RTMP) | Not implemented | Use the server-side REST API | | Playback | Not implemented | Use SWML | | Room locking | Not implemented | — | | Metadata (`setMeta`) | Not implemented | — | | Call transfer | Not implemented | — | If your application depends on recording, streaming, playback, room locking, metadata, or transfer, wait for these features to land before migrating. ## Migration checklist * [ ] Update the package and import paths * [ ] Replace `await SignalWire({ token })` with `new SignalWire(credentialProvider)` * [ ] Remove `rootElement` from `dial()` and attach media streams manually (or use web components) * [ ] Drop `node_id` / `userVariables` / `await call.start()` — handled by v4 internally * [ ] Convert `RoomSession` methods to `Call` / `call.self` equivalents * [ ] Replace `roomObj.on('event', ...)` with `call.eventName$.subscribe(...)` * [ ] Update `invite.accept` / `invite.reject` to `call.answer()` / `call.reject()` * [ ] Drop `client.online()` / `client.offline()` — registration is automatic (use `client.unregister()` to go offline) * [ ] Move screen share from the room to `call.self` * [ ] Swap `WebRTC.getCameras()` etc. for `client.videoInputDevices$` * [ ] Pass full `MediaDeviceInfo` objects (not bare `deviceId`) to device selectors * [ ] Replace `client.address.getAddresses()` with `client.directory.addresses$` * [ ] Replace `client.conversation` messaging with `callAddress.sendText()` / `textMessages$` * [ ] Add explicit cleanup: `call.hangup()`, `client.disconnect()`, `client.destroy()` ## Installation The package name is unchanged. Upgrade to the v4 major release: ```bash npm install @signalwire/js@latest ``` For the browser build: ```html ``` v4 ships as an ES module. If you bundled v3 as a CDN global, switch to module imports: ```html ``` ```js import { SignalWire, StaticCredentialProvider } from "@signalwire/js"; ``` ## Authentication v3 accepted a room token directly. v4 introduces a **CredentialProvider** that owns the token lifecycle — including scheduled refresh before expiry. Use **Subscriber Access Tokens (SAT)** for authenticated users and **Embed Tokens** for guest access. See [Authentication](/docs/browser-sdk/v4/guides/authentication) for the full reference. The SDK ships with `StaticCredentialProvider` for pre-obtained tokens (build-time SAT, server-rendered pages). For long-running apps, implement a custom provider that fetches and refreshes a SAT from your backend. ### Client Bound SAT (DPoP) When the SDK passes an `AuthenticateContext` with a DPoP key `fingerprint`, forward it to your token endpoint to request a **Client Bound SAT** with automatic refresh: ```js class UserCredentialProvider { async authenticate(context) { const response = await fetch("/api/subscriber/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fingerprint: context?.fingerprint }), }); const { token, expiresAt } = await response.json(); return { token, expiry_at: expiresAt }; } async refresh() { return this.authenticate(); } } ``` ## Client initialization v3 was an async factory. v4 is a synchronous constructor; connection happens automatically when you subscribe to the first observable. **Before (v3):** ```js const client = await SignalWire({ host, token: "", debug: { logWsTraffic: true }, }); ``` **After (v4):** ```js import { SignalWire, StaticCredentialProvider } from "@signalwire/js"; const credentials = new StaticCredentialProvider({ token: "" }); // Second argument carries v3-style options (debug, logLevel, custom logger, etc.) const client = new SignalWire(credentials, { logLevel: "debug", debug: { logWsTraffic: true }, }); client.ready$.subscribe((ready) => { if (ready) console.log("Client connected and authenticated"); }); client.errors$.subscribe((error) => { console.error("Client error:", error); }); ``` By default, the client connects and registers automatically on construction. Pass `skipConnection: true` or `skipRegister: true` if you want to drive that lifecycle yourself. ### Connection state v3 had no separate connection observable — the factory was the connect call. v4 exposes connection state reactively: ```js client.isConnected$.subscribe((connected) => { /* ... */ }); client.isRegistered$.subscribe((registered) => { /* ... */ }); client.ready$.subscribe((ready) => { /* connected + authenticated */ }); ``` ## Outbound calls v3 took an options object with `to`, `rootElement`, optional `nodeId` for routing, and `userVariables`, then required `await call.start()`. v4 takes the destination as the first argument, handles steering internally, and does **not** auto-attach media — you wire streams up yourself. **Before (v3):** ```js const call = await client.dial({ to: "/private/user1", rootElement: document.getElementById("container"), nodeId: steeringId, userVariables: { /* ... */ }, }); await call.start(); ``` **After (v4):** ```js const call = await client.dial("/private/user1", { audio: true, video: true, }); // No rootElement, no nodeId, no start() — routing is internal call.remoteStream$.subscribe((stream) => { if (stream) document.getElementById("remoteVideo").srcObject = stream; }); call.localStream$.subscribe((stream) => { if (stream) document.getElementById("localVideo").srcObject = stream; }); ``` ### Full call lifecycle ```js // v3 const call = await client.dial({ to, rootElement, nodeId, userVariables }); await call.start(); roomObj.on("room.joined", handler); roomObj.on("media.connected", handler); roomObj.hangup(); // v4 const call = await client.dial(address, { audio, video }); call.status$.subscribe(handler); // replaces on('room.joined') call.localStream$.subscribe(/* ... */); // replaces rootElement auto-render call.remoteStream$.subscribe(/* ... */); call.hangup(); ``` ## Inbound calls v3 used `client.online({ incomingCallHandlers })` with callbacks and an explicit `offline()`. v4 registers the user automatically on construction and exposes incoming calls as an observable — no online/offline toggle. To go offline for inbound calls, call `client.unregister()` (and `client.register()` to come back online). `answer()` and `reject()` are **synchronous** in v4 — no `await` needed. **Before (v3):** ```js await client.online({ incomingCallHandlers: { all: (notification) => { window.__invite = notification.invite; }, }, }); const call = await window.__invite.accept({ rootElement: document.getElementById("container"), }); await window.__invite.reject(); await client.offline(); ``` **After (v4):** ```js // Registration happens automatically on client construction. client.session.incomingCalls$.subscribe((calls) => { const ringing = calls.filter((c) => c.status === "ringing"); if (ringing.length > 0) showIncomingCallUI(ringing[0]); }); function acceptCall(call) { call.answer(); // synchronous call.remoteStream$.subscribe((stream) => { document.getElementById("remoteVideo").srcObject = stream; }); } function rejectCall(call) { call.reject(); // synchronous } // Equivalent to v3's client.offline() / client.online() await client.unregister(); await client.register(); ``` ## RoomSession → Call v3 distinguished between `CallFabricRoomSession` and `RoomSession`. v4 collapses both into a single `Call`, with self-participant controls moved off the room object onto `call.self`. | v3 | v4 | | --------------------------------------------- | --------------------------------------- | | `roomSession.audioMute()` | `call.self.mute()` | | `roomSession.audioUnmute()` | `call.self.unmute()` | | `roomSession.videoMute()` | `call.self.muteVideo()` | | `roomSession.videoUnmute()` | `call.self.unmuteVideo()` | | `roomSession.deaf()` | `call.self.toggleDeaf()` | | `roomSession.startScreenShare()` | `call.self.startScreenShare()` | | `roomSession.stopScreenShare()` | `call.self.stopScreenShare()` | | `roomSession.setMicrophoneVolume({ volume })` | `call.self.setAudioInputVolume(value)` | | `roomSession.setSpeakerVolume({ volume })` | `call.self.setAudioOutputVolume(value)` | ## Self participant `call.self` is a full participant object with reactive state. ```js const self = call.self; await self.mute(); await self.unmute(); await self.toggleMute(); await self.muteVideo(); await self.unmuteVideo(); await self.toggleMuteVideo(); await self.toggleDeaf(); // Sync access const isMuted = call.self?.audioMuted; const isVideoMuted = call.self?.videoMuted; // Reactive call.self$.subscribe((self) => { if (self) { self.audioMuted$.subscribe((muted) => updateMuteButton(muted)); } }); ``` ## Participants Event emitters are gone — participants are an observable list. Each participant also exposes individual observables for granular updates. **Before (v3):** ```js roomSession.on("member.joined", (member) => addParticipantToUI(member)); roomSession.on("member.left", (member) => removeParticipantFromUI(member)); roomSession.on("member.updated", handler); const members = roomSession.members; // flat objects with properties ``` **After (v4):** ```js // Full list (re-emits on every change) call.participants$.subscribe((participants) => { renderParticipantList(participants); }); // Individual events call.memberJoined$.subscribe((event) => addParticipantToUI(event.member)); call.memberLeft$.subscribe((event) => removeParticipantFromUI(event.member_id)); // Per-participant observables for fine-grained UI updates: // participant.name$ // participant.audioMuted$ // participant.videoMuted$ // participant.isTalking$ // participant.handraised$ // participant.deaf$ // participant.visible$ // participant.position$ const participants = call.participants; ``` ## Screen sharing Screen sharing moves from the room to `call.self`. ```js await call.self.startScreenShare(); await call.self.stopScreenShare(); call.self$.subscribe((self) => { if (self) { self.screenShareStatus$.subscribe((status) => { console.log("Screen share:", status); }); } }); ``` ## Layouts ```js // v3 roomObj.getLayoutList(); roomObj.setLayout({ name: layoutName }); roomObj.on("layout.changed", (event) => console.log(event.layout)); // v4 call.layouts$.subscribe((layouts) => console.log("Available:", layouts)); call.layout$.subscribe((layout) => console.log("Current:", layout)); await call.setLayout("grid", {}); await call.setLayout("highlight-1-active-4", { "participant-id": "reserved-1", }); ``` ## Recording and streaming Recording and streaming APIs are **not yet implemented** in v4. The observables exist for monitoring server-initiated state, but `startRecording()` and `startStreaming()` will throw. Drive these from SWML or the server-side REST API in the meantime. ```js // State observable (for server-initiated recordings) call.recording$.subscribe((isRecording) => updateRecordingIndicator(isRecording)); const isRecording = call.recording; ``` ## Device management The standalone `WebRTC` namespace and `roomObj.updateCamera()`-style methods are removed. Devices live on the client as reactive lists that auto-update when devices are plugged in or removed. > **Heads up:** v4 device selectors take the full `MediaDeviceInfo` object, not just a `deviceId` string. **Before (v3):** ```js import { WebRTC } from "@signalwire/js"; enumerateDevices(); getCameraDevicesWithPermissions(); createDeviceWatcher(); // for change detection await WebRTC.getCameras(); await WebRTC.getMicrophones(); await WebRTC.getSpeakers(); await WebRTC.checkCameraPermissions(); roomObj.updateMicrophone({ deviceId }); roomObj.updateCamera({ deviceId }); ``` **After (v4):** ```js client.videoInputDevices$.subscribe((cameras) => populateCameraSelect(cameras)); client.audioInputDevices$.subscribe((mics) => populateMicSelect(mics)); client.audioOutputDevices$.subscribe((speakers) => populateSpeakerSelect(speakers)); // Pass the full MediaDeviceInfo, not just deviceId call.self.selectVideoInputDevice(deviceInfo); call.self.selectAudioInputDevice(deviceInfo); call.self.selectAudioOutputDevice(deviceInfo); // Sync access const cameras = client.videoInputDevices; ``` ## User info `Subscriber` is renamed to `User`. **Before (v3):** ```js const info = await client.getSubscriberInfo(); console.log("Logged in as:", info.name); ``` **After (v4):** ```js const user = client.user; user.fetched$.subscribe((fetched) => { if (fetched) { console.log("User ID:", user.id); console.log("Display name:", user.displayName); } }); ``` ## Directory v3's paginated `client.address.getAddresses()` is replaced by a reactive directory that accumulates entries on `loadMore()`. **Before (v3):** ```js const data = await client.address.getAddresses({ type, displayName, pageSize: 10, }); // data.data, data.hasNext, data.hasPrev, data.nextPage(), data.prevPage() ``` **After (v4):** ```js const directory = client.directory; directory.addresses$.subscribe((addresses) => { // Reactive list — accumulates as loadMore() is called addresses.forEach((addr) => console.log(addr.displayName, addr.type)); }); directory.hasMore$.subscribe((hasMore) => toggleLoadMoreButton(hasMore)); directory.loading$.subscribe((loading) => showSpinner(loading)); directory.loadMore(); // fetches and appends the next page ``` You can dial an address directly: ```js const address = client.directory.addresses.find((a) => a.name === "user1"); const call = await client.dial(address.defaultChannel, { video: true, audio: true }); // URI strings still work const call2 = await client.dial("/private/user1"); ``` ## Messaging v3's `client.conversation` API is replaced by per-address messaging on the call. **Before (v3):** ```js client.conversation.sendMessage({ addressId, text }); client.conversation.subscribe((newMsg) => { /* ... */ }); client.conversation.getConversationMessages({ addressId, pageSize }); ``` **After (v4):** ```js callAddress.sendText(text); // scoped to the call's address callAddress.textMessages$.subscribe((textMessagesCollection) => { textMessagesCollection.values$.subscribe((messages) => renderMessages(messages)); textMessagesCollection.hasMore$.subscribe((hasMore) => {}); textMessagesCollection.loadMore(); }); ``` Messages are scoped to the current call's address — there is no global conversation client in v4. ## Removed namespaces The standalone `Chat`, `PubSub`, and `WebRTC` clients from v3 are removed. Device APIs move onto the client (see [Device management](#device-management)). Chat/PubSub equivalents are not part of the v4 browser SDK. ## Event-to-observable reference > When using RxJS operators like `filter`, `map`, or `pipe`, import them from `rxjs`: > > ```js > import { filter, map } from "rxjs"; > ``` > > See the [RxJS primer](/docs/browser-sdk/v4/guides/rxjs-primer) for a quick orientation. | v3 Event | v4 Observable | | ------------------------- | ------------------------------------------------------ | | `member.joined` | `call.memberJoined$` | | `member.left` | `call.memberLeft$` | | `member.updated` | `call.memberUpdated$` | | `member.talking` | `call.memberTalking$` | | `layout.changed` | `call.layout$`, `call.layoutLayers$` | | `recording.started/ended` | `call.recording$` (state observable) | | `playback.started/ended` | Not available in the browser SDK (server-side only) | | `room.updated` | `call.meta$`, `call.locked$` | | `room.joined` | `call.status$.pipe(filter(s => s === 'connected'))` | | `room.left` | `call.status$.pipe(filter(s => s === 'disconnected'))` | ## API quick reference | v3 | v4 | | --------------------------------------------- | ------------------------------------------------------------- | | `SignalWire({ token })` | `new SignalWire(credentialProvider)` | | Ready callback | `client.ready$` (emits `true` when connected + authenticated) | | `client.dial({ to, rootElement })` | `client.dial(destination, options)` | | `client.online({ handlers })` | Automatic — subscribe to `client.session.incomingCalls$` | | `client.offline()` | `client.unregister()` (re-enable with `client.register()`) | | `invite.accept()` (async) | `call.answer()` (sync) | | `invite.reject()` (async) | `call.reject()` (sync) | | `roomSession.audioMute()` | `call.self.mute()` | | `roomSession.deaf()` | `call.self.toggleDeaf()` | | `roomSession.setMicrophoneVolume({ volume })` | `call.self.setAudioInputVolume(value)` | | `roomSession.setLayout(name)` | `call.setLayout(name, positions)` | | `roomSession.getLayoutList()` | `call.layouts$` | | `roomSession.members` | `call.participants` / `call.participants$` | | `roomSession.on('event', fn)` | `call.eventName$.subscribe(fn)` | | `client.updateToken(token)` | Handled by credential provider's `refresh()` | | `client.address.getAddresses()` | `client.directory.addresses$` + `directory.loadMore()` | | `client.conversation.sendMessage()` | `callAddress.sendText()` | | `roomSession.leave()` | `call.hangup()` | | Disconnect | `client.disconnect()` + `client.destroy()` | ## Cleanup v4 requires explicit cleanup. End calls with `hangup()`, then disconnect and destroy the client to release all subscriptions. ```js await call.hangup(); await client.disconnect(); // closes the WebSocket client.destroy(); // releases subscriptions and resources // Manual subscription cleanup, if needed const sub = call.status$.subscribe((status) => console.log(status)); sub.unsubscribe(); ``` ## Web components v4 ships `@signalwire/web-components`, composable around the new reactive Call API. `` is the root container — nest media, controls, and status components inside, then assign the call. ```html ``` ```js const call = await client.dial("/public/room"); const callMedia = document.getElementById("call-media"); callMedia.call = call; // Child components receive the call automatically via Lit context ``` `` renders participant overlays driven by the same context. ## Common migration issues 1. **No video displays.** v4 does not auto-attach to the DOM. Subscribe to `remoteStream$` (and `localStream$`) and assign the stream to a `