Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

🤝Rails implementation of a WebRTC Signaling Server

NotificationsYou must be signed in to change notification settings

jeanpaulsio/action-cable-signaling-server

Repository files navigation

Action Cable Signaling Server

A Rails implementation of a signaling server for WebRTC apps leveraging Action Cable instead of Socket.io

Resources

I'd highly recommend reading through some of the WebRTC documentation from MDN and Google.


2020 Update

Update May 27, 2020

You're probably here because you've done a little bit of research and you already know that you want to build a signaling server with Rails. The goal of this repository is to help you get a basic signaling server up and running using vanilla JS. In the future, I'd like to see how we might use different front-end technologies alongside this implementation. I'm particularly excited about:

The DIY section of this readme is now updated for Rails 6 + Webpacker. If you're looking for details on implementation for Rails 5, clickhere.

Problem

WebRTC is hard enough as it is. You want to implement real-time communication in your Rails app (video chat, screensharing, etc) but all of the examples online use socket.io. But you're a Rails dev! You don't want to spin up a Node server and create an Express app just for this feature.

Solution

We can broadcast messages and take care of the signaling handshake 🤝 between peers (aka the WebRTC dance) using Action Cable.

Known Bugs 🐛

Right now this example only works in Google Chrome. PR's welcome to get this up and running in FireFox and Safari!f2a950 Thank you,@gobijan

DIY Approach

Here, I'll walk you through implementing your ownsignaling server in Rails.

In this example, we'll make a video chat app. However, WebRTC can do more than that! Once your signaling server is set up, it's possible to extend your app to support other cool stuff like screen sharing.

We're going to be creating a few files for this.

├── app│   ├── javascript│   │   └── signaling_server.js│   ├── channels│   │   └── session_channel.rb│   ├── controllers│   │   └── sessions_controller.rb│   │   └── pages_controller.rb│   ├── views│   │   ├── pages│   │   │   └── home.html.erb
  • signaling_server.js - Holds all of our WebRTC JS logic. We'll also be broadcasting data to our backend using JavaScript'sfetch API. Data will be broadcasted with Action Cable.
  • session_channel.rb - Subscribes a user to a particular channel. In this case,session_channel.
  • sessions_controller.rb - Endpoint that will broadcast data.
  • pages_controller.rb - Will house our video stream. Nothing special about this.
  • home.html.erb - Corresponding view topages#home.

Routes

# config/routes.rbRails.application.routes.drawdoroot'pages#home'post'/sessions',to:'sessions#create'mountActionCable.server,at:'/cable'end

Our routes will look something like this. We haven't done anything with Action Cable just yet, but do take note that we mount the server in our routes.

Scaffolding out the View

<!-- app/views/pages/home.html.erb --><h1>Action Cable Signaling Server</h1><div>Random User ID:<spanid="current-user"><%= @random_number %></span></div><divid="remote-video-container"></div><videoid="local-video"autoplay></video><hr/><buttonid="join-button">  Join Room</button><buttonid="leave-button">  Leave Room</button>

The reason we have@random_number is because each user should have a unique identifier when joining the room. In a real app, this could be something like@user.id orcurrent_user.id.

ThePagesController is super simple:

# app/controllers/pages_controller.rbclassPagesController <ApplicationControllerdefhome@random_number=rand(0...10_000)endend

Action Cable Setup

We'll create two files for this

# app/channels/session_channel.rbclassSessionChannel <ApplicationCable::Channeldefsubscribedstream_from"session_channel"enddefunsubscribed# Any cleanup needed when channel is unsubscribedendend
# app/controllers/sessions_controller.rbclassSessionsController <ApplicationControllerdefcreatehead:no_contentActionCable.server.broadcast"session_channel",session_paramsendprivatedefsession_paramsparams.require(:session).permit(:type,:from,:to,:sdp,:candidate)endend

session_params should give you insight as to what we're broadcasting in order to complete the WebRTC dance.

signaling_server.js

We'll test our Action Cable connection before diving into the WebRTC portion

// app/javascript/signaling_server.jsimportconsumerfrom"./channels/consumer";// file generated@rails/actioncableconsthandleJoinSession=async()=>{consumer.subscriptions.create("SessionChannel",{connected:()=>{broadcastData({type:"initiateConnection"});},received:data=>{console.log("RECEIVED:",data);}});};consthandleLeaveSession=()=>{};constbroadcastData=(data)=>{/**   * Add CSRF protection: https://stackoverflow.com/questions/8503447/rails-how-to-add-csrf-protection-to-forms-created-in-javascript   */constcsrfToken=document.querySelector("[name=csrf-token]").content;constheaders=newHeaders({"content-type":"application/json","X-CSRF-TOKEN":csrfToken,});fetch("sessions",{method:"POST",body:JSON.stringify(data),    headers,});};

