Add webrtc chat
This commit is contained in:
parent
43a71faddf
commit
929c98bb14
2
Gemfile
2
Gemfile
@ -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'
|
||||
|
||||
240
Gemfile.lock
240
Gemfile.lock
@ -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
|
||||
|
||||
6
app/assets/config/manifest.js
Normal file
6
app/assets/config/manifest.js
Normal 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
9
app/assets/javascripts/controllers/application.js
Normal file
9
app/assets/javascripts/controllers/application.js
Normal 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 }
|
||||
5
app/assets/javascripts/controllers/index.js
Normal file
5
app/assets/javascripts/controllers/index.js
Normal 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)
|
||||
17
app/assets/javascripts/controllers/medium_controller.js
Normal file
17
app/assets/javascripts/controllers/medium_controller.js
Normal 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']
|
||||
147
app/assets/javascripts/controllers/room_controller.js
Normal file
147
app/assets/javascripts/controllers/room_controller.js
Normal 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']
|
||||
@ -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 .
|
||||
|
||||
0
app/assets/javascripts/libraries/.keep
Normal file
0
app/assets/javascripts/libraries/.keep
Normal file
5626
app/assets/javascripts/libraries/adapter.js
Normal file
5626
app/assets/javascripts/libraries/adapter.js
Normal file
File diff suppressed because it is too large
Load Diff
43
app/assets/javascripts/models/client.js
Normal file
43
app/assets/javascripts/models/client.js
Normal 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()
|
||||
}
|
||||
}
|
||||
183
app/assets/javascripts/models/webrtc_negotiation.js
Normal file
183
app/assets/javascripts/models/webrtc_negotiation.js
Normal 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
|
||||
}
|
||||
}
|
||||
37
app/assets/javascripts/subscriptions/room_subscription.js
Normal file
37
app/assets/javascripts/subscriptions/room_subscription.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
29
app/channels/room_channel.rb
Normal file
29
app/channels/room_channel.rb
Normal 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
|
||||
9
app/channels/signaling_channel.rb
Normal file
9
app/channels/signaling_channel.rb
Normal 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
|
||||
16
app/controllers/rooms_controller.rb
Normal file
16
app/controllers/rooms_controller.rb
Normal 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
|
||||
1
app/javascript/application.js
Normal file
1
app/javascript/application.js
Normal file
@ -0,0 +1 @@
|
||||
import 'controllers'
|
||||
9
app/javascript/controllers/application.js
Normal file
9
app/javascript/controllers/application.js
Normal 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 }
|
||||
5
app/javascript/controllers/index.js
Normal file
5
app/javascript/controllers/index.js
Normal 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)
|
||||
17
app/javascript/controllers/medium_controller.js
Normal file
17
app/javascript/controllers/medium_controller.js
Normal 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']
|
||||
147
app/javascript/controllers/room_controller.js
Normal file
147
app/javascript/controllers/room_controller.js
Normal 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']
|
||||
0
app/javascript/libraries/.keep
Normal file
0
app/javascript/libraries/.keep
Normal file
5626
app/javascript/libraries/adapter.js
Normal file
5626
app/javascript/libraries/adapter.js
Normal file
File diff suppressed because it is too large
Load Diff
43
app/javascript/models/client.js
Normal file
43
app/javascript/models/client.js
Normal 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()
|
||||
}
|
||||
}
|
||||
183
app/javascript/models/webrtc_negotiation.js
Normal file
183
app/javascript/models/webrtc_negotiation.js
Normal 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
|
||||
}
|
||||
}
|
||||
37
app/javascript/subscriptions/room_subscription.js
Normal file
37
app/javascript/subscriptions/room_subscription.js
Normal 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)
|
||||
}
|
||||
}
|
||||
35
app/javascript/subscriptions/signaling_subscription.js
Normal file
35
app/javascript/subscriptions/signaling_subscription.js
Normal 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
11
app/models/client.rb
Normal 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
11
app/models/room.rb
Normal file
@ -0,0 +1,11 @@
|
||||
class Room
|
||||
attr_reader :id
|
||||
|
||||
def initialize(id:)
|
||||
@id = id
|
||||
end
|
||||
|
||||
def to_param
|
||||
id
|
||||
end
|
||||
end
|
||||
@ -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>
|
||||
|
||||
8
app/views/media/_medium.html.erb
Normal file
8
app/views/media/_medium.html.erb
Normal 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>
|
||||
3
app/views/rooms/new.html.erb
Normal file
3
app/views/rooms/new.html.erb
Normal file
@ -0,0 +1,3 @@
|
||||
<%= form_with url: rooms_path do |form| %>
|
||||
<%= form.submit 'Create Room' %>
|
||||
<% end %>
|
||||
19
app/views/rooms/show.html.erb
Normal file
19
app/views/rooms/show.html.erb
Normal 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
8
config/importmap.rb
Normal 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'
|
||||
@ -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
|
||||
|
||||
@ -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 />)
|
||||
});
|
||||
@ -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;
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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', '*']}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user