Add webrtc chat

This commit is contained in:
Jesse C. Fisher 2023-10-23 06:44:08 +00:00 committed by TheNeedful-DO
parent 43a71faddf
commit 929c98bb14
44 changed files with 14623 additions and 521 deletions

View File

@ -3,7 +3,7 @@ ruby '2.7.7'
gem 'rails', '~> 6.0'
# TODO Upgrade sprockets
gem 'redis', '~> 4.0'
gem 'sprockets', '~> 3.7'
gem 'sprockets' #, '~> 3.7'
gem 'sass-rails' #, '~> 4.0.3'
gem 'uglifier' #, '>= 1.3.0'
gem 'coffee-rails' #, '~> 4.0.0'

View File

@ -67,37 +67,38 @@ GEM
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
airbrussh (1.4.0)
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
airbrussh (1.5.0)
sshkit (>= 1.6.1, != 1.7.0)
ast (2.4.2)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
bcrypt (3.1.16)
base64 (0.1.1)
bcrypt (3.1.19)
bcrypt_pbkdf (1.1.0)
better_errors (2.9.1)
coderay (>= 1.0.0)
better_errors (2.10.1)
erubi (>= 1.0.0)
rack (>= 0.9.0)
rouge (>= 1.0.0)
binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1)
builder (3.2.4)
capistrano (3.16.0)
capistrano (3.18.0)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
sshkit (>= 1.9.0)
capistrano-bundler (2.0.1)
capistrano-bundler (2.1.0)
capistrano (~> 3.1)
capistrano-db-tasks (0.6)
capistrano (>= 3.0.0)
capistrano-postgresql (6.2.0)
capistrano (>= 3.0)
sshkit (>= 1.17.0)
capistrano-rails (1.6.1)
capistrano-rails (1.6.3)
capistrano (~> 3.1)
capistrano-bundler (>= 1.1, < 3)
capistrano-rails-console (2.3.0)
@ -110,7 +111,7 @@ GEM
capistrano (~> 3.7)
capistrano-bundler
puma (~> 3.4)
capybara (3.36.0)
capybara (3.39.2)
addressable
matrix
mini_mime (>= 0.1.3)
@ -119,12 +120,12 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
capybara-screenshot (1.0.25)
capybara-screenshot (1.0.26)
capybara (>= 1.0, < 4)
launchy
childprocess (4.1.0)
ci_reporter (2.0.0)
ci_reporter (2.1.0)
builder (>= 2.1.2)
rexml
ci_reporter_rspec (1.0.0)
ci_reporter (~> 2.0)
rspec (>= 2.14, < 4)
@ -137,30 +138,30 @@ GEM
execjs
coffee-script-source (1.12.2)
concurrent-ruby (1.2.2)
connection_pool (2.2.5)
connection_pool (2.4.1)
crass (1.0.6)
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
database_cleaner-active_record (2.0.1)
database_cleaner (2.0.2)
database_cleaner-active_record (>= 2, < 3)
database_cleaner-active_record (2.1.0)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.3.3)
debug_inspector (1.1.0)
devise (4.8.0)
devise (4.9.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise_invitable (2.0.5)
devise_invitable (2.0.8)
actionmailer (>= 5.0)
devise (>= 4.6)
diff-lcs (1.4.4)
diff-lcs (1.5.0)
docile (1.4.0)
dotenv (2.7.6)
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
dotenv (2.8.1)
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
railties (>= 3.2)
ed25519 (1.3.0)
em-websocket (0.5.3)
@ -169,21 +170,21 @@ GEM
erubi (1.12.0)
erubis (2.7.0)
eventmachine (1.2.7)
execjs (2.8.1)
execjs (2.9.1)
factory_girl (4.9.0)
activesupport (>= 3.0.0)
factory_girl_rails (4.9.0)
factory_girl (~> 4.9.0)
railties (>= 3.0.0)
faker (2.19.0)
i18n (>= 1.6, < 2)
ffi (1.15.4)
formatador (0.3.0)
faker (3.2.1)
i18n (>= 1.8.11, < 2)
ffi (1.16.3)
formatador (1.1.0)
friendly_id (5.4.2)
activerecord (>= 4.0.0)
globalid (1.2.1)
activesupport (>= 6.1)
guard (2.18.0)
guard (2.18.1)
formatador (>= 0.2.4)
listen (>= 2.7, < 4.0)
lumberjack (>= 1.0.12, < 2.0)
@ -192,8 +193,8 @@ GEM
pry (>= 0.13.0)
shellany (~> 0.0)
thor (>= 0.18.1)
guard-bundler (2.2.1)
bundler (>= 1.3.0, < 3)
guard-bundler (3.0.1)
bundler (>= 2.1, < 3)
guard (~> 2.2)
guard-compat (~> 1.1)
guard-compat (1.2.1)
@ -212,19 +213,19 @@ GEM
guard (~> 2.1)
guard-compat (~> 1.1)
rspec (>= 2.99.0, < 4.0)
haml (5.2.2)
temple (>= 0.8.0)
haml (6.2.3)
temple (>= 0.8.2)
thor
tilt
haml-rails (2.0.1)
haml-rails (2.1.0)
actionpack (>= 5.1)
activesupport (>= 5.1)
haml (>= 4.0.6, < 6.0)
html2haml (>= 1.0.1)
haml (>= 4.0.6)
railties (>= 5.1)
high_voltage (3.1.2)
html2haml (2.2.0)
html2haml (2.3.0)
erubis (~> 2.7.0)
haml (>= 4.0, < 6)
haml (>= 4.0)
nokogiri (>= 1.6.0)
ruby_parser (~> 3.5)
http_parser.rb (0.8.0)
@ -238,23 +239,26 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
interception (0.5)
jbuilder (2.11.2)
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jquery-rails (4.4.0)
jquery-rails (4.6.0)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
jquery-ui-rails (6.0.1)
railties (>= 3.2.16)
launchy (2.5.0)
addressable (~> 2.7)
listen (3.7.0)
json (2.6.3)
language_server-protocol (3.17.0.3)
launchy (2.5.2)
addressable (~> 2.8)
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.21.4)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
lumberjack (1.2.8)
lumberjack (1.2.9)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
@ -264,22 +268,22 @@ GEM
matrix (0.4.2)
method_source (1.0.0)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
mini_portile2 (2.8.5)
minitest (5.20.0)
multi_json (1.15.0)
nenv (0.3.0)
net-imap (0.4.1)
net-imap (0.4.2)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.1)
timeout
net-scp (3.0.0)
net-ssh (>= 2.6.5, < 7.0.0)
net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-smtp (0.4.0)
net-protocol
net-ssh (6.1.0)
net-ssh (7.2.0)
nio4r (2.5.9)
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
@ -288,13 +292,14 @@ GEM
nenv (~> 0.1)
shellany (~> 0.0)
orm_adapter (0.5.0)
parallel (1.21.0)
parallel_tests (3.7.3)
parallel (1.23.0)
parallel_tests (4.3.0)
parallel
parser (3.0.2.0)
parser (3.2.2.4)
ast (~> 2.4.1)
pg (1.2.3)
pry (0.14.1)
racc
pg (1.5.4)
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
pry-rails (0.3.9)
@ -302,13 +307,15 @@ GEM
pry-rescue (1.5.2)
interception (>= 0.5)
pry (>= 0.12.0)
public_suffix (4.0.6)
psych (5.1.1.1)
stringio
public_suffix (5.0.3)
puma (3.12.6)
pundit (2.1.1)
pundit (2.3.1)
activesupport (>= 3.0.0)
racc (1.7.1)
rack (2.2.8)
rack-livereload (0.3.17)
rack-livereload (0.5.1)
rack
rack-test (2.1.0)
rack (>= 1.3)
@ -341,64 +348,69 @@ GEM
method_source
rake (>= 12.2)
thor (~> 1.0)
rainbow (3.0.0)
rainbow (3.1.1)
rake (13.0.6)
rb-fchange (0.0.6)
ffi
rb-fsevent (0.11.0)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rbnacl (3.4.0)
ffi
rbnacl-libsodium (1.0.16)
rbnacl (>= 3.0.1)
rdoc (6.3.3)
react-rails (2.6.1)
rdoc (6.5.0)
psych (>= 4.0.0)
react-rails (3.1.1)
babel-transpiler (>= 0.7.0)
connection_pool
execjs
railties (>= 3.2)
tilt
redis (4.8.1)
regexp_parser (2.1.1)
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.5)
rspec (3.10.0)
rspec-core (~> 3.10.0)
rspec-expectations (~> 3.10.0)
rspec-mocks (~> 3.10.0)
rspec-core (3.10.1)
rspec-support (~> 3.10.0)
rspec-expectations (3.10.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0)
rspec-mocks (3.10.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0)
rspec-rails (5.0.2)
regexp_parser (2.8.2)
responders (3.1.1)
actionpack (>= 5.2)
activesupport (>= 5.2)
railties (>= 5.2)
rspec-core (~> 3.10)
rspec-expectations (~> 3.10)
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.10.3)
rubocop (1.22.3)
rexml (3.2.6)
rouge (4.1.3)
rspec (3.12.0)
rspec-core (~> 3.12.0)
rspec-expectations (~> 3.12.0)
rspec-mocks (~> 3.12.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-rails (6.0.3)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
rspec-core (~> 3.12)
rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-support (3.12.1)
rubocop (1.57.1)
base64 (~> 0.1.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.0.0.0)
parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.12.0, < 2.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.13.0)
parser (>= 3.0.1.1)
ruby-progressbar (1.11.0)
ruby_parser (3.18.1)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0)
parser (>= 3.2.1.0)
ruby-progressbar (1.13.0)
ruby_parser (3.20.3)
sexp_processor (~> 4.16)
rubyzip (2.3.2)
sass-rails (6.0.0)
@ -411,43 +423,44 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
sdoc (2.2.0)
sdoc (2.6.1)
rdoc (>= 5.0)
selenium-webdriver (4.0.3)
childprocess (>= 0.5, < 5.0)
selenium-webdriver (4.9.0)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2)
sexp_processor (4.16.0)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sexp_processor (4.17.0)
shellany (0.0.1)
simple_form (5.1.0)
simple_form (5.3.0)
actionpack (>= 5.2)
activemodel (>= 5.2)
simplecov (0.21.2)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.3)
spring (3.0.0)
simplecov_json_formatter (0.1.4)
spring (4.1.1)
spring-commands-rspec (1.0.4)
spring (>= 0.9.1)
sprockets (3.7.2)
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
sshkit (1.21.2)
sshkit (1.21.5)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
sshkit-interactive (0.3.0)
sshkit (~> 1.12)
stimulus-rails (1.3.0)
railties (>= 6.0.0)
temple (0.8.2)
thor (1.2.2)
tilt (2.0.10)
stringio (3.0.8)
temple (0.10.3)
thor (1.3.0)
tilt (2.3.0)
timeout (0.4.0)
turbo-rails (1.5.0)
actionpack (>= 6.0.0)
@ -457,13 +470,14 @@ GEM
concurrent-ruby (~> 1.0)
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
unicode-display_width (2.1.0)
unicode-display_width (2.5.0)
warden (1.2.9)
rack (>= 2.0.9)
webdrivers (5.0.0)
webdrivers (5.3.1)
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (~> 4.0)
selenium-webdriver (~> 4.0, < 4.11)
websocket (1.2.10)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@ -540,7 +554,7 @@ DEPENDENCIES
simplecov
spring
spring-commands-rspec
sprockets (~> 3.7)
sprockets
stimulus-rails
turbo-rails
uglifier