We're doing a couple things here. ThebroadcastData function is just a wrapper around JavaScript'sfetch API. When we press "Join Room" in our view, we invokehandleJoinSession() which creates a subscription toSessionChannel.

Once a user connects, wePOST to sessions an object. Remember, we whitelisted:type so our"initiateConnection" value will be accepted.

If you take a peek at your running server, you should see something like:

[ActionCable] Broadcasting to session_channel: <ActionController::Parameters {"type"=>"initiateConnection"} permitted: true>

If you open up your console via dev tools, you should see this message:

RECEIVED: {type: "initiateConnection"}

We are seeing this because our received method will log out data that is received from the subscription. If you see that, congrats! You're now able to send and receive data. This is the foundation for the WebRTC dance and is paramount for our signaling server.

More WebRTC setup

Here's a commented out skeleton of oursignaling_server.js file

importconsumerfrom"./channels/consumer";// Broadcast TypesconstJOIN_ROOM="JOIN_ROOM";constEXCHANGE="EXCHANGE";constREMOVE_USER="REMOVE_USER";// DOM ElementsletcurrentUser;letlocalVideo;letremoteVideoContainer;// ObjectsletpcPeers={};letlocalstream;window.onload=()=>{currentUser=document.getElementById("current-user").innerHTML;localVideo=document.getElementById("local-video");remoteVideoContainer=document.getElementById("remote-video-container");};// Ice Credentialsconstice={iceServers:[{urls:"stun:stun.l.google.com:19302"}]};// Add event listener's to buttons// We need to do this now that our JS isn't handled by the asset pipelinedocument.addEventListener("DOMContentLoaded",()=>{constjoinButton=document.getElementById("join-button");constleaveButton=document.getElementById("leave-button");joinButton.onclick=handleJoinSession;leaveButton.onclick=handleLeaveSession;});// Initialize user's own videodocument.onreadystatechange=()=>{if(document.readyState==="interactive"){navigator.mediaDevices.getUserMedia({/**         * If you're testing locally in two separate browser windows, setting audio         * to `true` will result in horrible feedback. I'd recommend setting         * `audio: false` while you test.         */audio:true,video:true,}).then((stream)=>{localstream=stream;localVideo.srcObject=stream;localVideo.muted=true;}).catch(logError);}};consthandleJoinSession=async()=>{// connect to Action Cable// Switch over broadcasted data.type and decide what to do from there};consthandleLeaveSession=()=>{// leave session};constjoinRoom=(data)=>{// create a peerConnection to join a room};constremoveUser=(data)=>{// remove a user from a room};constcreatePC=(userId,isOffer)=>{// new instance of RTCPeerConnection// potentially create an "offer"// exchange SDP// exchange ICE// add stream// returns instance of peer connection};constexchange=(data)=>{// add ice candidates// sets remote and local description// creates answer to sdp offer};constbroadcastData=(data)=>{/**   * Add CSRF protection: https://stackoverflow.com/questions/8503447/rails-how-to-add-csrf-protection-to-forms-created-in-javascript   */constcsrfToken=document.querySelector("[name=csrf-token]").content;constheaders=newHeaders({"content-type":"application/json","X-CSRF-TOKEN":csrfToken,});fetch("sessions",{method:"POST",body:JSON.stringify(data),    headers,});};constlogError=error=>console.warn("Whoops! Error:",error);

And here's our final JS

