Migrating a telehealth application to Daily from OpenTok in 1 week

This post will walk you through our process of migrating our telehealth application, SimplyDoc, from OpenTok to Daily. SimplyDoc is a starter kit that can be used as a quick start to building a custom telehealth application.

For more general guidance about migrating from OpenTok to Daily, you can check out Daily's OpenTok transition guide. This migration took one person about seven days using Daily's reference docs. By following the new transition guide, the implementation time could be reduced to as little as four days.

SimplyDoc application constraints

SimplyDoc was built a few years ago and uses Node.js version 12.
daily-react couldn’t be used for this migration because it uses recoil and both packages require a peer of react@>=16.13.1

Getting started

We had to take two main components into account in the migration to Daily: SimplyDoc’s backend and frontend.

  • SimplyDoc’s  backend creates OpenTok session IDs and session tokens.
  • SimplyDoc’s frontend contains the client-facing application and uses data retrieved from the backend.

The following diagram shows the steps taken to change both of these components to use Daily:

Diagram of SimplyDoc backend and frontend conversion steps
Diagram of SimplyDoc backend and frontend conversion steps

Backend code migration

Identify creation of OpenTok sessions and tokens

SimplyDoc's existing code for creating OpenTok (OT) sessions and tokens needs to instead create Daily rooms and tokens. The original OpenTok code was as follows:

OpenTok session generation logic