View File

@ -0,0 +1,6 @@
//= link gems.js
//= link jquery.js
//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/assets/javascripts .js

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
import { Application } from '@hotwired/stimulus'
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }

View File

@ -0,0 +1,5 @@
import { application } from 'controllers/application'
// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from '@hotwired/stimulus-loading'
eagerLoadControllersFrom('controllers', application)

View File

@ -0,0 +1,17 @@
import { Controller } from '@hotwired/stimulus'
export default class MediumController extends Controller {
connect () {
this.reRenderMediaElement()
}
// Fix potentially blank videos due to autoplay rules?
reRenderMediaElement () {
const mediaElement = this.mediaElementTarget
const clone = mediaElement.cloneNode(true)
mediaElement.parentNode.insertBefore(clone, mediaElement)
mediaElement.remove()
}
}
MediumController.targets = ['mediaElement']

View File

@ -0,0 +1,147 @@
import { Controller } from '@hotwired/stimulus'
import Client from 'models/client'
import WebrtcNegotiation from 'models/webrtc_negotiation'
import RoomSubscription from 'subscriptions/room_subscription'
import Signaller from 'subscriptions/signaling_subscription'
export default class RoomController extends Controller {
connect() {
this.clients = {}
this.client = new Client(this.clientIdValue)
this.subscription = new RoomSubscription({
delegate: this,
id: this.idValue,
clientId: this.client.id
})
this.signaller = new Signaller({
delegate: this,
id: this.idValue,
clientId: this.client.id
})
this.client.on('iceConnection:checking', ({ detail: { otherClient } }) => {
this.startStreamingTo(otherClient)
})
}
async enter () {
try {
const constraints = { audio: true, video: true }
this.client.stream = await navigator.mediaDevices.getUserMedia(constraints)
this.localMediumTarget.srcObject = this.client.stream
this.localMediumTarget.muted = true // Keep muted on Firefox
this.enterTarget.hidden = true
this.subscription.start()
this.signaller.start()
} catch (error) {
console.error(error)
}
}
greetNewClient ({ from }) {
const otherClient = this.findOrCreateClient(from)
otherClient.newcomer = true
this.subscription.greet({ to: otherClient.id, from: this.client.id })
}
remoteMediumTargetConnected (element) {
const clientId = element.id.replace('medium_', '')
this.negotiateConnection(clientId)
}
remoteMediumTargetDisconnected (element) {
const clientId = element.id.replace('medium_', '')
this.teardownClient(clientId)
}
negotiateConnection (clientId) {
const otherClient = this.findOrCreateClient(clientId)
// Be polite to newcomers!
const polite = !!otherClient.newcomer
otherClient.negotiation = this.createNegotiation({ otherClient, polite })
// The polite client sets up the negotiation last, so we can start streaming
// The impolite client signals to the other client that it's ready
if (polite) {
this.startStreamingTo(otherClient)
} else {
this.subscription.greet({ to: otherClient.id, from: this.client.id })
}
}
teardownClient (clientId) {
this.clients[clientId].stop()
delete this.clients[clientId]
}
createNegotiation ({ otherClient, polite }) {
const negotiation = new WebrtcNegotiation({
signaller: this.signaller,
client: this.client,
otherClient: otherClient,
polite
})
otherClient.on('track', ({ detail }) => {
this.startStreamingFrom(otherClient.id, detail)
})
return negotiation
}
startStreamingTo (otherClient) {
this.client.streamTo(otherClient)
}
startStreamingFrom (id, { track, streams: [stream] }) {
const remoteMediaElement = this.findRemoteMediaElement(id)
if (!remoteMediaElement.srcObject) {
remoteMediaElement.srcObject = stream
}
}
findOrCreateClient (id) {
return this.clients[id] || (this.clients[id] = new Client(id))
}
findRemoteMediaElement (clientId) {
const target = this.remoteMediumTargets.find(
target => target.id === `medium_${clientId}`
)
return target ? target.querySelector('video') : null
}
negotiationFor (id) {
return this.clients[id].negotiation
}
// RoomSubscription Delegate
roomPinged (data) {
this.greetNewClient(data)
}
// Signaler Delegate
sdpDescriptionReceived ({ from, description }) {
this.negotiationFor(from).setDescription(description)
}
iceCandidateReceived ({ from, candidate }) {
this.negotiationFor(from).addCandidate(candidate)
}
negotiationRestarted ({ from }) {
const negotiation = this.negotiationFor(from)
negotiation.restart()
negotiation.createOffer()
}
}
RoomController.values = { id: String, clientId: String }
RoomController.targets = ['localMedium', 'remoteMedium', 'enter']