// app/javascript/signaling_server.jsimportconsumerfrom"./channels/consumer";// Broadcast TypesconstJOIN_ROOM="JOIN_ROOM";constEXCHANGE="EXCHANGE";constREMOVE_USER="REMOVE_USER";// DOM ElementsletcurrentUser;letlocalVideo;letremoteVideoContainer;// ObjectsletpcPeers={};letlocalstream;window.onload=()=>{currentUser=document.getElementById("current-user").innerHTML;localVideo=document.getElementById("local-video");remoteVideoContainer=document.getElementById("remote-video-container");};// Ice Credentialsconstice={iceServers:[{urls:"stun:stun.l.google.com:19302"}]};// Add event listener's to buttonsdocument.addEventListener("DOMContentLoaded",()=>{constjoinButton=document.getElementById("join-button");constleaveButton=document.getElementById("leave-button");joinButton.onclick=handleJoinSession;leaveButton.onclick=handleLeaveSession;});// Initialize user's own videodocument.onreadystatechange=()=>{if(document.readyState==="interactive"){navigator.mediaDevices.getUserMedia({audio:true,video:true,}).then((stream)=>{localstream=stream;localVideo.srcObject=stream;localVideo.muted=true;}).catch(logError);}};consthandleJoinSession=async()=>{consumer.subscriptions.create("SessionChannel",{connected:()=>{broadcastData({type:JOIN_ROOM,from:currentUser,});},received:(data)=>{console.log("received",data);if(data.from===currentUser)return;switch(data.type){caseJOIN_ROOM:returnjoinRoom(data);caseEXCHANGE:if(data.to!==currentUser)return;returnexchange(data);caseREMOVE_USER:returnremoveUser(data);default:return;}},});};consthandleLeaveSession=()=>{for(letuserinpcPeers){pcPeers[user].close();}pcPeers={};remoteVideoContainer.innerHTML="";broadcastData({type:REMOVE_USER,from:currentUser,});};constjoinRoom=(data)=>{createPC(data.from,true);};constremoveUser=(data)=>{console.log("removing user",data.from);letvideo=document.getElementById(`remoteVideoContainer+${data.from}`);video&&video.remove();deletepcPeers[data.from];};constcreatePC=(userId,isOffer)=>{letpc=newRTCPeerConnection(ice);pcPeers[userId]=pc;for(consttrackoflocalstream.getTracks()){pc.addTrack(track,localstream);}isOffer&&pc.createOffer().then((offer)=>{returnpc.setLocalDescription(offer);}).then(()=>{broadcastData({type:EXCHANGE,from:currentUser,to:userId,sdp:JSON.stringify(pc.localDescription),});}).catch(logError);pc.onicecandidate=(event)=>{event.candidate&&broadcastData({type:EXCHANGE,from:currentUser,to:userId,candidate:JSON.stringify(event.candidate),});};pc.ontrack=(event)=>{constelement=document.createElement("video");element.id=`remoteVideoContainer+${userId}`;element.autoplay="autoplay";element.srcObject=event.streams[0];remoteVideoContainer.appendChild(element);};pc.oniceconnectionstatechange=()=>{if(pc.iceConnectionState=="disconnected"){console.log("Disconnected:",userId);broadcastData({type:REMOVE_USER,from:userId,});}};returnpc;};constexchange=(data)=>{letpc;if(!pcPeers[data.from]){pc=createPC(data.from,false);}else{pc=pcPeers[data.from];}if(data.candidate){pc.addIceCandidate(newRTCIceCandidate(JSON.parse(data.candidate))).then(()=>console.log("Ice candidate added")).catch(logError);}if(data.sdp){constsdp=JSON.parse(data.sdp);pc.setRemoteDescription(newRTCSessionDescription(sdp)).then(()=>{if(sdp.type==="offer"){pc.createAnswer().then((answer)=>{returnpc.setLocalDescription(answer);}).then(()=>{broadcastData({type:EXCHANGE,from:currentUser,to:data.from,sdp:JSON.stringify(pc.localDescription),});});}}).catch(logError);}};constbroadcastData=(data)=>{/**   * Add CSRF protection: https://stackoverflow.com/questions/8503447/rails-how-to-add-csrf-protection-to-forms-created-in-javascript   */constcsrfToken=document.querySelector("[name=csrf-token]").content;constheaders=newHeaders({"content-type":"application/json","X-CSRF-TOKEN":csrfToken,});fetch("sessions",{method:"POST",body:JSON.stringify(data),    headers,});};constlogError=(error)=>console.warn("Whoops! Error:",error);

Deployment (Heroku)

You would deploy this app the same way you would any other Rails app that is using ActionCable.

Typical redis stuff

#Gemfilegem"redis"

Then

$ bundle install$ heroku create$ heroku addons:create redistogo

Addingredistogo will automatically add an environment variable to your project with the keyREDISTOGO_URL

# config/cable.ymlproduction:adapter:redisurl:<%= ENV.fetch("REDISTOGO_URL") { "redis://localhost:6379/1" } %>channel_prefix:action_cable_signaling_server_production
# config/environments/production.rbconfig.action_cable.url='wss://yourapp.herokuapp.com/cable'config.action_cable.allowed_request_origins=['*']
$ git add .$ git commit -m 'ready to ship'$ git push heroku master

License

MIT

Releases

No releases published

Packages

No packages published

[8]ページ先頭

©2009-2025 Movatter.jp