const opentok = new OpenTok(
      process.env.TOKBOX_API_KEY,
      process.env.TOKBOX_API_SECRET
    );
    opentok.createSession({ mediaMode: ‘routed’ }, (err, session) => {
      if (err) {
        log(‘error’, { error: `getTokboxSession: ${err.message}` });
        reject(err);
        return;
      }

OpenTok token generation logic

const token = opentok.generateToken(session.sessionId);

Write functions to create rooms and tokens with Daily

Replacing OpenTok session and token creation logic with Daily’s rooms and meeting tokens was quite straightforward.

Daily logic to create a room

async function createDailyRoom() {
 const options = {};
 return new Promise((resolve, reject) => {
   return fetch(`https://api.daily.co/v1/rooms/`, {
     method: ‘POST’,
     body: JSON.stringify(options),
     headers: {
       ‘Content-Type’: ‘application/json’,
       Authorization: ‘Bearer ‘ + process.env.TOKBOX_API_KEY,
     },
   }).then(r => {
     console.log(‘Room Created’);
     return resolve(r.json());
   });
 });
}

Explaining the code above, we are using Daily’s REST API to create rooms. For the Authorization Bearer token, this request is using the TOKBOX_API_KEY  environment variable, which should be updated to the Daily API key. The Daily API key can be found in your developer dashboard.

Next, we wrote the logic to obtain meeting tokens for the new room.

Daily logic to obtain a meeting token

async function createDailyToken(roomName) {
 const options = {
   properties: {
     is_owner: true,
     room_name: roomName,
   },
 };
 const response = await fetch(`https://api.daily.co/v1/meeting-tokens/`, {
   method: ‘POST’,
   body: JSON.stringify(options),
   headers: {
     ‘Content-Type’: ‘application/json’,
     Authorization: `Bearer ${process.env.TOKBOX_API_KEY}`,
   },
 });

 return response.json();
}

The  createDailyToken()  function above is using Daily’s REST API to obtain a meeting token. In this case the token is for the owner, so the owner  property is specified during creation of the token. We specify the room name for which the token is to be issued using the  room_name property.

Replace OpenTok calls to use the migrated functions above

Instead of calling OpenTok library functions to create session IDs and tokens, we now use the Daily room and token creation code we wrote above. This allows us to obtain and store the Daily room URL instead of the OT session, and the Daily token instead of the OT token. The following is an example of this:

export function getTokboxSession() {
 return new Promise((resolve, reject) => {
   createDailyRoom().then(async room => {
     const response = await createDailyToken(room.name);
     const sessionId = room.url;
     resolve({ token: response.token, sessionId });
   });
 });
}

With this adjustment, the application is going to follow SimplyDoc’s normal code path, but with the room and token values that we need for Daily. In your application, you will eventually want to rename the getTokboxSession() function as well. Here, keeping the name allowed us to move on with the rest of the migration without increasing the diff with name changes in the rest of the code path.


Frontend migration

Device selection

Before joining a call, SimplyDoc has a preview page where you can select the camera and microphone you are going to use. SimplyDoc uses the browser native functions to get these devices, so no changes to this code were necessary.

If this was not the case, we could have used the getInputDevices()  method from daily-js or the useDevices hook from Daily React to get the participant’s available devices.

Network check

SimplyDoc has a network check implementation with OpenTok, which checks the quality of the user’s network. With Daily, the closest equivalent is getNetworkStats(). We removed the existing OpenTok logic:

OpenTok logic

this.otNetworkTest
           .testConnectivity()
           .then(results => {
             let networkCheck;
             if (results.success) {
               networkCheck = (
                 <Icon color=“green” name=“check circle” size=“large” />
               );
             } else {
               networkCheck = (
                 <Icon color=“red” name=“times circle” size=“large” />
               );
               console.log(results);
             }
             if (!this.isNetworkTestCanceled) {
               this.setState({ networkCheck });
               this.otNetworkTest
                 .testQuality(stats => {
                   if (!this.isNetworkTestCanceled) {
                     this.updateProgressBar();
                   }
                   return stats;
                 })
                 .then(results => {
                   if (!this.isNetworkTestCanceled) {
                     const parsedResults = parseNetworkStats(results);
                     this.setState({
                       audioStats: parsedResults.audio,
                       videoStats: parsedResults.video,
                       audioLoading: false,
                       videoLoading: false,
                       progress: 100,
                     });
                   }
                 })
                 .catch(error => {
                   console.log(‘OpenTok quality test error’, error);
                 });
             }
           })
           .catch(function (error) {
             console.log(‘OpenTok connectivity test error’, error);
           });
```javascript

We then replaced the above with the following call to `getNetworkStats()`. The information returned was not identical to OpenTok, but gives us an idea of the network quality. 

#### Daily logic
```javascript
const networkStatus = callObject.getNetworkStats();

Replace OpenTok session connection logic with Daily room join logic

We need to use Daily’s call object instance to connect to a room, so we created a state variable to store this call object:

callObject: null,

In order to connect to the call, we replaced the connect() method from OpenTok with the preAuth() method from Daily.

The previous OpenTok connection logic looked as follows:

OpenTok logic

this.otCore = new Core(otCoreOptions);
       logger.info(logHeaders.CONNECT_OPENTOK, {
         isAnonymous: this.isAnonymous(),
         userId: auth.viewer.userId,
         appointmentId: call.appointment.appointmentId,
         otSessionId: call.appointment.sessionId,
         variation: ‘attempt’,
       });
       this.otCore
         .connect()
         .then(() => {
           this.setState({ connected: true });
           logger.info(logHeaders.CONNECT_OPENTOK, {

We replaced the code above with the following:

Daily logic

const newCallObject = DailyIframe.createCallObject();
       newCallObject
         .preAuth({ url: call.appointment.sessionId })
         .then(response => {
           this.setState({
             connected: true,
             callObject: newCallObject,
           });

Above, we are calling Daily’s  preAuth()  method. After that, we set the callObject  state variable to the call object created using createCallObject().

To start the call, we replaced the  startCall()  method from OpenTok with the  join() method from Daily.

OpenTok startCall() logic

this.otCore
     .startCall({
       insertMode: ‘append’,
       facingMode: ‘user’,
       resolution: ‘1280x720’,
       fitMode,
       publishAudio: this.state.localAudioEnabled,
       publishVideo: this.state.localVideoEnabled,
       videoSource: this.state.videoInputDeviceId || true,
       audioSource: this.state.audioInputDeviceId || true,
     })
     .then(({ publishers, subscribers, meta }) => {
       if (domain.name.indexOf(‘meetem’) === -1) {
         this.setState({ timerStarted: true });
         this.startTimer();
       }

The code above was replaced with the following join() call:

Daily logic

this.state.callObject
     .join()
     .then(response => {
       this.handleNewParticipantsState();
       if (domain.name.indexOf(‘meetem’) === -1) {
         this.setState({ timerStarted: true });
         this.startTimer();
       }

To set the video and audio source devices, we used Daily’s setInputDevicesAsync() call object instance method:

Daily logic

this.state.callObject.setInputDevicesAsync({
     audioDeviceId: this.state.audioInputDeviceId,
     videoDeviceId: this.state.videoInputDeviceId,
   });

Having replaced the OpenTok code above with Daily logic, we were able to get the participants to join the call.

Replace OpenTok events with Daily events to manage participant state

In order to get participants’ presence information and other data, we need to listen for and handle relevant call events. OpenTok manages its call events differently than Daily, so we decided to remove all of that logic:

OpenTok event handling logic

const events = [
         ‘subscribeToCamera’,
         ‘unsubscribeFromCamera’,
         ‘subscribeToScreen’,
         ‘unsubscribeFromScreen’,
         ‘startScreenShare’,
         ‘endScreenShare’,
       ];
       events.forEach(event =>
         this.otCore.on(event, ({ publishers, subscribers, meta }) => {
           this.setState({ publishers, subscribers, meta });
           const subscriberCameras = subscribers.camera;
           const subscriberSip = subscribers.sip;
           for (var subscriber in subscriberCameras) {
             // eslint-disable-next-line no-prototype-builtins
             if (
               subscriberCameras.hasOwnProperty(subscriber) &&
               this.state.sipCall
             ) {
               subscribers.camera[subscriber].subscribeToAudio(false);
             }
           }
           for (var sip in subscriberSip) {
             // eslint-disable-next-line no-prototype-builtins
             if (subscriberSip.hasOwnProperty(sip) && this.state.sipCall) {
               subscribers.sip[sip].subscribeToAudio(false);
             }
           }
         })
       );

       this.otCore.on(‘streamCreated’, () => {
         this.sendStats(hash);
       });

       this.otCore.on(‘connectionCreated’, event => {
         const { connections } = this.props.call;
         const { connection } = event;
         const data = utils.getConnectionData(connection);
         if (
           !this.state.timerStarted &&
           (connections || []).length &&
           domain.name.indexOf(‘meetem’) !== -1
         ) {
           this.startTimer();
           this.setState({ timerStarted: true });
         }
         let d;
         for (const I in connections) {
           // eslint-disable-next-line no-prototype-builtins
           if (!connections.hasOwnProperty(i)) continue;
           d = utils.getConnectionData(connections[I]);
           if (
             data.user !== ‘guest’ &&
             d.userId === data.userId &&
             connection.connectionId ===
               this.otCore.getSession().connection.connectionId
           ) {
             event.preventDefault();
             this.disconnect(false);
             const errorModal = {
               isOpen: true,
               onRequestClose: () => {
                 redirectToHome();
               },
               title: I18n.t(‘videocall.multiple_calls_modal.title’),
               message: I18n.t(‘videocall.multiple_calls_modal.message’),
             };
             this.setState({ errorModal });
             return;
           }
         }
         actions.ADD_CONNECTION(event.connection);
         if (
           this.state.active &&
           connection.connectionId !==
             this.otCore.getSession().connection.connectionId
         ) {
           this.otCore.signal(‘activeConnection’, null, event.connection);
         }
         this.updateMicStatus(!this.state.localAudioEnabled);
       });

       this.otCore.on(‘connectionDestroyed’, event => {
         const { micStatusUsers } = this.state;
         micStatusUsers.delete(event.connection.connectionId);
         this.setState({ micStatusUsers });

         actions.REMOVE_CONNECTION(event.connection);
       });

       this.otCore.on(‘signal’, e => {
         if (
           e.from.connectionId ===
           this.otCore.getSession().connection.connectionId
         ) {
           return;
         }
         switch (e.type) {
           case ‘signal:callEnded’:
             this.unsubscribeToHistory();
             this.cancelFullScreen();
             this.disconnect();
             const currentUser = parseInt(auth.viewer.userId, 10);
             let errorModal;
             if (
               currentUser === call.appointment.userId ||
               currentUser === call.appointment.customerId
             ) {
               this.setState({ displayReviewModal: true, callError: null });
             } else if (
               currentUser &&
               currentUser !== call.appointment.userId &&
               currentUser !== call.appointment.customerId
             ) {
               errorModal = {
                 isOpen: true,
                 onRequestClose: () => {
                   this.disconnect();
                   redirectToHome();
                 },
                 title: I18n.t(‘videocall.end_of_call’),
                 message: I18n.t(‘videocall.call_finished_message’),
               };
               this.setState({ errorModal });
             } else {
               let message = `${I18n.t(
                 ‘videocall.would_you_like_to_be_part’
               )} ${domain.name}`;
               let okLabel = I18n.t(‘videocall.start_session’);
               let redirect = ‘/signup’;

               if (this.props.domain.soloDoc === ‘Y’) {
                 message = I18n.t(‘videocall.thanks_for_joining’);
                 okLabel = I18n.t(‘videocall.close’);
                 redirect = ‘/‘;
               }

               errorModal = {
                 isOpen: true,
                 onRequestClose: () => {
                   this.disconnect();
                   redirectToHome();
                 },
                 sendToSignup: () => {
                   this.disconnect();
                   redirectToHome({ toRoute: redirect });
                 },
                 title: I18n.t(‘videocall.end_of_call’),
                 message,
                 okLabel,
               };
               this.setState({ errorModal });
             }
             break;
           case ‘signal:activeConnection’:
             actions.SET_ACTIVE_CONNECTION(e.from.connectionId);
             break;
           case ‘signal:subscriberMicStatus’:
             const data = JSON.parse(e.data || ‘{}’);
             if (data.connectionId) {
               const { mute, user, connectionId, sipCall } = data;
               const { micStatusUsers } = this.state;
               micStatusUsers.set(connectionId, { user, mute, sipCall });
               this.setState({ micStatusUsers });
             }
             break;
           case ‘signal:weakConnection’:
             let userData;
             try {
               userData = JSON.parse(e.from.data);
               console.log(
                 ‘weak connection signal received from’,
                 userData.name
               );
             } catch (err) {
               userData = {
                 name: ‘Unknown’,
               };
               console.log(
                 ‘weak connection received from connection with no user data’
               );
             }
             const userName =
               userData.firstName || userData.name.split(‘ ‘)[0];
             this.showToast(userName + I18n.t(‘errors.other_weak_connection’));
             break;
         }
       });

       this.otCore.on(‘sessionReconnecting’, e => {
         // when client get disconnected
         this.showToast(I18n.t(‘errors.weak_connection’));
         this.otCore.signal(‘weakConnection’);
       });

       this.otCore.on(‘streamDestroyed’, e => {
         // when any client is having connection issues
         if (e.reason === NETWORK_DISCONNECTED) {
           console.log(‘streamDestroyed’, e);
           const otState = this.otCore.state();

           const publisher =
             otState.publishers.camera[
               Object.keys(otState.publishers.camera)[0]
             ] ||
             otState.publishers.screen[
               Object.keys(otState.publishers.screen)[0]
             ];

           if (publisher.stream.streamId === e.stream.streamId) {
             this.showToast(I18n.t(‘errors.weak_connection’));
             this.otCore.signal(‘weakConnection’);
           } else {
             const userData = JSON.parse(e.stream.connection.data);
             this.showToast(
               userData.name.split(‘ ‘)[0] +
                 I18n.t(‘errors.other_weak_connection’)
             );
           }
         }
       });

We deleted all of the OpenTok-specific code above from SimplyDoc. In order to implement equivalent behavior with Daily, we added a new state variable called participants:

participants: [],

This array variable will store the information about each participant in the call. It is updated using the following function:

Daily logic

handleNewParticipantsState = () => {
   const callParticipants = this.state.callObject.participants();
   let cParticipants = [];
   for (const [id, participant] of Object.entries(callParticipants)) {
     cParticipants.push({ …participant, id });
   }
   this.setState({
     participants: cParticipants,
   });
 };

The  handleNewParticipantsState()  function above calls Daily’s participants() method  to retrieve the latest information about each participant in the call.

Our  participants  state variable will be updated in response to the following Daily events:

The code to handle these events is as follows:

Daily event handling logic

const events = [
         ‘participant-joined’,
         ‘participant-updated’,
         ‘participant-left’,
       ];
       for (const event of events) {
         newCallObject.on(event, () => this.handleNewParticipantsState());
       }

Initially, we tried to match events between OpenTok and Daily, but there wasn’t 100% parity. It became much easier and cleaner to remove OpenTok code, its events, custom elements, etc, and write logic that fully utilized Daily from scratch.

Develop UI component(s) to manage video and audio tracks for call participants

OpenTok has options to specify the subscriber and publisher video containers. These are used to show the user’s video tracks. With Daily, we created one custom component to render participants’ video and audio tracks.

In this component, we pass a prop named  participant. This  object contains all relevant information about the participant, including their media tracks:

Daily logic

import React, { useEffect, useRef } from ‘react’;

export const VideoContainer = props => {
 const localVideoElement = useRef(null);
 const localAudioElement = useRef(null);
 const { participant, isSmall, hasAudio } = props;

 useEffect(() => {
   if (!participant) {
     return;
   }
   const audio = participant.tracks.audio;
   const video = participant.tracks.video;
   if (
     video &&
     video.persistentTrack &&
     localVideoElement &&
     localVideoElement.current
   ) {
     localVideoElement.current.srcObject = new MediaStream([
       video.persistentTrack,
     ]);
   }
   if (
     audio &&
     audio.persistentTrack &&
     localAudioElement &&
     localAudioElement.current
   ) {
     localAudioElement.current.srcObject = new MediaStream([
       audio.persistentTrack,
     ]);
   }
 }, [participant]);

 return (
   <React.Fragment>
     <video
       style={isSmall ? { width: ‘100px’ } : {}}
       autoPlay
       muted
       playsInline
       ref={localVideoElement}
     />
     {hasAudio ? <audio autoPlay playsInline ref={localAudioElement} /> : null}
   </React.Fragment>
 );
};

Using this component, we can show the participant’s video and, if needed, also play the audio track.

Conclusion

Changing OpenTok to Daily was not as complex as we first thought, even when using daily-js because of our application’s older React version (using daily-react would have been even easier).

This change can be made by any developer with a little knowledge of WebRTC. The most important thing is to know where the video and audio tracks are and how to add them to the code. Aside from the Daily reference docs, we used Daily’s demos to get more context and examples of API usage. Daily's OpenTok transition guide also provides a framework for this kind of migration.

Never miss a story

Get the latest direct to your inbox.