View File

@ -17,10 +17,13 @@
// require react
// require react_ujs
// require components
//= require qrcode.min.js
// require qrcode.min.js
//= require eyecandy
//= require cable
//= require bundle
// require cable
// require_tree ./subscriptions
// require_tree ./models
// require_tree ./libraries
// require_tree ./controllers
// require react-rails-hot-loader
// require_tree .

View File

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
export default class Client {
constructor (id) {
this.callbacks = {}
this.id = id
}
get peerConnection () {
return this.negotiation && this.negotiation.peerConnection
}
streamTo (otherClient) {
if (!otherClient.streaming) {
this.stream.getTracks().forEach(track => {
otherClient.peerConnection.addTrack(track, this.stream)
})
otherClient.streaming = true
}
}
on (name, callback) {
const names = name.split(' ')
names.forEach((name) => {
this.callbacks[name] = this.callbacks[name] || []
this.callbacks[name].push(callback)
})
}
broadcast (name, data) {
(this.callbacks[name] || []).forEach(
callback => callback.call(null, { type: name, detail: data })
)
}
off (name) {
if (name) return delete this.callbacks[name]
else this.callbacks = {}
}
stop () {
this.off()
this.negotiation.stop()
}
}

View File

@ -0,0 +1,183 @@
const RETRY_LIMIT = 10
const peerConnectionConfig = {
iceServers: [
{
urls: [
'stun:stun.l.google.com:19302',
'stun:global.stun.twilio.com:3478'
]
}
],
sdpSemantics: 'unified-plan'
}
export default class WebrtcNegotiation {
constructor ({ client, otherClient, polite, signaller }) {
this.client = client
this.otherClient = otherClient
this.polite = polite
this.signaller = signaller
this.makingOffer = false
this.isSettingRemoteAnswerPending = false
this.candidates = []
this.retryCount = 0
this.start()
}
async createOffer () {
if (!this.readyToMakeOffer) return
try {
this.makingOffer = true
await this.setLocalDescription(await this.peerConnection.createOffer())
} catch (error) {
console.error(error)
} finally {
this.makingOffer = false
}
}
async setDescription (description) {
try {
if (this.ignore(description)) return
await this.setRemoteDescription(description)
if (description.type === 'offer') {
await this.setLocalDescription(await this.peerConnection.createAnswer())
}
} catch (error) {
if (this.retryCount < RETRY_LIMIT) {
this.initiateManualRollback()
this.retryCount++
} else {
this.stop()
console.error(`Negotiation failed after ${this.retryCount} retries`)
}
}
}
async setLocalDescription (description) {
await this.peerConnection.setLocalDescription(description)
this.signaller.signal({
type: this.peerConnection.localDescription ? 'description' : undefined,
to: this.otherClient.id,
from: this.client.id,
description: this.peerConnection.localDescription
})
}
async setRemoteDescription (description) {
this.isSettingRemoteAnswerPending = description.type === 'answer'
await this.peerConnection.setRemoteDescription(description) // SRD rolls back as needed
this.addCandidates()
this.isSettingRemoteAnswerPending = false
}
addCandidate (candidate) {
this.candidates.push(candidate)
this.addCandidates()
}
// TODO try/catch and ignore failures if necessary?
addCandidates () {
if (this.peerConnection.remoteDescription) {
while (this.candidates.length) {
this.peerConnection.addIceCandidate(this.candidates.shift())
}
}
}
get readyToMakeOffer () {
return !this.makingOffer && this.peerConnection.signalingState === 'stable'
}
get readyToReceiveOffer () {
return !this.makingOffer && (
this.peerConnection.signalingState === 'stable' ||
this.isSettingRemoteAnswerPending
)
}
collides (description) {
return description.type === 'offer' && !this.readyToReceiveOffer
}
ignore (description) {
return !this.polite && this.collides(description)
}
initiateManualRollback() {
this.restart()
this.signaller.signal({
type: 'restart',
to: this.otherClient.id,
from: this.client.id
})
}
restart() {
this.stop()
this.start()
}
start () {
this.setupPeerConnection()
}
stop () {
this.makingOffer = false
this.isSettingRemoteAnswerPending = false
this.candidates = []
this.teardownPeerConnection()
this.otherClient.streaming = false
}
setupPeerConnection () {
this.peerConnection = new RTCPeerConnection(peerConnectionConfig)
this._onnegotiationneeded = () => this.createOffer()
this._onicecandidate = ({ candidate }) => {
this.signaller.signal({
type: candidate ? 'candidate' : undefined,
to: this.otherClient.id,
from: this.client.id,
candidate
})
}
this._oniceconnectionstatechange = (event) => {
this.client.broadcast(`iceConnection:${this.peerConnection.iceConnectionState}`, { otherClient: this.otherClient })
switch (this.peerConnection.iceConnectionState) {
case 'completed':
this.retryCount = 0
break;
case 'failed':
if ('restartIce' in this.peerConnection) this.peerConnection.restartIce()
break;
default:
return
}
}
this._ontrack = (event) => this.otherClient.broadcast('track', event)
this.peerConnection.addEventListener('negotiationneeded', this._onnegotiationneeded)
this.peerConnection.addEventListener('icecandidate', this._onicecandidate)
this.peerConnection.addEventListener('iceconnectionstatechange', this._oniceconnectionstatechange)
this.peerConnection.addEventListener('track', this._ontrack)
}
teardownPeerConnection () {
this.peerConnection.close()
this.peerConnection.removeEventListener('negotiationneeded', this._onnegotiationneeded)
this.peerConnection.removeEventListener('icecandidate', this._onicecandidate)
this.peerConnection.removeEventListener('iceconnectionstatechange', this._oniceconnectionstatechange)
this.peerConnection.removeEventListener('track', this._ontrack)
this.peerConnection = null
}
}

