diff --git a/.gitignore b/.gitignore index 7bda60a3e0..88fb2340f9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ # Check out https://help.github.com/articles/ignoring-files for how to set that up. .Gemfile -.byebug_history .ruby-version /*/doc/ /*/test/tmp/ diff --git a/.rubocop.yml b/.rubocop.yml index 3322983b47..5ad6a39673 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,5 @@ require: + - rubocop-minitest - rubocop-packaging - rubocop-performance - rubocop-rails @@ -49,6 +50,9 @@ Layout/CaseIndentation: Layout/ClosingHeredocIndentation: Enabled: true +Layout/ClosingParenthesisIndentation: + Enabled: true + # Align comments with method definitions. Layout/CommentIndentation: Enabled: true @@ -139,6 +143,9 @@ Style/DefWithParentheses: Style/MethodDefParentheses: Enabled: true +Style/ExplicitBlockArgument: + Enabled: true + Style/FrozenStringLiteralComment: Enabled: true EnforcedStyle: always @@ -288,3 +295,6 @@ Performance/DeletePrefix: Performance/DeleteSuffix: Enabled: true + +Minitest/UnreachableAssertion: + Enabled: true diff --git a/Gemfile b/Gemfile index 01f34c5964..3e8cf8d796 100644 --- a/Gemfile +++ b/Gemfile @@ -13,9 +13,12 @@ gem "capybara", ">= 3.26" gem "selenium-webdriver", ">= 4.0.0.alpha7" gem "rack-cache", "~> 1.2" -gem "sass-rails" -gem "turbolinks", "~> 5" -gem "webpacker", "~> 5.0", require: ENV["SKIP_REQUIRE_WEBPACKER"] != "true" +gem "stimulus-rails" +gem "turbo-rails" +gem "jsbundling-rails" +gem "cssbundling-rails" +gem "importmap-rails" +gem "tailwindcss-rails" # require: false so bcrypt is loaded only when has_secure_password is used. # This is to avoid Active Model (and by extension the entire framework) # being dependent on a binary library. @@ -23,13 +26,14 @@ gem "bcrypt", "~> 3.1.11", require: false # This needs to be with require false to avoid it being automatically loaded by # sprockets. -gem "uglifier", ">= 1.3.0", require: false +gem "terser", ">= 1.1.4", require: false # Explicitly avoid 1.x that doesn't support Ruby 2.4+ gem "json", ">= 2.0.0" group :rubocop do gem "rubocop", ">= 0.90", require: false + gem "rubocop-minitest", require: false gem "rubocop-packaging", require: false gem "rubocop-performance", require: false gem "rubocop-rails", require: false @@ -85,7 +89,7 @@ end group :storage do gem "aws-sdk-s3", require: false gem "google-cloud-storage", "~> 1.11", require: false - gem "azure-storage-blob", require: false + gem "azure-storage-blob", "~> 2.0", require: false gem "image_processing", "~> 1.2" end @@ -95,7 +99,6 @@ gem "aws-sdk-sns", require: false gem "webmock" group :ujs do - gem "qunit-selenium" gem "webdrivers" end @@ -111,12 +114,12 @@ instance_eval File.read local_gemfile if File.exist? local_gemfile group :test do gem "minitest-bisect" + gem "minitest-ci", require: false gem "minitest-retry" - gem "minitest-reporters" platforms :mri do gem "stackprof" - gem "byebug" + gem "debug", ">= 1.0.0", require: false end gem "benchmark-ips" @@ -177,6 +180,9 @@ if RUBY_VERSION >= "3.1" gem "net-imap", require: false gem "net-pop", require: false + # digest gem, which is one of the default gems has bumped to 3.0.1.pre for ruby 3.1.0dev. + gem "digest", "~> 3.0.1.pre", require: false + # matrix was removed from default gems in Ruby 3.1, but is used by the `capybara` gem. # So we need to add it as a dependency until `capybara` is fixed: https://github.com/teamcapybara/capybara/pull/2468 gem "matrix", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 6d356d9c58..497f636f54 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,7 +81,6 @@ PATH i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - zeitwerk (~> 2.3) rails (7.0.0.alpha) actioncable (= 7.0.0.alpha) actionmailbox (= 7.0.0.alpha) @@ -103,6 +102,7 @@ PATH method_source rake (>= 0.13) thor (~> 1.0) + zeitwerk (~> 2.5.0.beta3) GEM remote: https://rubygems.org/ @@ -110,19 +110,18 @@ GEM addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) amq-protocol (2.3.2) - ansi (1.5.0) ast (2.4.2) aws-eventstream (1.1.1) - aws-partitions (1.465.0) - aws-sdk-core (3.114.1) + aws-partitions (1.469.0) + aws-sdk-core (3.114.3) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.43.0) + aws-sdk-kms (1.44.0) aws-sdk-core (~> 3, >= 3.112.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.96.0) + aws-sdk-s3 (1.96.1) aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) @@ -165,7 +164,6 @@ GEM bunny (2.18.0) amq-protocol (~> 2.3, >= 2.3.1) sorted_set (~> 1, >= 1.0.2) - byebug (11.1.3) capybara (3.35.3) addressable mini_mime (>= 0.1.3) @@ -174,7 +172,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - childprocess (4.0.0) + childprocess (4.1.0) coffee-script (2.4.1) coffee-script-source execjs @@ -185,10 +183,15 @@ GEM crack (0.4.5) rexml crass (1.0.6) - curses (1.4.1) + cssbundling-rails (0.1.0) + rails (>= 6.0.0) + curses (1.4.2) daemons (1.4.0) dalli (2.7.11) dante (0.2.0) + debug (1.0.0) + irb + reline (>= 0.2.7) declarative (0.0.20) delayed_job (4.1.9) activesupport (>= 3.0, < 6.2) @@ -237,12 +240,12 @@ GEM faye-websocket (0.11.1) eventmachine (>= 0.12.0) websocket-driver (>= 0.5.1) - ffi (1.15.1) + ffi (1.15.3) fugit (1.5.0) et-orbi (~> 1.1, >= 1.1.8) raabro (~> 1.4) - globalid (0.4.2) - activesupport (>= 4.2.0) + globalid (0.5.1) + activesupport (>= 5.0) google-apis-core (0.3.0) addressable (~> 2.5, >= 2.5.1) googleauth (~> 0.14) @@ -287,14 +290,21 @@ GEM image_processing (1.12.1) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) + importmap-rails (0.5.1) + rails (>= 6.0.0) + io-console (0.5.9) + irb (1.3.7) + reline (>= 0.2.7) jmespath (1.4.0) + jsbundling-rails (0.1.0) + rails (>= 6.0.0) json (2.5.1) jwt (2.2.3) kindlerb (1.2.0) mustache nokogiri libxml-ruby (3.2.1) - listen (3.5.1) + listen (3.6.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) loofah (2.10.0) @@ -307,15 +317,13 @@ GEM method_source (1.0.0) mini_magick (4.11.0) mini_mime (1.1.0) + mini_portile2 (2.5.3) minitest (5.14.4) minitest-bisect (1.5.1) minitest-server (~> 1.0) path_expander (~> 1.1) - minitest-reporters (1.4.3) - ansi - builder - minitest (>= 5.0) - ruby-progressbar + minitest-ci (3.4.0) + minitest (>= 5.0.6) minitest-retry (0.2.2) minitest (>= 5.0) minitest-server (1.0.6) @@ -330,13 +338,16 @@ GEM net-http-persistent (4.0.1) connection_pool (~> 2.2) nio4r (2.5.7) + nokogiri (1.11.7) + mini_portile2 (~> 2.5.0) + racc (~> 1.4) nokogiri (1.11.7-x86_64-darwin) racc (~> 1.4) nokogiri (1.11.7-x86_64-linux) racc (~> 1.4) os (1.1.1) parallel (1.20.1) - parser (3.0.1.1) + parser (3.0.2.0) ast (~> 2.4.1) path_expander (1.1.0) pg (1.2.3) @@ -345,9 +356,6 @@ GEM puma (5.3.2) nio4r (~> 2.0) que (0.14.3) - qunit-selenium (0.0.4) - selenium-webdriver - thor raabro (1.4.0) racc (1.5.2) rack (2.2.3) @@ -355,8 +363,6 @@ GEM rack (>= 0.4) rack-protection (2.1.0) rack - rack-proxy (0.7.0) - rack rack-test (1.1.0) rack (>= 1.0, < 3) rails-dom-testing (2.0.3) @@ -372,10 +378,12 @@ GEM rbtree (0.4.4) rdoc (6.3.1) redcarpet (3.2.3) - redis (4.2.5) + redis (4.3.1) redis-namespace (1.8.1) redis (>= 3.0.4) regexp_parser (2.1.1) + reline (0.2.7) + io-console (~> 0.5) representable (3.1.1) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -394,23 +402,25 @@ GEM retriable (3.1.2) rexml (3.2.5) rouge (3.26.0) - rubocop (1.16.0) + rubocop (1.19.0) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml - rubocop-ast (>= 1.7.0, < 2.0) + rubocop-ast (>= 1.9.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.7.0) + rubocop-ast (1.9.1) parser (>= 3.0.1.1) + rubocop-minitest (0.15.0) + rubocop (>= 0.90, < 2.0) rubocop-packaging (0.5.1) rubocop (>= 0.89, < 2.0) - rubocop-performance (1.11.3) + rubocop-performance (1.11.4) rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) - rubocop-rails (2.10.1) + rubocop-rails (2.11.3) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.7.0, < 2.0) @@ -421,23 +431,12 @@ GEM rubyzip (2.3.0) rufus-scheduler (3.7.0) fugit (~> 1.1, >= 1.1.6) - sass-rails (6.0.0) - sassc-rails (~> 2.1, >= 2.1.1) - sassc (2.4.0) - ffi (~> 1.9) - sassc-rails (2.1.2) - railties (>= 4.0.0) - sassc (>= 2.0) - sprockets (> 3.0) - sprockets-rails - tilt sdoc (2.2.0) rdoc (>= 5.0) selenium-webdriver (4.0.0.beta4) childprocess (>= 0.5, < 5.0) rexml (~> 3.2) rubyzip (>= 1.2.2) - semantic_range (3.0.0) sequel (5.45.0) serverengine (2.0.7) sigdump (~> 0.2.2) @@ -476,8 +475,14 @@ GEM sprockets (>= 3.0.0) sqlite3 (1.4.2) stackprof (0.2.17) + stimulus-rails (0.4.2) + rails (>= 6.0.0) sucker_punch (3.0.1) concurrent-ruby (~> 1.0) + tailwindcss-rails (0.4.3) + rails (>= 6.0.0) + terser (1.1.4) + execjs (>= 0.3.0, < 3) thin (1.8.1) daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) @@ -485,14 +490,11 @@ GEM thor (1.1.0) tilt (2.0.10) trailblazer-option (0.1.1) - turbolinks (5.2.1) - turbolinks-source (~> 5.2) - turbolinks-source (5.2.0) + turbo-rails (0.7.11) + rails (>= 6.0.0) tzinfo (2.0.4) concurrent-ruby (~> 1.0) uber (0.1.0) - uglifier (4.2.0) - execjs (>= 0.3.0, < 3) unicode-display_width (2.0.0) useragent (0.16.10) vegas (0.1.11) @@ -509,21 +511,17 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webpacker (5.4.0) - activesupport (>= 5.2) - rack-proxy (>= 0.6.1) - railties (>= 5.2) - semantic_range (>= 2.3.0) webrick (1.7.0) websocket (1.2.9) - websocket-driver (0.7.4) + websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.4.2) + zeitwerk (2.5.0.beta3) PLATFORMS + ruby x86_64-darwin-19 x86_64-darwin-20 x86_64-linux @@ -534,27 +532,30 @@ DEPENDENCIES activerecord-jdbcsqlite3-adapter (>= 1.3.0) aws-sdk-s3 aws-sdk-sns - azure-storage-blob + azure-storage-blob (~> 2.0) backburner bcrypt (~> 3.1.11) benchmark-ips blade bootsnap (>= 1.4.4) - byebug capybara (>= 3.26) connection_pool + cssbundling-rails dalli + debug (>= 1.0.0) delayed_job delayed_job_active_record google-cloud-storage (~> 1.11) hiredis image_processing (~> 1.2) + importmap-rails + jsbundling-rails json (>= 2.0.0) kindlerb (~> 1.2.0) libxml-ruby listen (~> 3.3) minitest-bisect - minitest-reporters + minitest-ci minitest-retry mysql2 (~> 0.5)! nokogiri (>= 1.8.1, != 1.11.0) @@ -563,7 +564,6 @@ DEPENDENCIES puma que queue_classic! - qunit-selenium racc (>= 1.4.6) rack-cache (~> 1.2) rails! @@ -576,10 +576,10 @@ DEPENDENCIES rexml rouge rubocop (>= 0.90) + rubocop-minitest rubocop-packaging rubocop-performance rubocop-rails - sass-rails sdoc (>= 2.2.0) selenium-webdriver (>= 4.0.0.alpha7) sequel @@ -588,17 +588,18 @@ DEPENDENCIES sprockets-export sqlite3 (~> 1.4) stackprof + stimulus-rails sucker_punch - turbolinks (~> 5) + tailwindcss-rails + terser (>= 1.1.4) + turbo-rails tzinfo-data - uglifier (>= 1.3.0) w3c_validators (~> 1.3.6) wdm (>= 0.1.0) webdrivers webmock - webpacker (~> 5.0) webrick websocket-client-simple! BUNDLED WITH - 2.2.19 + 2.2.25 diff --git a/RELEASING_RAILS.md b/RELEASING_RAILS.md index f48f24ac6a..9c305841f2 100644 --- a/RELEASING_RAILS.md +++ b/RELEASING_RAILS.md @@ -49,12 +49,12 @@ give them a heads up that Rails will be released soonish. This is only required for major and minor releases, bugfix releases aren't a big enough deal, and are supposed to be backward compatible. -Send an email just giving a heads up about the upcoming release to these +Send a message just giving a heads up about the upcoming release to these lists: * team@jruby.org * community@rubini.us -* rubyonrails-core@googlegroups.com +* [rubyonrails-core](https://discuss.rubyonrails.org/c/rubyonrails-core) Implementors will love you and help you. @@ -135,8 +135,8 @@ Write a release announcement that includes the version, changes, and links to GitHub where people can find the specific commit list. Here are the mailing lists where you should announce: -* rubyonrails-core@googlegroups.com -* rubyonrails-talk@googlegroups.com +* [rubyonrails-core](https://discuss.rubyonrails.org/c/rubyonrails-core) +* [rubyonrails-talk](https://discuss.rubyonrails.org/c/rubyonrails-talk) * ruby-talk@ruby-lang.org Use Markdown format for your announcement. Remember to ask people to report diff --git a/actioncable/.eslintrc b/actioncable/.eslintrc index 3d9ecd4bce..b85ef26b31 100644 --- a/actioncable/.eslintrc +++ b/actioncable/.eslintrc @@ -3,7 +3,8 @@ "rules": { "semi": ["error", "never"], "quotes": ["error", "double"], - "no-unused-vars": ["error", { "vars": "all", "args": "none" }] + "no-unused-vars": ["error", { "vars": "all", "args": "none" }], + "no-console": "off" }, "plugins": [ "import" diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index e33ec06d0a..b64fb1bea1 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,3 +1,23 @@ +* Compile ESM package that can be used directly in the browser as actioncable.esm.js. + + *DHH* + +* Move action_cable.js to actioncable.js to match naming convention used for other Rails frameworks, and use JS console to communicate the deprecation. + + *DHH* + +* Stop transpiling the UMD package generated as actioncable.js and drop the IE11 testing that relied on that. + + *DHH* + +* Truncate broadcast logging messages. + + *J Smith* + +* OpenSSL constants are now used for Digest computations. + + *Dirkjan Bussink* + * The Action Cable client now includes safeguards to prevent a "thundering herd" of client reconnects after server connectivity loss: @@ -11,4 +31,5 @@ *Jonathan Hefner* + Please check [6-1-stable](https://github.com/rails/rails/blob/6-1-stable/actioncable/CHANGELOG.md) for previous changes. diff --git a/actioncable/app/assets/javascripts/action_cable.js b/actioncable/app/assets/javascripts/action_cable.js index 49a84c65b5..371e0ca4f0 100644 --- a/actioncable/app/assets/javascripts/action_cable.js +++ b/actioncable/app/assets/javascripts/action_cable.js @@ -1,153 +1,113 @@ (function(global, factory) { - typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : factory(global.ActionCable = {}); -})(this, function(exports) { + typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, + factory(global.ActionCable = {})); +})(this, (function(exports) { "use strict"; var adapters = { logger: self.console, WebSocket: self.WebSocket }; var logger = { - log: function log() { + log(...messages) { if (this.enabled) { - var _adapters$logger; - for (var _len = arguments.length, messages = Array(_len), _key = 0; _key < _len; _key++) { - messages[_key] = arguments[_key]; - } messages.push(Date.now()); - (_adapters$logger = adapters.logger).log.apply(_adapters$logger, [ "[ActionCable]" ].concat(messages)); + adapters.logger.log("[ActionCable]", ...messages); } } }; - var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function(obj) { - return typeof obj; - } : function(obj) { - return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; - }; - var classCallCheck = function(instance, Constructor) { - if (!(instance instanceof Constructor)) { - throw new TypeError("Cannot call a class as a function"); - } - }; - var createClass = function() { - function defineProperties(target, props) { - for (var i = 0; i < props.length; i++) { - var descriptor = props[i]; - descriptor.enumerable = descriptor.enumerable || false; - descriptor.configurable = true; - if ("value" in descriptor) descriptor.writable = true; - Object.defineProperty(target, descriptor.key, descriptor); - } - } - return function(Constructor, protoProps, staticProps) { - if (protoProps) defineProperties(Constructor.prototype, protoProps); - if (staticProps) defineProperties(Constructor, staticProps); - return Constructor; - }; - }(); - var now = function now() { - return new Date().getTime(); - }; - var secondsSince = function secondsSince(time) { - return (now() - time) / 1e3; - }; - var ConnectionMonitor = function() { - function ConnectionMonitor(connection) { - classCallCheck(this, ConnectionMonitor); + const now = () => (new Date).getTime(); + const secondsSince = time => (now() - time) / 1e3; + class ConnectionMonitor { + constructor(connection) { this.visibilityDidChange = this.visibilityDidChange.bind(this); this.connection = connection; this.reconnectAttempts = 0; } - ConnectionMonitor.prototype.start = function start() { + start() { if (!this.isRunning()) { this.startedAt = now(); delete this.stoppedAt; this.startPolling(); addEventListener("visibilitychange", this.visibilityDidChange); - logger.log("ConnectionMonitor started. stale threshold = " + this.constructor.staleThreshold + " s"); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); } - }; - ConnectionMonitor.prototype.stop = function stop() { + } + stop() { if (this.isRunning()) { this.stoppedAt = now(); this.stopPolling(); removeEventListener("visibilitychange", this.visibilityDidChange); logger.log("ConnectionMonitor stopped"); } - }; - ConnectionMonitor.prototype.isRunning = function isRunning() { + } + isRunning() { return this.startedAt && !this.stoppedAt; - }; - ConnectionMonitor.prototype.recordPing = function recordPing() { + } + recordPing() { this.pingedAt = now(); - }; - ConnectionMonitor.prototype.recordConnect = function recordConnect() { + } + recordConnect() { this.reconnectAttempts = 0; this.recordPing(); delete this.disconnectedAt; logger.log("ConnectionMonitor recorded connect"); - }; - ConnectionMonitor.prototype.recordDisconnect = function recordDisconnect() { + } + recordDisconnect() { this.disconnectedAt = now(); logger.log("ConnectionMonitor recorded disconnect"); - }; - ConnectionMonitor.prototype.startPolling = function startPolling() { + } + startPolling() { this.stopPolling(); this.poll(); - }; - ConnectionMonitor.prototype.stopPolling = function stopPolling() { + } + stopPolling() { clearTimeout(this.pollTimeout); - }; - ConnectionMonitor.prototype.poll = function poll() { - var _this = this; - this.pollTimeout = setTimeout(function() { - _this.reconnectIfStale(); - _this.poll(); - }, this.getPollInterval()); - }; - ConnectionMonitor.prototype.getPollInterval = function getPollInterval() { - var _constructor = this.constructor, staleThreshold = _constructor.staleThreshold, reconnectionBackoffRate = _constructor.reconnectionBackoffRate; - var backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); - var jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; - var jitter = jitterMax * Math.random(); + } + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); return staleThreshold * 1e3 * backoff * (1 + jitter); - }; - ConnectionMonitor.prototype.reconnectIfStale = function reconnectIfStale() { + } + reconnectIfStale() { if (this.connectionIsStale()) { - logger.log("ConnectionMonitor detected stale connection. reconnectAttempts = " + this.reconnectAttempts + ", time stale = " + secondsSince(this.refreshedAt) + " s, stale threshold = " + this.constructor.staleThreshold + " s"); + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); this.reconnectAttempts++; if (this.disconnectedRecently()) { - logger.log("ConnectionMonitor skipping reopening recent disconnect. time disconnected = " + secondsSince(this.disconnectedAt) + " s"); + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); } else { logger.log("ConnectionMonitor reopening"); this.connection.reopen(); } } - }; - ConnectionMonitor.prototype.connectionIsStale = function connectionIsStale() { + } + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + connectionIsStale() { return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; - }; - ConnectionMonitor.prototype.disconnectedRecently = function disconnectedRecently() { + } + disconnectedRecently() { return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; - }; - ConnectionMonitor.prototype.visibilityDidChange = function visibilityDidChange() { - var _this2 = this; + } + visibilityDidChange() { if (document.visibilityState === "visible") { - setTimeout(function() { - if (_this2.connectionIsStale() || !_this2.connection.isOpen()) { - logger.log("ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = " + document.visibilityState); - _this2.connection.reopen(); + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); } - }, 200); + }), 200); } - }; - createClass(ConnectionMonitor, [ { - key: "refreshedAt", - get: function get$$1() { - return this.pingedAt ? this.pingedAt : this.startedAt; - } - } ]); - return ConnectionMonitor; - }(); + } + } ConnectionMonitor.staleThreshold = 6; ConnectionMonitor.reconnectionBackoffRate = .15; var INTERNAL = { @@ -166,32 +126,31 @@ default_mount_path: "/cable", protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] }; - var message_types = INTERNAL.message_types, protocols = INTERNAL.protocols; - var supportedProtocols = protocols.slice(0, protocols.length - 1); - var indexOf = [].indexOf; - var Connection = function() { - function Connection(consumer) { - classCallCheck(this, Connection); + const {message_types: message_types, protocols: protocols} = INTERNAL; + const supportedProtocols = protocols.slice(0, protocols.length - 1); + const indexOf = [].indexOf; + class Connection { + constructor(consumer) { this.open = this.open.bind(this); this.consumer = consumer; this.subscriptions = this.consumer.subscriptions; this.monitor = new ConnectionMonitor(this); this.disconnected = true; } - Connection.prototype.send = function send(data) { + send(data) { if (this.isOpen()) { this.webSocket.send(JSON.stringify(data)); return true; } else { return false; } - }; - Connection.prototype.open = function open() { + } + open() { if (this.isActive()) { - logger.log("Attempted to open WebSocket, but existing socket is " + this.getState()); + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); return false; } else { - logger.log("Opening WebSocket, current state is " + this.getState() + ", subprotocols: " + protocols); + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${protocols}`); if (this.webSocket) { this.uninstallEventHandlers(); } @@ -200,90 +159,85 @@ this.monitor.start(); return true; } - }; - Connection.prototype.close = function close() { - var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : { - allowReconnect: true - }, allowReconnect = _ref.allowReconnect; + } + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { if (!allowReconnect) { this.monitor.stop(); } if (this.isActive()) { return this.webSocket.close(); } - }; - Connection.prototype.reopen = function reopen() { - logger.log("Reopening WebSocket, current state is " + this.getState()); + } + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); if (this.isActive()) { try { return this.close(); } catch (error) { logger.log("Failed to reopen WebSocket", error); } finally { - logger.log("Reopening WebSocket in " + this.constructor.reopenDelay + "ms"); + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); setTimeout(this.open, this.constructor.reopenDelay); } } else { return this.open(); } - }; - Connection.prototype.getProtocol = function getProtocol() { + } + getProtocol() { if (this.webSocket) { return this.webSocket.protocol; } - }; - Connection.prototype.isOpen = function isOpen() { + } + isOpen() { return this.isState("open"); - }; - Connection.prototype.isActive = function isActive() { + } + isActive() { return this.isState("open", "connecting"); - }; - Connection.prototype.isProtocolSupported = function isProtocolSupported() { + } + isProtocolSupported() { return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; - }; - Connection.prototype.isState = function isState() { - for (var _len = arguments.length, states = Array(_len), _key = 0; _key < _len; _key++) { - states[_key] = arguments[_key]; - } + } + isState(...states) { return indexOf.call(states, this.getState()) >= 0; - }; - Connection.prototype.getState = function getState() { + } + getState() { if (this.webSocket) { - for (var state in adapters.WebSocket) { + for (let state in adapters.WebSocket) { if (adapters.WebSocket[state] === this.webSocket.readyState) { return state.toLowerCase(); } } } return null; - }; - Connection.prototype.installEventHandlers = function installEventHandlers() { - for (var eventName in this.events) { - var handler = this.events[eventName].bind(this); - this.webSocket["on" + eventName] = handler; + } + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; } - }; - Connection.prototype.uninstallEventHandlers = function uninstallEventHandlers() { - for (var eventName in this.events) { - this.webSocket["on" + eventName] = function() {}; + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; } - }; - return Connection; - }(); + } + } Connection.reopenDelay = 500; Connection.prototype.events = { - message: function message(event) { + message(event) { if (!this.isProtocolSupported()) { return; } - var _JSON$parse = JSON.parse(event.data), identifier = _JSON$parse.identifier, message = _JSON$parse.message, reason = _JSON$parse.reason, reconnect = _JSON$parse.reconnect, type = _JSON$parse.type; + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); switch (type) { case message_types.welcome: this.monitor.recordConnect(); return this.subscriptions.reload(); case message_types.disconnect: - logger.log("Disconnecting. Reason: " + reason); + logger.log(`Disconnecting. Reason: ${reason}`); return this.close({ allowReconnect: reconnect }); @@ -301,8 +255,8 @@ return this.subscriptions.notify(identifier, "received", message); } }, - open: function open() { - logger.log("WebSocket onopen event, using '" + this.getProtocol() + "' subprotocol"); + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); this.disconnected = false; if (!this.isProtocolSupported()) { logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); @@ -311,7 +265,7 @@ }); } }, - close: function close(event) { + close(event) { logger.log("WebSocket onclose event"); if (this.disconnected) { return; @@ -322,167 +276,136 @@ willAttemptReconnect: this.monitor.isRunning() }); }, - error: function error() { + error() { logger.log("WebSocket onerror event"); } }; - var extend = function extend(object, properties) { + const extend = function(object, properties) { if (properties != null) { - for (var key in properties) { - var value = properties[key]; + for (let key in properties) { + const value = properties[key]; object[key] = value; } } return object; }; - var Subscription = function() { - function Subscription(consumer) { - var params = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - var mixin = arguments[2]; - classCallCheck(this, Subscription); + class Subscription { + constructor(consumer, params = {}, mixin) { this.consumer = consumer; this.identifier = JSON.stringify(params); extend(this, mixin); } - Subscription.prototype.perform = function perform(action) { - var data = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + perform(action, data = {}) { data.action = action; return this.send(data); - }; - Subscription.prototype.send = function send(data) { + } + send(data) { return this.consumer.send({ command: "message", identifier: this.identifier, data: JSON.stringify(data) }); - }; - Subscription.prototype.unsubscribe = function unsubscribe() { + } + unsubscribe() { return this.consumer.subscriptions.remove(this); - }; - return Subscription; - }(); - var Subscriptions = function() { - function Subscriptions(consumer) { - classCallCheck(this, Subscriptions); + } + } + class Subscriptions { + constructor(consumer) { this.consumer = consumer; this.subscriptions = []; } - Subscriptions.prototype.create = function create(channelName, mixin) { - var channel = channelName; - var params = (typeof channel === "undefined" ? "undefined" : _typeof(channel)) === "object" ? channel : { + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { channel: channel }; - var subscription = new Subscription(this.consumer, params, mixin); + const subscription = new Subscription(this.consumer, params, mixin); return this.add(subscription); - }; - Subscriptions.prototype.add = function add(subscription) { + } + add(subscription) { this.subscriptions.push(subscription); this.consumer.ensureActiveConnection(); this.notify(subscription, "initialized"); this.sendCommand(subscription, "subscribe"); return subscription; - }; - Subscriptions.prototype.remove = function remove(subscription) { + } + remove(subscription) { this.forget(subscription); if (!this.findAll(subscription.identifier).length) { this.sendCommand(subscription, "unsubscribe"); } return subscription; - }; - Subscriptions.prototype.reject = function reject(identifier) { - var _this = this; - return this.findAll(identifier).map(function(subscription) { - _this.forget(subscription); - _this.notify(subscription, "rejected"); + } + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); return subscription; - }); - }; - Subscriptions.prototype.forget = function forget(subscription) { - this.subscriptions = this.subscriptions.filter(function(s) { - return s !== subscription; - }); + })); + } + forget(subscription) { + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); return subscription; - }; - Subscriptions.prototype.findAll = function findAll(identifier) { - return this.subscriptions.filter(function(s) { - return s.identifier === identifier; - }); - }; - Subscriptions.prototype.reload = function reload() { - var _this2 = this; - return this.subscriptions.map(function(subscription) { - return _this2.sendCommand(subscription, "subscribe"); - }); - }; - Subscriptions.prototype.notifyAll = function notifyAll(callbackName) { - var _this3 = this; - for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { - args[_key - 1] = arguments[_key]; - } - return this.subscriptions.map(function(subscription) { - return _this3.notify.apply(_this3, [ subscription, callbackName ].concat(args)); - }); - }; - Subscriptions.prototype.notify = function notify(subscription, callbackName) { - for (var _len2 = arguments.length, args = Array(_len2 > 2 ? _len2 - 2 : 0), _key2 = 2; _key2 < _len2; _key2++) { - args[_key2 - 2] = arguments[_key2]; - } - var subscriptions = void 0; + } + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); + } + reload() { + return this.subscriptions.map((subscription => this.sendCommand(subscription, "subscribe"))); + } + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); + } + notify(subscription, callbackName, ...args) { + let subscriptions; if (typeof subscription === "string") { subscriptions = this.findAll(subscription); } else { subscriptions = [ subscription ]; } - return subscriptions.map(function(subscription) { - return typeof subscription[callbackName] === "function" ? subscription[callbackName].apply(subscription, args) : undefined; - }); - }; - Subscriptions.prototype.sendCommand = function sendCommand(subscription, command) { - var identifier = subscription.identifier; + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)); + } + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; return this.consumer.send({ command: command, identifier: identifier }); - }; - return Subscriptions; - }(); - var Consumer = function() { - function Consumer(url) { - classCallCheck(this, Consumer); + } + } + class Consumer { + constructor(url) { this._url = url; this.subscriptions = new Subscriptions(this); this.connection = new Connection(this); } - Consumer.prototype.send = function send(data) { + get url() { + return createWebSocketURL(this._url); + } + send(data) { return this.connection.send(data); - }; - Consumer.prototype.connect = function connect() { + } + connect() { return this.connection.open(); - }; - Consumer.prototype.disconnect = function disconnect() { + } + disconnect() { return this.connection.close({ allowReconnect: false }); - }; - Consumer.prototype.ensureActiveConnection = function ensureActiveConnection() { + } + ensureActiveConnection() { if (!this.connection.isActive()) { return this.connection.open(); } - }; - createClass(Consumer, [ { - key: "url", - get: function get$$1() { - return createWebSocketURL(this._url); - } - } ]); - return Consumer; - }(); + } + } function createWebSocketURL(url) { if (typeof url === "function") { url = url(); } if (url && !/^wss?:/i.test(url)) { - var a = document.createElement("a"); + const a = document.createElement("a"); a.href = url; a.href = a.href; a.protocol = a.protocol.replace("http", "ws"); @@ -491,16 +414,16 @@ return url; } } - function createConsumer() { - var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getConfig("url") || INTERNAL.default_mount_path; + function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { return new Consumer(url); } function getConfig(name) { - var element = document.head.querySelector("meta[name='action-cable-" + name + "']"); + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); if (element) { return element.getAttribute("content"); } } + console.log("DEPRECATION: action_cable.js has been renamed to actioncable.js – please update your reference before Rails 8"); exports.Connection = Connection; exports.ConnectionMonitor = ConnectionMonitor; exports.Consumer = Consumer; @@ -508,11 +431,11 @@ exports.Subscription = Subscription; exports.Subscriptions = Subscriptions; exports.adapters = adapters; - exports.createWebSocketURL = createWebSocketURL; - exports.logger = logger; exports.createConsumer = createConsumer; + exports.createWebSocketURL = createWebSocketURL; exports.getConfig = getConfig; + exports.logger = logger; Object.defineProperty(exports, "__esModule", { value: true }); -}); +})); diff --git a/actioncable/app/assets/javascripts/actioncable.esm.js b/actioncable/app/assets/javascripts/actioncable.esm.js new file mode 100644 index 0000000000..d69e8ecd89 --- /dev/null +++ b/actioncable/app/assets/javascripts/actioncable.esm.js @@ -0,0 +1,442 @@ +var adapters = { + logger: self.console, + WebSocket: self.WebSocket +}; + +var logger = { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()); + adapters.logger.log("[ActionCable]", ...messages); + } + } +}; + +const now = () => (new Date).getTime(); + +const secondsSince = time => (now() - time) / 1e3; + +class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; + } + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener("visibilitychange", this.visibilityDidChange); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); + } + } + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener("visibilitychange", this.visibilityDidChange); + logger.log("ConnectionMonitor stopped"); + } + } + isRunning() { + return this.startedAt && !this.stoppedAt; + } + recordPing() { + this.pingedAt = now(); + } + recordConnect() { + this.reconnectAttempts = 0; + this.recordPing(); + delete this.disconnectedAt; + logger.log("ConnectionMonitor recorded connect"); + } + recordDisconnect() { + this.disconnectedAt = now(); + logger.log("ConnectionMonitor recorded disconnect"); + } + startPolling() { + this.stopPolling(); + this.poll(); + } + stopPolling() { + clearTimeout(this.pollTimeout); + } + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1e3 * backoff * (1 + jitter); + } + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); + this.reconnectAttempts++; + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); + } else { + logger.log("ConnectionMonitor reopening"); + this.connection.reopen(); + } + } + } + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + disconnectedRecently() { + return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; + } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); + } + }), 200); + } + } +} + +ConnectionMonitor.staleThreshold = 6; + +ConnectionMonitor.reconnectionBackoffRate = .15; + +var INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart" + }, + default_mount_path: "/cable", + protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] +}; + +const {message_types: message_types, protocols: protocols} = INTERNAL; + +const supportedProtocols = protocols.slice(0, protocols.length - 1); + +const indexOf = [].indexOf; + +class Connection { + constructor(consumer) { + this.open = this.open.bind(this); + this.consumer = consumer; + this.subscriptions = this.consumer.subscriptions; + this.monitor = new ConnectionMonitor(this); + this.disconnected = true; + } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)); + return true; + } else { + return false; + } + } + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); + return false; + } else { + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${protocols}`); + if (this.webSocket) { + this.uninstallEventHandlers(); + } + this.webSocket = new adapters.WebSocket(this.consumer.url, protocols); + this.installEventHandlers(); + this.monitor.start(); + return true; + } + } + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { + if (!allowReconnect) { + this.monitor.stop(); + } + if (this.isActive()) { + return this.webSocket.close(); + } + } + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); + if (this.isActive()) { + try { + return this.close(); + } catch (error) { + logger.log("Failed to reopen WebSocket", error); + } finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); + setTimeout(this.open, this.constructor.reopenDelay); + } + } else { + return this.open(); + } + } + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol; + } + } + isOpen() { + return this.isState("open"); + } + isActive() { + return this.isState("open", "connecting"); + } + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; + } + isState(...states) { + return indexOf.call(states, this.getState()) >= 0; + } + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase(); + } + } + } + return null; + } + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; + } + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; + } + } +} + +Connection.reopenDelay = 500; + +Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { + return; + } + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); + switch (type) { + case message_types.welcome: + this.monitor.recordConnect(); + return this.subscriptions.reload(); + + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`); + return this.close({ + allowReconnect: reconnect + }); + + case message_types.ping: + return this.monitor.recordPing(); + + case message_types.confirmation: + return this.subscriptions.notify(identifier, "connected"); + + case message_types.rejection: + return this.subscriptions.reject(identifier); + + default: + return this.subscriptions.notify(identifier, "received", message); + } + }, + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); + this.disconnected = false; + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); + return this.close({ + allowReconnect: false + }); + } + }, + close(event) { + logger.log("WebSocket onclose event"); + if (this.disconnected) { + return; + } + this.disconnected = true; + this.monitor.recordDisconnect(); + return this.subscriptions.notifyAll("disconnected", { + willAttemptReconnect: this.monitor.isRunning() + }); + }, + error() { + logger.log("WebSocket onerror event"); + } +}; + +const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key]; + object[key] = value; + } + } + return object; +}; + +class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer; + this.identifier = JSON.stringify(params); + extend(this, mixin); + } + perform(action, data = {}) { + data.action = action; + return this.send(data); + } + send(data) { + return this.consumer.send({ + command: "message", + identifier: this.identifier, + data: JSON.stringify(data) + }); + } + unsubscribe() { + return this.consumer.subscriptions.remove(this); + } +} + +class Subscriptions { + constructor(consumer) { + this.consumer = consumer; + this.subscriptions = []; + } + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { + channel: channel + }; + const subscription = new Subscription(this.consumer, params, mixin); + return this.add(subscription); + } + add(subscription) { + this.subscriptions.push(subscription); + this.consumer.ensureActiveConnection(); + this.notify(subscription, "initialized"); + this.sendCommand(subscription, "subscribe"); + return subscription; + } + remove(subscription) { + this.forget(subscription); + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe"); + } + return subscription; + } + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); + return subscription; + })); + } + forget(subscription) { + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); + return subscription; + } + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); + } + reload() { + return this.subscriptions.map((subscription => this.sendCommand(subscription, "subscribe"))); + } + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); + } + notify(subscription, callbackName, ...args) { + let subscriptions; + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription); + } else { + subscriptions = [ subscription ]; + } + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)); + } + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; + return this.consumer.send({ + command: command, + identifier: identifier + }); + } +} + +class Consumer { + constructor(url) { + this._url = url; + this.subscriptions = new Subscriptions(this); + this.connection = new Connection(this); + } + get url() { + return createWebSocketURL(this._url); + } + send(data) { + return this.connection.send(data); + } + connect() { + return this.connection.open(); + } + disconnect() { + return this.connection.close({ + allowReconnect: false + }); + } + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open(); + } + } +} + +function createWebSocketURL(url) { + if (typeof url === "function") { + url = url(); + } + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a"); + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace("http", "ws"); + return a.href; + } else { + return url; + } +} + +function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url); +} + +function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); + if (element) { + return element.getAttribute("content"); + } +} + +export { Connection, ConnectionMonitor, Consumer, INTERNAL, Subscription, Subscriptions, adapters, createConsumer, createWebSocketURL, getConfig, logger }; diff --git a/actioncable/app/assets/javascripts/actioncable.js b/actioncable/app/assets/javascripts/actioncable.js new file mode 100644 index 0000000000..7bc8ebb5db --- /dev/null +++ b/actioncable/app/assets/javascripts/actioncable.js @@ -0,0 +1,440 @@ +(function(global, factory) { + typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, + factory(global.ActionCable = {})); +})(this, (function(exports) { + "use strict"; + var adapters = { + logger: self.console, + WebSocket: self.WebSocket + }; + var logger = { + log(...messages) { + if (this.enabled) { + messages.push(Date.now()); + adapters.logger.log("[ActionCable]", ...messages); + } + } + }; + const now = () => (new Date).getTime(); + const secondsSince = time => (now() - time) / 1e3; + class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this); + this.connection = connection; + this.reconnectAttempts = 0; + } + start() { + if (!this.isRunning()) { + this.startedAt = now(); + delete this.stoppedAt; + this.startPolling(); + addEventListener("visibilitychange", this.visibilityDidChange); + logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); + } + } + stop() { + if (this.isRunning()) { + this.stoppedAt = now(); + this.stopPolling(); + removeEventListener("visibilitychange", this.visibilityDidChange); + logger.log("ConnectionMonitor stopped"); + } + } + isRunning() { + return this.startedAt && !this.stoppedAt; + } + recordPing() { + this.pingedAt = now(); + } + recordConnect() { + this.reconnectAttempts = 0; + this.recordPing(); + delete this.disconnectedAt; + logger.log("ConnectionMonitor recorded connect"); + } + recordDisconnect() { + this.disconnectedAt = now(); + logger.log("ConnectionMonitor recorded disconnect"); + } + startPolling() { + this.stopPolling(); + this.poll(); + } + stopPolling() { + clearTimeout(this.pollTimeout); + } + poll() { + this.pollTimeout = setTimeout((() => { + this.reconnectIfStale(); + this.poll(); + }), this.getPollInterval()); + } + getPollInterval() { + const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor; + const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); + const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; + const jitter = jitterMax * Math.random(); + return staleThreshold * 1e3 * backoff * (1 + jitter); + } + reconnectIfStale() { + if (this.connectionIsStale()) { + logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); + this.reconnectAttempts++; + if (this.disconnectedRecently()) { + logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); + } else { + logger.log("ConnectionMonitor reopening"); + this.connection.reopen(); + } + } + } + get refreshedAt() { + return this.pingedAt ? this.pingedAt : this.startedAt; + } + connectionIsStale() { + return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; + } + disconnectedRecently() { + return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; + } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout((() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); + this.connection.reopen(); + } + }), 200); + } + } + } + ConnectionMonitor.staleThreshold = 6; + ConnectionMonitor.reconnectionBackoffRate = .15; + var INTERNAL = { + message_types: { + welcome: "welcome", + disconnect: "disconnect", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" + }, + disconnect_reasons: { + unauthorized: "unauthorized", + invalid_request: "invalid_request", + server_restart: "server_restart" + }, + default_mount_path: "/cable", + protocols: [ "actioncable-v1-json", "actioncable-unsupported" ] + }; + const {message_types: message_types, protocols: protocols} = INTERNAL; + const supportedProtocols = protocols.slice(0, protocols.length - 1); + const indexOf = [].indexOf; + class Connection { + constructor(consumer) { + this.open = this.open.bind(this); + this.consumer = consumer; + this.subscriptions = this.consumer.subscriptions; + this.monitor = new ConnectionMonitor(this); + this.disconnected = true; + } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)); + return true; + } else { + return false; + } + } + open() { + if (this.isActive()) { + logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); + return false; + } else { + logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${protocols}`); + if (this.webSocket) { + this.uninstallEventHandlers(); + } + this.webSocket = new adapters.WebSocket(this.consumer.url, protocols); + this.installEventHandlers(); + this.monitor.start(); + return true; + } + } + close({allowReconnect: allowReconnect} = { + allowReconnect: true + }) { + if (!allowReconnect) { + this.monitor.stop(); + } + if (this.isActive()) { + return this.webSocket.close(); + } + } + reopen() { + logger.log(`Reopening WebSocket, current state is ${this.getState()}`); + if (this.isActive()) { + try { + return this.close(); + } catch (error) { + logger.log("Failed to reopen WebSocket", error); + } finally { + logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); + setTimeout(this.open, this.constructor.reopenDelay); + } + } else { + return this.open(); + } + } + getProtocol() { + if (this.webSocket) { + return this.webSocket.protocol; + } + } + isOpen() { + return this.isState("open"); + } + isActive() { + return this.isState("open", "connecting"); + } + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; + } + isState(...states) { + return indexOf.call(states, this.getState()) >= 0; + } + getState() { + if (this.webSocket) { + for (let state in adapters.WebSocket) { + if (adapters.WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase(); + } + } + } + return null; + } + installEventHandlers() { + for (let eventName in this.events) { + const handler = this.events[eventName].bind(this); + this.webSocket[`on${eventName}`] = handler; + } + } + uninstallEventHandlers() { + for (let eventName in this.events) { + this.webSocket[`on${eventName}`] = function() {}; + } + } + } + Connection.reopenDelay = 500; + Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { + return; + } + const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data); + switch (type) { + case message_types.welcome: + this.monitor.recordConnect(); + return this.subscriptions.reload(); + + case message_types.disconnect: + logger.log(`Disconnecting. Reason: ${reason}`); + return this.close({ + allowReconnect: reconnect + }); + + case message_types.ping: + return this.monitor.recordPing(); + + case message_types.confirmation: + return this.subscriptions.notify(identifier, "connected"); + + case message_types.rejection: + return this.subscriptions.reject(identifier); + + default: + return this.subscriptions.notify(identifier, "received", message); + } + }, + open() { + logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); + this.disconnected = false; + if (!this.isProtocolSupported()) { + logger.log("Protocol is unsupported. Stopping monitor and disconnecting."); + return this.close({ + allowReconnect: false + }); + } + }, + close(event) { + logger.log("WebSocket onclose event"); + if (this.disconnected) { + return; + } + this.disconnected = true; + this.monitor.recordDisconnect(); + return this.subscriptions.notifyAll("disconnected", { + willAttemptReconnect: this.monitor.isRunning() + }); + }, + error() { + logger.log("WebSocket onerror event"); + } + }; + const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key]; + object[key] = value; + } + } + return object; + }; + class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer; + this.identifier = JSON.stringify(params); + extend(this, mixin); + } + perform(action, data = {}) { + data.action = action; + return this.send(data); + } + send(data) { + return this.consumer.send({ + command: "message", + identifier: this.identifier, + data: JSON.stringify(data) + }); + } + unsubscribe() { + return this.consumer.subscriptions.remove(this); + } + } + class Subscriptions { + constructor(consumer) { + this.consumer = consumer; + this.subscriptions = []; + } + create(channelName, mixin) { + const channel = channelName; + const params = typeof channel === "object" ? channel : { + channel: channel + }; + const subscription = new Subscription(this.consumer, params, mixin); + return this.add(subscription); + } + add(subscription) { + this.subscriptions.push(subscription); + this.consumer.ensureActiveConnection(); + this.notify(subscription, "initialized"); + this.sendCommand(subscription, "subscribe"); + return subscription; + } + remove(subscription) { + this.forget(subscription); + if (!this.findAll(subscription.identifier).length) { + this.sendCommand(subscription, "unsubscribe"); + } + return subscription; + } + reject(identifier) { + return this.findAll(identifier).map((subscription => { + this.forget(subscription); + this.notify(subscription, "rejected"); + return subscription; + })); + } + forget(subscription) { + this.subscriptions = this.subscriptions.filter((s => s !== subscription)); + return subscription; + } + findAll(identifier) { + return this.subscriptions.filter((s => s.identifier === identifier)); + } + reload() { + return this.subscriptions.map((subscription => this.sendCommand(subscription, "subscribe"))); + } + notifyAll(callbackName, ...args) { + return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args))); + } + notify(subscription, callbackName, ...args) { + let subscriptions; + if (typeof subscription === "string") { + subscriptions = this.findAll(subscription); + } else { + subscriptions = [ subscription ]; + } + return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined)); + } + sendCommand(subscription, command) { + const {identifier: identifier} = subscription; + return this.consumer.send({ + command: command, + identifier: identifier + }); + } + } + class Consumer { + constructor(url) { + this._url = url; + this.subscriptions = new Subscriptions(this); + this.connection = new Connection(this); + } + get url() { + return createWebSocketURL(this._url); + } + send(data) { + return this.connection.send(data); + } + connect() { + return this.connection.open(); + } + disconnect() { + return this.connection.close({ + allowReconnect: false + }); + } + ensureActiveConnection() { + if (!this.connection.isActive()) { + return this.connection.open(); + } + } + } + function createWebSocketURL(url) { + if (typeof url === "function") { + url = url(); + } + if (url && !/^wss?:/i.test(url)) { + const a = document.createElement("a"); + a.href = url; + a.href = a.href; + a.protocol = a.protocol.replace("http", "ws"); + return a.href; + } else { + return url; + } + } + function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) { + return new Consumer(url); + } + function getConfig(name) { + const element = document.head.querySelector(`meta[name='action-cable-${name}']`); + if (element) { + return element.getAttribute("content"); + } + } + exports.Connection = Connection; + exports.ConnectionMonitor = ConnectionMonitor; + exports.Consumer = Consumer; + exports.INTERNAL = INTERNAL; + exports.Subscription = Subscription; + exports.Subscriptions = Subscriptions; + exports.adapters = adapters; + exports.createConsumer = createConsumer; + exports.createWebSocketURL = createWebSocketURL; + exports.getConfig = getConfig; + exports.logger = logger; + Object.defineProperty(exports, "__esModule", { + value: true + }); +})); diff --git a/actioncable/app/javascript/action_cable/index_with_name_deprecation.js b/actioncable/app/javascript/action_cable/index_with_name_deprecation.js new file mode 100644 index 0000000000..272ba7bec2 --- /dev/null +++ b/actioncable/app/javascript/action_cable/index_with_name_deprecation.js @@ -0,0 +1,2 @@ +export * from "./index" +console.log("DEPRECATION: action_cable.js has been renamed to actioncable.js – please update your reference before Rails 8") diff --git a/actioncable/karma.conf.js b/actioncable/karma.conf.js index 83e9c98af1..bf7c1b2fbb 100644 --- a/actioncable/karma.conf.js +++ b/actioncable/karma.conf.js @@ -27,7 +27,6 @@ if (process.env.CI) { sl_ff: sauce("firefox", 63), sl_safari: sauce("safari", 12.0, "macOS 10.13"), sl_edge: sauce("microsoftedge", 17.17134, "Windows 10"), - sl_ie_11: sauce("internet explorer", 11, "Windows 8.1"), } config.browsers = Object.keys(config.customLaunchers) diff --git a/actioncable/lib/action_cable/channel/broadcasting.rb b/actioncable/lib/action_cable/channel/broadcasting.rb index 9f702e425e..c0e16b4666 100644 --- a/actioncable/lib/action_cable/channel/broadcasting.rb +++ b/actioncable/lib/action_cable/channel/broadcasting.rb @@ -25,7 +25,7 @@ module ActionCable serialize_broadcasting([ channel_name, model ]) end - def serialize_broadcasting(object) #:nodoc: + def serialize_broadcasting(object) # :nodoc: case when object.is_a?(Array) object.map { |m| serialize_broadcasting(m) }.join(":") diff --git a/actioncable/lib/action_cable/connection/base.rb b/actioncable/lib/action_cable/connection/base.rb index a243a91def..dfdfa716a3 100644 --- a/actioncable/lib/action_cable/connection/base.rb +++ b/actioncable/lib/action_cable/connection/base.rb @@ -68,7 +68,7 @@ module ActionCable # Called by the server when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user. # This method should not be called directly -- instead rely upon on the #connect (and #disconnect) callbacks. - def process #:nodoc: + def process # :nodoc: logger.info started_request_message if websocket.possible? && allow_request_origin? @@ -80,11 +80,11 @@ module ActionCable # Decodes WebSocket messages and dispatches them to subscribed channels. # WebSocket message transfer encoding is always JSON. - def receive(websocket_message) #:nodoc: + def receive(websocket_message) # :nodoc: send_async :dispatch_websocket_message, websocket_message end - def dispatch_websocket_message(websocket_message) #:nodoc: + def dispatch_websocket_message(websocket_message) # :nodoc: if websocket.alive? subscriptions.execute_command decode(websocket_message) else diff --git a/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb index 85831806a9..c7a0f07b19 100644 --- a/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb +++ b/actioncable/lib/action_cable/connection/tagged_logger_proxy.rb @@ -18,10 +18,10 @@ module ActionCable @tags = @tags.uniq end - def tag(logger) + def tag(logger, &block) if logger.respond_to?(:tagged) current_tags = tags - logger.formatter.current_tags - logger.tagged(*current_tags) { yield } + logger.tagged(*current_tags, &block) else yield end diff --git a/actioncable/lib/action_cable/engine.rb b/actioncable/lib/action_cable/engine.rb index 3c37040293..9d95b28d22 100644 --- a/actioncable/lib/action_cable/engine.rb +++ b/actioncable/lib/action_cable/engine.rb @@ -9,6 +9,7 @@ module ActionCable class Engine < Rails::Engine # :nodoc: config.action_cable = ActiveSupport::OrderedOptions.new config.action_cable.mount_path = ActionCable::INTERNAL[:default_mount_path] + config.action_cable.precompile_assets = true config.eager_load_namespaces << ActionCable @@ -22,6 +23,14 @@ module ActionCable ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger } end + initializer "action_cable.asset" do + config.after_initialize do |app| + if Rails.application.config.respond_to?(:assets) && app.config.action_cable.precompile_assets + Rails.application.config.assets.precompile += %w( actioncable.js actioncable.esm.js ) + end + end + end + initializer "action_cable.set_configs" do |app| options = app.config.action_cable options.allowed_request_origins ||= /https?:\/\/localhost:\d+/ if ::Rails.env.development? diff --git a/actioncable/lib/action_cable/helpers/action_cable_helper.rb b/actioncable/lib/action_cable/helpers/action_cable_helper.rb index aa0c194e28..26cddad2b6 100644 --- a/actioncable/lib/action_cable/helpers/action_cable_helper.rb +++ b/actioncable/lib/action_cable/helpers/action_cable_helper.rb @@ -8,14 +8,15 @@ module ActionCable # #
# <%= action_cable_meta_tag %> - # <%= javascript_include_tag 'application', 'data-turbolinks-track' => 'reload' %> + # <%= javascript_include_tag 'application', 'data-turbo-track' => 'reload' %> # # # This is then used by Action Cable to determine the URL of your WebSocket server. # Your JavaScript can then connect to the server without needing to specify the # URL directly: # - # window.Cable = require("@rails/actioncable") + # import Cable from "@rails/actioncable" + # window.Cable = Cable # window.App = {} # App.cable = Cable.createConsumer() # diff --git a/actioncable/lib/action_cable/server/broadcasting.rb b/actioncable/lib/action_cable/server/broadcasting.rb index b73cfa7d1f..fd6d926912 100644 --- a/actioncable/lib/action_cable/server/broadcasting.rb +++ b/actioncable/lib/action_cable/server/broadcasting.rb @@ -40,7 +40,7 @@ module ActionCable end def broadcast(message) - server.logger.debug { "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect}" } + server.logger.debug { "[ActionCable] Broadcasting to #{broadcasting}: #{message.inspect.truncate(300)}" } payload = { broadcasting: broadcasting, message: message, coder: coder } ActiveSupport::Notifications.instrument("broadcast.action_cable", payload) do diff --git a/actioncable/lib/action_cable/server/configuration.rb b/actioncable/lib/action_cable/server/configuration.rb index 26209537df..49567ddedb 100644 --- a/actioncable/lib/action_cable/server/configuration.rb +++ b/actioncable/lib/action_cable/server/configuration.rb @@ -9,6 +9,7 @@ module ActionCable attr_accessor :connection_class, :worker_pool_size attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host attr_accessor :cable, :url, :mount_path + attr_accessor :precompile_assets def initialize @log_tags = [] diff --git a/actioncable/lib/action_cable/server/worker.rb b/actioncable/lib/action_cable/server/worker.rb index e9b130e5f0..e1b75d4a69 100644 --- a/actioncable/lib/action_cable/server/worker.rb +++ b/actioncable/lib/action_cable/server/worker.rb @@ -36,12 +36,10 @@ module ActionCable @executor.shuttingdown? end - def work(connection) + def work(connection, &block) self.connection = connection - run_callbacks :work do - yield - end + run_callbacks :work, &block ensure self.connection = nil end diff --git a/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb index 2e378d4bf3..63f20fa34d 100644 --- a/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb +++ b/actioncable/lib/action_cable/server/worker/active_record_connection_management.rb @@ -12,8 +12,8 @@ module ActionCable end end - def with_database_connections - connection.logger.tag(ActiveRecord::Base.logger) { yield } + def with_database_connections(&block) + connection.logger.tag(ActiveRecord::Base.logger, &block) end end end diff --git a/actioncable/lib/action_cable/subscription_adapter/postgresql.rb b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb index 3b114d4e54..563a2aa3c9 100644 --- a/actioncable/lib/action_cable/subscription_adapter/postgresql.rb +++ b/actioncable/lib/action_cable/subscription_adapter/postgresql.rb @@ -3,7 +3,7 @@ gem "pg", "~> 1.1" require "pg" require "thread" -require "digest/sha1" +require "openssl" module ActionCable module SubscriptionAdapter @@ -58,7 +58,7 @@ module ActionCable private def channel_identifier(channel) - channel.size > 63 ? Digest::SHA1.hexdigest(channel) : channel + channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel end def listener diff --git a/actioncable/lib/action_cable/test_helper.rb b/actioncable/lib/action_cable/test_helper.rb index 3d709f6519..d235d20d2d 100644 --- a/actioncable/lib/action_cable/test_helper.rb +++ b/actioncable/lib/action_cable/test_helper.rb @@ -45,7 +45,7 @@ module ActionCable def assert_broadcasts(stream, number, &block) if block_given? original_count = broadcasts_size(stream) - assert_nothing_raised(&block) + _assert_nothing_raised_or_warn("assert_broadcasts", &block) new_count = broadcasts_size(stream) actual_count = new_count - original_count else @@ -106,7 +106,7 @@ module ActionCable old_messages = new_messages clear_messages(stream) - assert_nothing_raised(&block) + _assert_nothing_raised_or_warn("assert_broadcast_on", &block) new_messages = broadcasts(stream) clear_messages(stream) diff --git a/actioncable/lib/rails/generators/channel/USAGE b/actioncable/lib/rails/generators/channel/USAGE index a603af1993..6dda8c9858 100644 --- a/actioncable/lib/rails/generators/channel/USAGE +++ b/actioncable/lib/rails/generators/channel/USAGE @@ -10,4 +10,4 @@ Example: creates a Chat channel class, test and JavaScript asset: Channel: app/channels/chat_channel.rb Test: test/channels/chat_channel_test.rb - Assets: app/javascript/channels/chat_channel.js + Assets: $JAVASCRIPT_PATH/channels/chat_channel.js diff --git a/actioncable/lib/rails/generators/channel/channel_generator.rb b/actioncable/lib/rails/generators/channel/channel_generator.rb index 0b80d1f96b..d61b6e02f6 100644 --- a/actioncable/lib/rails/generators/channel/channel_generator.rb +++ b/actioncable/lib/rails/generators/channel/channel_generator.rb @@ -13,39 +13,98 @@ module Rails hook_for :test_framework - def create_channel_file - template "channel.rb", File.join("app/channels", class_path, "#{file_name}_channel.rb") + def create_channel_files + create_shared_channel_files + create_channel_file - if options[:assets] - if behavior == :invoke - template "javascript/index.js", "app/javascript/channels/index.js" - template "javascript/consumer.js", "app/javascript/channels/consumer.js" + if using_javascript? + if first_setup_required? + create_shared_channel_javascript_files + import_channels_in_javascript_entrypoint + + if using_importmap? + pin_javascript_dependencies + elsif using_node? + install_javascript_dependencies + end end - js_template "javascript/channel", File.join("app/javascript/channels", class_path, "#{file_name}_channel") + create_channel_javascript_file + import_channel_in_javascript_entrypoint end - - generate_application_cable_files end private + def create_shared_channel_files + return if behavior != :invoke + + copy_file "#{__dir__}/templates/application_cable/channel.rb", + "app/channels/application_cable/channel.rb" + copy_file "#{__dir__}/templates/application_cable/connection.rb", + "app/channels/application_cable/connection.rb" + end + + def create_channel_file + template "channel.rb", + File.join("app/channels", class_path, "#{file_name}_channel.rb") + end + + def create_shared_channel_javascript_files + template "javascript/index.js", "app/javascript/channels/index.js" + template "javascript/consumer.js", "app/javascript/channels/consumer.js" + end + + def create_channel_javascript_file + channel_js_path = File.join("app/javascript/channels", class_path, "#{file_name}_channel") + js_template "javascript/channel", channel_js_path + gsub_file "#{channel_js_path}.js", /\.\/consumer/, "channels/consumer" unless using_node? + end + + def import_channels_in_javascript_entrypoint + append_to_file "app/javascript/application.js", + using_node? ? %(import "./channels"\n) : %(import "channels"\n) + end + + def import_channel_in_javascript_entrypoint + append_to_file "app/javascript/channels/index.js", + using_node? ? %(import "./#{file_name}_channel"\n) : %(import "channels/#{file_name}_channel"\n) + end + + def install_javascript_dependencies + say "Installing JavaScript dependencies", :green + run "yarn add @rails/actioncable" + end + + def pin_javascript_dependencies + append_to_file "config/importmap.rb", <<-RUBY +pin "@rails/actioncable", to: "actioncable.esm.js" +pin_all_from "app/javascript/channels", under: "channels" + RUBY + end + + def file_name @_file_name ||= super.sub(/_channel\z/i, "") end - # FIXME: Change these files to symlinks once RubyGems 2.5.0 is required. - def generate_application_cable_files - return if behavior != :invoke + def first_setup_required? + !root.join("app/javascript/channels/index.js").exist? + end - files = [ - "application_cable/channel.rb", - "application_cable/connection.rb" - ] + def using_javascript? + @using_javascript ||= options[:assets] && root.join("app/javascript").exist? + end - files.each do |name| - path = File.join("app/channels/", name) - template(name, path) if !File.exist?(path) - end + def using_node? + @using_node ||= root.join("package.json").exist? + end + + def using_importmap? + @using_importmap ||= root.join("config/importmap.rb").exist? + end + + def root + @root ||= Pathname(destination_root) end end end diff --git a/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt b/actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb similarity index 100% rename from actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb.tt rename to actioncable/lib/rails/generators/channel/templates/application_cable/channel.rb diff --git a/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt b/actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb similarity index 100% rename from actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb.tt rename to actioncable/lib/rails/generators/channel/templates/application_cable/connection.rb diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt index 0cfcf74919..08dc8af2a0 100644 --- a/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt +++ b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt @@ -1,5 +1 @@ -// Load all the channels within this directory and all subdirectories. -// Channel files must be named *_channel.js. - -const channels = require.context('.', true, /_channel\.js$/) -channels.keys().forEach(channels) +// Import all the channels to be used by Action Cable diff --git a/actioncable/package.json b/actioncable/package.json index 62494a1c74..90ba6332ae 100644 --- a/actioncable/package.json +++ b/actioncable/package.json @@ -2,6 +2,7 @@ "name": "@rails/actioncable", "version": "7.0.0-alpha", "description": "WebSocket framework for Ruby on Rails.", + "module": "app/javascript/action_cable/index.js", "main": "app/assets/javascripts/action_cable.js", "files": [ "app/assets/javascripts/*.js", @@ -23,9 +24,8 @@ }, "homepage": "https://rubyonrails.org/", "devDependencies": { - "babel-core": "^6.25.0", - "babel-plugin-external-helpers": "^6.22.0", - "babel-preset-env": "^1.6.0", + "@rollup/plugin-node-resolve": "^11.0.1", + "@rollup/plugin-commonjs": "^19.0.1", "eslint": "^4.3.0", "eslint-plugin-import": "^2.7.0", "karma": "^3.1.1", @@ -34,11 +34,8 @@ "karma-sauce-launcher": "^1.2.0", "mock-socket": "^2.0.0", "qunit": "^2.8.0", - "rollup": "^0.58.2", - "rollup-plugin-babel": "^3.0.4", - "rollup-plugin-commonjs": "^9.1.0", - "rollup-plugin-node-resolve": "^3.3.0", - "rollup-plugin-uglify": "^3.0.0" + "rollup": "^2.35.1", + "rollup-plugin-terser": "^7.0.2" }, "scripts": { "prebuild": "yarn lint && bundle exec rake assets:codegen", diff --git a/actioncable/rollup.config.js b/actioncable/rollup.config.js index 64727e0887..56ea857e27 100644 --- a/actioncable/rollup.config.js +++ b/actioncable/rollup.config.js @@ -1,24 +1,47 @@ -import babel from "rollup-plugin-babel" -import uglify from "rollup-plugin-uglify" +import resolve from "@rollup/plugin-node-resolve" +import { terser } from "rollup-plugin-terser" -const uglifyOptions = { +const terserOptions = { mangle: false, compress: false, - output: { + format: { beautify: true, indent_level: 2 } } -export default { - input: "app/javascript/action_cable/index.js", - output: { - file: "app/assets/javascripts/action_cable.js", - format: "umd", - name: "ActionCable" +export default [ + { + input: "app/javascript/action_cable/index.js", + output: [ + { + file: "app/assets/javascripts/actioncable.js", + format: "umd", + name: "ActionCable" + }, + + { + file: "app/assets/javascripts/actioncable.esm.js", + format: "es" + } + ], + plugins: [ + resolve(), + terser(terserOptions) + ] }, - plugins: [ - babel(), - uglify(uglifyOptions) - ] -} + + { + input: "app/javascript/action_cable/index_with_name_deprecation.js", + output: { + file: "app/assets/javascripts/action_cable.js", + format: "umd", + name: "ActionCable" + }, + breakOnWarning: false, + plugins: [ + resolve(), + terser(terserOptions) + ] + }, +] \ No newline at end of file diff --git a/actioncable/rollup.config.test.js b/actioncable/rollup.config.test.js index f92ff36240..06ddc8871c 100644 --- a/actioncable/rollup.config.test.js +++ b/actioncable/rollup.config.test.js @@ -1,6 +1,5 @@ -import babel from "rollup-plugin-babel" -import commonjs from "rollup-plugin-commonjs" -import resolve from "rollup-plugin-node-resolve" +import commonjs from "@rollup/plugin-commonjs" +import resolve from "@rollup/plugin-node-resolve" export default { input: "test/javascript/src/test.js", @@ -12,7 +11,6 @@ export default { plugins: [ resolve(), - commonjs(), - babel() + commonjs() ] } diff --git a/actioncable/test/client_test.rb b/actioncable/test/client_test.rb index 5866ca7733..ddc6da8412 100644 --- a/actioncable/test/client_test.rb +++ b/actioncable/test/client_test.rb @@ -201,7 +201,7 @@ class ClientTest < ActionCable::TestCase end def concurrently(enum) - enum.map { |*x| Concurrent::Future.execute { yield(*x) } }.map(&:value!) + enum.map { |*x| Concurrent::Promises.future { yield(*x) } }.map(&:value!) end def test_single_client diff --git a/actioncable/test/subscription_adapter/redis_test.rb b/actioncable/test/subscription_adapter/redis_test.rb index 4f1fc584b3..c001b352ce 100644 --- a/actioncable/test/subscription_adapter/redis_test.rb +++ b/actioncable/test/subscription_adapter/redis_test.rb @@ -12,8 +12,8 @@ class RedisAdapterTest < ActionCable::TestCase def cable_config { adapter: "redis", driver: "ruby" }.tap do |x| - if host = URI(ENV["REDIS_URL"] || "").hostname - x[:host] = host + if host = ENV["REDIS_URL"] + x[:url] = host end end end @@ -29,7 +29,8 @@ class RedisAdapterTest::AlternateConfiguration < RedisAdapterTest def cable_config alt_cable_config = super.dup alt_cable_config.delete(:url) - alt_cable_config.merge(host: URI(ENV["REDIS_URL"] || "").hostname || "127.0.0.1", port: 6379, db: 12) + url = URI(ENV["REDIS_URL"] || "") + alt_cable_config.merge(host: url.hostname || "127.0.0.1", port: url.port || 6379, db: 12) end end diff --git a/actioncable/test/test_helper.rb b/actioncable/test/test_helper.rb index 033f034b0c..7b1d600eb4 100644 --- a/actioncable/test/test_helper.rb +++ b/actioncable/test/test_helper.rb @@ -7,11 +7,6 @@ require "active_support/testing/method_call_assertions" require "puma" require "rack/mock" -begin - require "byebug" -rescue LoadError -end - # Require all the stubs and models Dir[File.expand_path("stubs/*.rb", __dir__)].each { |file| require file } diff --git a/actionmailbox/CHANGELOG.md b/actionmailbox/CHANGELOG.md index b86aceafe6..bb7b4f5b36 100644 --- a/actionmailbox/CHANGELOG.md +++ b/actionmailbox/CHANGELOG.md @@ -1,4 +1,38 @@ +* Add `attachments` to the list of permitted parameters for inbound emails conductor. + When using the conductor to test inbound emails with attachments, this prevents an + unpermitted parameter warning in default configurations, and prevents errors for + applications that set: + + ```ruby + config.action_controller.action_on_unpermitted_parameters = :raise + ``` + + *David Jones*, *Dana Henke* + +* Add ability to configure ActiveStorage service + for storing email raw source. + + ```yml + # config/storage.yml + incoming_emails: + service: Disk + root: /secure/dir/for/emails/only + ``` + + ```ruby + config.action_mailbox.storage_service = :incoming_emails + ``` + + *Yurii Rashkovskii* + +* Add ability to incinerate an inbound message through the conductor interface. + + *Santiago Bartesaghi* + +* OpenSSL constants are now used for Digest computations. + + *Dirkjan Bussink* Please check [6-1-stable](https://github.com/rails/rails/blob/6-1-stable/actionmailbox/CHANGELOG.md) for previous changes. diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb index 4846739c45..62523cf4dc 100644 --- a/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/inbound_emails_controller.rb @@ -20,14 +20,18 @@ module Rails private def new_mail - Mail.new(params.require(:mail).permit(:from, :to, :cc, :bcc, :x_original_to, :in_reply_to, :subject, :body).to_h).tap do |mail| + Mail.new(mail_params.except(:attachments).to_h).tap do |mail| mail[:bcc]&.include_in_headers = true - params[:mail][:attachments].to_a.each do |attachment| + mail_params[:attachments].to_a.each do |attachment| mail.add_file(filename: attachment.original_filename, content: attachment.read) end end end + def mail_params + params.require(:mail).permit(:from, :to, :cc, :bcc, :x_original_to, :in_reply_to, :subject, :body, attachments: []) + end + def create_inbound_email(mail) ActionMailbox::InboundEmail.create_and_extract_message_id!(mail.to_s) end diff --git a/actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb b/actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb new file mode 100644 index 0000000000..990a3fe888 --- /dev/null +++ b/actionmailbox/app/controllers/rails/conductor/action_mailbox/incinerates_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Rails + # Incinerating will destroy an email that is due and has already been processed. + class Conductor::ActionMailbox::IncineratesController < Rails::Conductor::BaseController + def create + ActionMailbox::InboundEmail.find(params[:inbound_email_id]).incinerate + + redirect_to main_app.rails_conductor_inbound_emails_url + end + end +end diff --git a/actionmailbox/app/models/action_mailbox/inbound_email.rb b/actionmailbox/app/models/action_mailbox/inbound_email.rb index 7357d0083e..8d2976452a 100644 --- a/actionmailbox/app/models/action_mailbox/inbound_email.rb +++ b/actionmailbox/app/models/action_mailbox/inbound_email.rb @@ -29,7 +29,7 @@ module ActionMailbox include Incineratable, MessageId, Routable - has_one_attached :raw_email + has_one_attached :raw_email, service: ActionMailbox.storage_service enum status: %i[ pending processing delivered failed bounced ] def mail diff --git a/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb b/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb index d8f52c8dbf..5e3cc4ec85 100644 --- a/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb +++ b/actionmailbox/app/models/action_mailbox/inbound_email/message_id.rb @@ -14,7 +14,7 @@ module ActionMailbox::InboundEmail::MessageId # attachment called +raw_email+. Before the upload, extract the Message-ID from the +source+ and set # it as an attribute on the new +InboundEmail+. def create_and_extract_message_id!(source, **options) - message_checksum = Digest::SHA1.hexdigest(source) + message_checksum = OpenSSL::Digest::SHA1.hexdigest(source) message_id = extract_message_id(source) || generate_missing_message_id(message_checksum) create! raw_email: create_and_upload_raw_email!(source), @@ -35,7 +35,8 @@ module ActionMailbox::InboundEmail::MessageId end def create_and_upload_raw_email!(source) - ActiveStorage::Blob.create_and_upload! io: StringIO.new(source), filename: "message.eml", content_type: "message/rfc822" + ActiveStorage::Blob.create_and_upload! io: StringIO.new(source), filename: "message.eml", content_type: "message/rfc822", + service_name: ActionMailbox.storage_service end end end diff --git a/actionmailbox/app/models/action_mailbox/record.rb b/actionmailbox/app/models/action_mailbox/record.rb index 533f43b046..77b3a99e89 100644 --- a/actionmailbox/app/models/action_mailbox/record.rb +++ b/actionmailbox/app/models/action_mailbox/record.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ActionMailbox - class Record < ActiveRecord::Base #:nodoc: + class Record < ActiveRecord::Base # :nodoc: self.abstract_class = true end end diff --git a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb index e761904196..48c08cd040 100644 --- a/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb +++ b/actionmailbox/app/views/rails/conductor/action_mailbox/inbound_emails/show.html.erb @@ -4,7 +4,7 @@