View File

@ -0,0 +1,37 @@
import { cable } from '@hotwired/turbo-rails'
export default class RoomSubscription {
constructor ({ delegate, id, clientId }) {
this.delegate = delegate
this.id = id
this.clientId = clientId
}
async start () {
const self = this
this.subscription = await cable.subscribeTo({
channel: 'RoomChannel',
id: this.id,
client_id: this.clientId
}, {
greet (data) {
this.perform('greet', data)
},
received (data) {
// Ignore self-sent data
if (data.from === self.clientId) return
if (data.type === 'ping') self.delegate.roomPinged(data)
}
})
this.started = true
}
greet (data) {
if (!this.started) return
this.subscription.greet(data)
}
}

View File

@ -0,0 +1,35 @@
import { cable } from '@hotwired/turbo-rails'
export default class SignalingSubscription {
constructor({ delegate, id, clientId }) {
this.callbacks = {}
this.delegate = delegate
this.id = id
this.clientId = clientId
}
async start () {
const self = this
this.subscription = await cable.subscribeTo({
channel: 'SignalingChannel',
id: this.id
}, {
received (data) {
const { to, from, type, description, candidate } = data
if (to != self.clientId) return
if (description) return self.delegate.sdpDescriptionReceived({ from, description })
if (candidate) return self.delegate.iceCandidateReceived({ from, candidate })
if (type === 'restart') return self.delegate.negotiationRestarted({ from })
}
})
this.started = true
}
signal (data) {
if (!this.started) return
this.subscription.perform('signal', data)
}
}

View File

@ -1,4 +1,15 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_client
def connect
self.current_client = find_client
end
private
def find_client
Client.new(id: cookies.encrypted[:client_id])
end
end
end

View File

@ -0,0 +1,29 @@
class RoomChannel < ApplicationCable::Channel
def subscribed
@room = find_room
stream_for @room
broadcast_to @room, { type: 'ping', from: params[:client_id] }
end
def unsubscribed
Turbo::StreamsChannel.broadcast_remove_to(
find_room,
target: "medium_#{current_client.id}"
)
end
def greet(data)
Turbo::StreamsChannel.broadcast_append_to(
data['to'],
target: 'media',
partial: 'media/medium',
locals: { client_id: data['from'] }
)
end
private
def find_room
Room.new(id: params[:id])
end
end

View File

@ -0,0 +1,9 @@
class SignalingChannel < ApplicationCable::Channel
def subscribed
stream_for Room.new(id: params[:id])
end
def signal(data)
broadcast_to(Room.new(id: params[:id]), data)
end
end

View File

@ -0,0 +1,16 @@
class RoomsController < ApplicationController
before_action :skip_authorization
def new
end
def create
redirect_to room_path(SecureRandom.uuid)
end
def show
@client = Client.new(id: SecureRandom.uuid)
cookies.encrypted[:client_id] = @client.id
@room = Room.new(id: params[:id])
end
end

View File

@ -0,0 +1 @@
import 'controllers'

View File

@ -0,0 +1,9 @@
import { Application } from '@hotwired/stimulus'
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }

View File

@ -0,0 +1,5 @@
import { application } from 'controllers/application'
// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from '@hotwired/stimulus-loading'
eagerLoadControllersFrom('controllers', application)

View File

@ -0,0 +1,17 @@
import { Controller } from '@hotwired/stimulus'
export default class MediumController extends Controller {
connect () {
this.reRenderMediaElement()
}
// Fix potentially blank videos due to autoplay rules?
reRenderMediaElement () {
const mediaElement = this.mediaElementTarget
const clone = mediaElement.cloneNode(true)
mediaElement.parentNode.insertBefore(clone, mediaElement)
mediaElement.remove()
}
}
MediumController.targets = ['mediaElement']

View File

@ -0,0 +1,147 @@
import { Controller } from '@hotwired/stimulus'
import Client from 'models/client'
import WebrtcNegotiation from 'models/webrtc_negotiation'
import RoomSubscription from 'subscriptions/room_subscription'
import Signaller from 'subscriptions/signaling_subscription'
export default class RoomController extends Controller {
connect() {
this.clients = {}
this.client = new Client(this.clientIdValue)
this.subscription = new RoomSubscription({
delegate: this,
id: this.idValue,
clientId: this.client.id
})
this.signaller = new Signaller({
delegate: this,
id: this.idValue,
clientId: this.client.id
})
this.client.on('iceConnection:checking', ({ detail: { otherClient } }) => {
this.startStreamingTo(otherClient)
})
}
async enter () {
try {
const constraints = { audio: true, video: true }
this.client.stream = await navigator.mediaDevices.getUserMedia(constraints)
this.localMediumTarget.srcObject = this.client.stream
this.localMediumTarget.muted = true // Keep muted on Firefox
this.enterTarget.hidden = true
this.subscription.start()
this.signaller.start()
} catch (error) {
console.error(error)
}
}
greetNewClient ({ from }) {
const otherClient = this.findOrCreateClient(from)
otherClient.newcomer = true
this.subscription.greet({ to: otherClient.id, from: this.client.id })
}
remoteMediumTargetConnected (element) {
const clientId = element.id.replace('medium_', '')
this.negotiateConnection(clientId)
}
remoteMediumTargetDisconnected (element) {
const clientId = element.id.replace('medium_', '')
this.teardownClient(clientId)
}
negotiateConnection (clientId) {
const otherClient = this.findOrCreateClient(clientId)
// Be polite to newcomers!
const polite = !!otherClient.newcomer
otherClient.negotiation = this.createNegotiation({ otherClient, polite })
// The polite client sets up the negotiation last, so we can start streaming
// The impolite client signals to the other client that it's ready
if (polite) {
this.startStreamingTo(otherClient)
} else {
this.subscription.greet({ to: otherClient.id, from: this.client.id })
}
}
teardownClient (clientId) {
this.clients[clientId].stop()
delete this.clients[clientId]
}
createNegotiation ({ otherClient, polite }) {
const negotiation = new WebrtcNegotiation({
signaller: this.signaller,
client: this.client,
otherClient: otherClient,
polite
})
otherClient.on('track', ({ detail }) => {
this.startStreamingFrom(otherClient.id, detail)
})
return negotiation
}
startStreamingTo (otherClient) {
this.client.streamTo(otherClient)
}
startStreamingFrom (id, { track, streams: [stream] }) {
const remoteMediaElement = this.findRemoteMediaElement(id)
if (!remoteMediaElement.srcObject) {
remoteMediaElement.srcObject = stream
}
}
findOrCreateClient (id) {
return this.clients[id] || (this.clients[id] = new Client(id))
}
findRemoteMediaElement (clientId) {
const target = this.remoteMediumTargets.find(
target => target.id === `medium_${clientId}`
)
return target ? target.querySelector('video') : null
}
negotiationFor (id) {
return this.clients[id].negotiation
}
// RoomSubscription Delegate
roomPinged (data) {
this.greetNewClient(data)
}
// Signaler Delegate
sdpDescriptionReceived ({ from, description }) {
this.negotiationFor(from).setDescription(description)
}
iceCandidateReceived ({ from, candidate }) {
this.negotiationFor(from).addCandidate(candidate)
}
negotiationRestarted ({ from }) {
const negotiation = this.negotiationFor(from)
negotiation.restart()
negotiation.createOffer()
}
}
RoomController.values = { id: String, clientId: String }
RoomController.targets = ['localMedium', 'remoteMedium', 'enter']

View File

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
export default class Client {
constructor (id) {
this.callbacks = {}
this.id = id
}
get peerConnection () {
return this.negotiation && this.negotiation.peerConnection
}
streamTo (otherClient) {
if (!otherClient.streaming) {
this.stream.getTracks().forEach(track => {
otherClient.peerConnection.addTrack(track, this.stream)
})
otherClient.streaming = true
}
}
on (name, callback) {
const names = name.split(' ')
names.forEach((name) => {
this.callbacks[name] = this.callbacks[name] || []
this.callbacks[name].push(callback)
})
}
broadcast (name, data) {
(this.callbacks[name] || []).forEach(
callback => callback.call(null, { type: name, detail: data })
)
}
off (name) {
if (name) return delete this.callbacks[name]
else this.callbacks = {}
}
stop () {
this.off()
this.negotiation.stop()
}
}

View File

@ -0,0 +1,183 @@
const RETRY_LIMIT = 10
const peerConnectionConfig = {
iceServers: [
{
urls: [
'stun:stun.l.google.com:19302',
'stun:global.stun.twilio.com:3478'
]
}
],
sdpSemantics: 'unified-plan'
}
export default class WebrtcNegotiation {
constructor ({ client, otherClient, polite, signaller }) {
this.client = client
this.otherClient = otherClient
this.polite = polite
this.signaller = signaller
this.makingOffer = false
this.isSettingRemoteAnswerPending = false
this.candidates = []
this.retryCount = 0
this.start()
}
async createOffer () {
if (!this.readyToMakeOffer) return
try {
this.makingOffer = true
await this.setLocalDescription(await this.peerConnection.createOffer())
} catch (error) {
console.error(error)
} finally {
this.makingOffer = false
}
}
async setDescription (description) {
try {
if (this.ignore(description)) return
await this.setRemoteDescription(description)
if (description.type === 'offer') {
await this.setLocalDescription(await this.peerConnection.createAnswer())
}
} catch (error) {
if (this.retryCount < RETRY_LIMIT) {
this.initiateManualRollback()
this.retryCount++
} else {
this.stop()
console.error(`Negotiation failed after ${this.retryCount} retries`)
}
}
}
async setLocalDescription (description) {
await this.peerConnection.setLocalDescription(description)
this.signaller.signal({
type: this.peerConnection.localDescription ? 'description' : undefined,
to: this.otherClient.id,
from: this.client.id,
description: this.peerConnection.localDescription
})
}
async setRemoteDescription (description) {
this.isSettingRemoteAnswerPending = description.type === 'answer'
await this.peerConnection.setRemoteDescription(description) // SRD rolls back as needed
this.addCandidates()
this.isSettingRemoteAnswerPending = false
}
addCandidate (candidate) {
this.candidates.push(candidate)
this.addCandidates()
}
// TODO try/catch and ignore failures if necessary?
addCandidates () {
if (this.peerConnection.remoteDescription) {
while (this.candidates.length) {
this.peerConnection.addIceCandidate(this.candidates.shift())
}
}
}
get readyToMakeOffer () {
return !this.makingOffer && this.peerConnection.signalingState === 'stable'
}
get readyToReceiveOffer () {
return !this.makingOffer && (
this.peerConnection.signalingState === 'stable' ||
this.isSettingRemoteAnswerPending
)
}
collides (description) {
return description.type === 'offer' && !this.readyToReceiveOffer
}
ignore (description) {
return !this.polite && this.collides(description)
}
initiateManualRollback() {
this.restart()
this.signaller.signal({
type: 'restart',
to: this.otherClient.id,
from: this.client.id
})
}
restart() {
this.stop()
this.start()
}
start () {
this.setupPeerConnection()
}
stop () {
this.makingOffer = false
this.isSettingRemoteAnswerPending = false
this.candidates = []
this.teardownPeerConnection()
this.otherClient.streaming = false
}
setupPeerConnection () {
this.peerConnection = new RTCPeerConnection(peerConnectionConfig)
this._onnegotiationneeded = () => this.createOffer()
this._onicecandidate = ({ candidate }) => {
this.signaller.signal({
type: candidate ? 'candidate' : undefined,
to: this.otherClient.id,
from: this.client.id,
candidate
})
}
this._oniceconnectionstatechange = (event) => {
this.client.broadcast(`iceConnection:${this.peerConnection.iceConnectionState}`, { otherClient: this.otherClient })
switch (this.peerConnection.iceConnectionState) {
case 'completed':
this.retryCount = 0
break;
case 'failed':
if ('restartIce' in this.peerConnection) this.peerConnection.restartIce()
break;
default:
return
}
}
this._ontrack = (event) => this.otherClient.broadcast('track', event)
this.peerConnection.addEventListener('negotiationneeded', this._onnegotiationneeded)
this.peerConnection.addEventListener('icecandidate', this._onicecandidate)
this.peerConnection.addEventListener('iceconnectionstatechange', this._oniceconnectionstatechange)
this.peerConnection.addEventListener('track', this._ontrack)
}
teardownPeerConnection () {
this.peerConnection.close()
this.peerConnection.removeEventListener('negotiationneeded', this._onnegotiationneeded)
this.peerConnection.removeEventListener('icecandidate', this._onicecandidate)
this.peerConnection.removeEventListener('iceconnectionstatechange', this._oniceconnectionstatechange)
this.peerConnection.removeEventListener('track', this._ontrack)
this.peerConnection = null
}
}

View File

@ -0,0 +1,37 @@
import { cable } from '@hotwired/turbo-rails'
export default class RoomSubscription {
constructor ({ delegate, id, clientId }) {
this.delegate = delegate
this.id = id
this.clientId = clientId
}
async start () {
const self = this
this.subscription = await cable.subscribeTo({
channel: 'RoomChannel',
id: this.id,
client_id: this.clientId
}, {
greet (data) {
this.perform('greet', data)
},
received (data) {
// Ignore self-sent data
if (data.from === self.clientId) return
if (data.type === 'ping') self.delegate.roomPinged(data)
}
})
this.started = true
}
greet (data) {
if (!this.started) return
this.subscription.greet(data)
}
}

View File

@ -0,0 +1,35 @@
import { cable } from '@hotwired/turbo-rails'
export default class SignalingSubscription {
constructor({ delegate, id, clientId }) {
this.callbacks = {}
this.delegate = delegate
this.id = id
this.clientId = clientId
}
async start () {
const self = this
this.subscription = await cable.subscribeTo({
channel: 'SignalingChannel',
id: this.id
}, {
received (data) {
const { to, from, type, description, candidate } = data
if (to != self.clientId) return
if (description) return self.delegate.sdpDescriptionReceived({ from, description })
if (candidate) return self.delegate.iceCandidateReceived({ from, candidate })
if (type === 'restart') return self.delegate.negotiationRestarted({ from })
}
})
this.started = true
}
signal (data) {
if (!this.started) return
this.subscription.perform('signal', data)
}
}

11
app/models/client.rb Normal file
View File

@ -0,0 +1,11 @@
class Client
attr_reader :id
def initialize(id:)
@id = id
end
def to_param
id
end
end

11
app/models/room.rb Normal file
View File

@ -0,0 +1,11 @@
class Room
attr_reader :id
def initialize(id:)
@id = id
end
def to_param
id
end
end

View File

@ -6,7 +6,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&display=swap" rel="stylesheet">
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
<%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
<%= javascript_include_tag 'gems', 'data-turbolinks-track' => true %>
<%= javascript_importmap_tags %>
<%= csrf_meta_tags %>
<%= yield :head %>
</head>

View File

@ -0,0 +1,8 @@
<div
class="medium"
id="medium_<%= client_id %>"
data-controller="medium"
data-room-target="remoteMedium"
>
<video autoplay playsinline data-medium-target="mediaElement"></video>
</div>

View File

@ -0,0 +1,3 @@
<%= form_with url: rooms_path do |form| %>
<%= form.submit 'Create Room' %>
<% end %>

View File

@ -0,0 +1,19 @@
<%= turbo_stream_from @room %>
<%= turbo_stream_from @client.id %>
<article
class="room"
data-controller="room"
data-room-id-value="<%= @room.id %>"
data-room-client-id-value="<%= @client.id %>"
>
<div class="media" id="media">
<div class="medium medium--local" data-controller="medium">
<video data-room-target="localMedium" data-medium-target="mediaElement" muted autoplay playsinline></video>
<div class="medium-actions">
<%= button_tag 'Enter', data: { room_target: 'enter', action: 'room#enter' } %>
</div>
</div>
</div>
</article>

8
config/importmap.rb Normal file
View File

@ -0,0 +1,8 @@
pin 'application', preload: true
pin 'jquery'
pin '@hotwired/turbo-rails', to: "turbo.js"
pin '@hotwired/stimulus', to: 'stimulus.min.js', preload: true
pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js', preload: true
pin_all_from 'app/javascript/models', under: 'models'
pin_all_from 'app/javascript/controllers', under: 'controllers'
pin_all_from 'app/javascript/subscriptions', under: 'subscriptions'

View File

@ -56,4 +56,5 @@ Rails.application.routes.draw do
get '/calls', to: 'calls#root'
resources :calls, only: :create
mount ActionCable.server, at: '/cable'
resources :rooms, only: [:new, :create, :show]
end

View File

@ -1,9 +0,0 @@
import React from 'react';
import VideoCall from './components/VideoCall';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
document.addEventListener("DOMContentLoaded", () => {
// alert("hi");
const root = createRoot(document.getElementById("root"));
root.render(<VideoCall />)
});

View File

@ -1,153 +0,0 @@
import React from 'react';
import { broadcastData, JOIN_CALL, LEAVE_CALL, EXCHANGE, ice } from './video_util.js';
class VideoCall extends React.Component{
constructor(props){
super(props);
this.pcPeers = {};
this.userId = Math.floor(Math.random() * 10000);
this.joinCall = this.joinCall.bind(this);
this.leaveCall = this.leaveCall.bind(this);
}
componentDidMount(){
this.remoteVideoContainer =
document.getElementById("remote-video-container")
navigator.mediaDevices.getUserMedia({audio: false, video: true})
.then(stream => {
this.localStream = stream;
document.getElementById("local-video").srcObject = stream;
}).catch(error => { console.log(error)});
}
join(data){ this.createPC(data.from, true) }
joinCall(e){
console.log("Hello from joinCall");
// console.log(e);
App.cable.subscriptions.create(
{ channel: "CallChannel" },
{
connected: () => {
broadcastData({ type: JOIN_CALL, from: this.userId});
},
received: data => {
console.log("RECEIVED: ", data);
if (data.from === this.userId) return;
switch(data.type){
case JOIN_CALL:
return this.join(data);
case EXCHANGE:
if (data.to !== this.userId) return;
return this.exchange(data);
case LEAVE_CALL:
return this.removeUser(data);
default:
return;
}
}
});
}
createPC(userId, offerBool){
const pc = new RTCPeerConnection(ice);
this.pcPeers[userId] = pc;
this.localStream.getTracks()
.forEach(track => pc.addTrack(track, this.localStream));
if (offerBool) {
pc.createOffer().then(offer => {
pc.setLocalDescription(offer).then(() => {
setTimeout( () => {
broadcastData({
type: EXCHANGE,
from: this.userId,
to: userId,
sdp: JSON.stringify(pc.localDescription),
});
}, 0);
});
});
}
pc.onicecandidate = (e) => {
broadcastData({
type: EXCHANGE,
from: this.userId,
to: userId,
sdp: JSON.stringify(e.candidate)
})
}
pc.ontrack = (e) => {
const remoteVid = document.createElement("video");
remoteVid.id = `remoteVideoContainer+${userId}`;
remoteVid.autoplay = "autoplay";
remoteVid.srcObject = e.streams[0];
this.remoteVideoContainer.appendChild(remoteVid);
}
pc.oniceconnectionstatechange = (e) => {
if (pc.iceConnectionState === 'disconnected'){
broadcastData({ type: LEAVE_CALL, from: userId });
}
}
return pc;
}
exchange(data){
let pc;
if(this.pcPeers[data.from]){
pc = this.pcPeers[data.from];
} else{
pc = this.createPC(data.from, false);
}
if (data.candidate){
let candidate = JSON.parse(data.candidate)
pc.addIceCandidate(new RTCIceCandidate(candidate))
}
if(data.sdp){
const sdp = JSON.parse(data.sdp);
if(sdp && !sdp.candidate){
pc.setRemoteDescription(sdp).then( () =>{
if (sdp.type === 'offer'){
pc.createAnswer().then(answer => {
pc.setLocalDescription(answer)
.then( () => {
broadcastData({
type: EXCHANGE,
from: this.userId,
to: data.from,
sdp: JSON.stringify(pc.localDescription)
});
});
});
}
});
}
}
}
leaveCall(e){
const pcKeys = Object.keys(this.pcPeers);
for (let i = 0; i < pcKeys.length; i++) {
this.pcPeers[pcKeys[i]].close();
}
this.pcPeers = {};
this.localVideo.srcObject.getTracks()
.forEach(function (track) { track.stop(); })
this.localVideo.srcObject = null;
App.cable.subscriptions.subscriptions = [];
this.remoteVideoContainer.innerHTML = "";
broadcastData({ type: LEAVE_CALL, from: this.userId });
}
removeUser(data){
let video = document.getElementById(`remoteVideoContainer+${data.from}`);
video && video.remove();
let peers = this.pcPeers
delete peers[data.from]
}
render(){
return(
<div className="VideoCall">
<div id="remote-video-container"></div>
<video id="local-video" autoPlay></video>
<button onClick={this.joinCall}>Join Call</button>
<button onClick={this.leaveCall}>Leave Call</button>
</div>)
}
}
export default VideoCall;

View File

@ -1,19 +0,0 @@
export const JOIN_CALL = "JOIN_CALL";
export const EXCHANGE = "EXCHANGE";
export const LEAVE_CALL = "LEAVE_CALL";
export const ice = { iceServers: [
{
urls: "stun:stun2.l.google.com:19302"
}
]};
export const broadcastData = data => {
fetch("calls", {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content,
},
}
);
};

View File

@ -20,7 +20,7 @@
"babel-loader": "^9.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4"
// "webpack": "^5.88.2",
// "webpack-cli": "^5.1.4"
}
}

View File

@ -1,20 +0,0 @@
var path = require('path');
module.exports = {
entry: "./frontend/VideoComponent.jsx",
output: {
path: path.resolve(__dirname, 'app', 'assets', 'javascripts'),
filename: "bundle.js"
},
module: {
rules: [{
test: [/\.jsx?$/],
exclude: /(node_modules)/,
use: {
loader: 'babel-loader',
options: { presets: ['@babel/env', '@babel/react'] }
},
}]
},
devtool: 'eval-source-map',
resolve: { extensions: ['.js', '.jsx', '*']}
};

2148
yarn.lock Normal file

File diff suppressed because it is too large Load Diff