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 @@
diff --git a/actionmailbox/config/routes.rb b/actionmailbox/config/routes.rb index 8f48fc8178..7a8dcebb10 100644 --- a/actionmailbox/config/routes.rb +++ b/actionmailbox/config/routes.rb @@ -21,5 +21,6 @@ Rails.application.routes.draw do post "inbound_emails/sources", to: "inbound_emails/sources#create", as: :rails_conductor_inbound_email_sources post ":inbound_email_id/reroute" => "reroutes#create", as: :rails_conductor_inbound_email_reroute + post ":inbound_email_id/incinerate" => "incinerates#create", as: :rails_conductor_inbound_email_incinerate end end diff --git a/actionmailbox/lib/action_mailbox.rb b/actionmailbox/lib/action_mailbox.rb index 772dbd6529..1350aee853 100644 --- a/actionmailbox/lib/action_mailbox.rb +++ b/actionmailbox/lib/action_mailbox.rb @@ -14,4 +14,5 @@ module ActionMailbox mattr_accessor :incinerate, default: true mattr_accessor :incinerate_after, default: 30.days mattr_accessor :queues, default: {} + mattr_accessor :storage_service end diff --git a/actionmailbox/lib/action_mailbox/base.rb b/actionmailbox/lib/action_mailbox/base.rb index ff8587acd1..ce3e2a104c 100644 --- a/actionmailbox/lib/action_mailbox/base.rb +++ b/actionmailbox/lib/action_mailbox/base.rb @@ -77,7 +77,7 @@ module ActionMailbox @inbound_email = inbound_email end - def perform_processing #:nodoc: + def perform_processing # :nodoc: track_status_of_inbound_email do run_callbacks :process do process @@ -92,7 +92,7 @@ module ActionMailbox # Overwrite in subclasses end - def finished_processing? #:nodoc: + def finished_processing? # :nodoc: inbound_email.delivered? || inbound_email.bounced? end diff --git a/actionmailbox/lib/action_mailbox/engine.rb b/actionmailbox/lib/action_mailbox/engine.rb index bab3964d93..b6dadd0c01 100644 --- a/actionmailbox/lib/action_mailbox/engine.rb +++ b/actionmailbox/lib/action_mailbox/engine.rb @@ -20,6 +20,8 @@ module ActionMailbox config.action_mailbox.queues = ActiveSupport::InheritableOptions.new \ incineration: :action_mailbox_incineration, routing: :action_mailbox_routing + config.action_mailbox.storage_service = nil + initializer "action_mailbox.config" do config.after_initialize do |app| ActionMailbox.logger = app.config.action_mailbox.logger || Rails.logger @@ -27,6 +29,7 @@ module ActionMailbox ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days ActionMailbox.queues = app.config.action_mailbox.queues || {} ActionMailbox.ingress = app.config.action_mailbox.ingress + ActionMailbox.storage_service = app.config.action_mailbox.storage_service end end end diff --git a/actionmailbox/test/dummy/config/application.rb b/actionmailbox/test/dummy/config/application.rb index 8948a9fa45..fd99577fcc 100644 --- a/actionmailbox/test/dummy/config/application.rb +++ b/actionmailbox/test/dummy/config/application.rb @@ -7,7 +7,7 @@ Bundler.require(*Rails.groups) module Dummy class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 6.0 + config.load_defaults 7.0 # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers diff --git a/actionmailbox/test/dummy/config/environments/development.rb b/actionmailbox/test/dummy/config/environments/development.rb index 20da35c498..04c55af609 100644 --- a/actionmailbox/test/dummy/config/environments/development.rb +++ b/actionmailbox/test/dummy/config/environments/development.rb @@ -63,8 +63,4 @@ Rails.application.configure do # Annotate rendered view with file names # config.action_view.annotate_rendered_view_with_filenames = true - - # Use an evented file watcher to asynchronously detect changes in source code, - # routes, locales, etc. This feature depends on the listen gem. - # config.file_watcher = ActiveSupport::EventedFileUpdateChecker end diff --git a/actionmailbox/test/dummy/config/environments/production.rb b/actionmailbox/test/dummy/config/environments/production.rb index 80cec7f779..9fb44990d2 100644 --- a/actionmailbox/test/dummy/config/environments/production.rb +++ b/actionmailbox/test/dummy/config/environments/production.rb @@ -23,7 +23,7 @@ Rails.application.configure do config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? # Compress JavaScripts and CSS. - config.assets.js_compressor = :uglifier + config.assets.js_compressor = :terser # config.assets.css_compressor = :sass # Do not fallback to assets pipeline if a precompiled asset is missed. diff --git a/actionmailbox/test/dummy/config/environments/test.rb b/actionmailbox/test/dummy/config/environments/test.rb index d46aad6b48..dabe03edcd 100644 --- a/actionmailbox/test/dummy/config/environments/test.rb +++ b/actionmailbox/test/dummy/config/environments/test.rb @@ -46,4 +46,7 @@ Rails.application.configure do # Annotate rendered view with file names # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error if unpermitted parameters are sent + config.action_controller.action_on_unpermitted_parameters = :raise end diff --git a/actionmailbox/test/dummy/config/storage.yml b/actionmailbox/test/dummy/config/storage.yml index d8e4267cc4..ab8ba89f40 100644 --- a/actionmailbox/test/dummy/config/storage.yml +++ b/actionmailbox/test/dummy/config/storage.yml @@ -6,6 +6,10 @@ local: service: Disk root: <%= Rails.root.join("storage") %> +test_email: + service: Disk + root: <%= Rails.root.join("tmp/storage_email") %> + # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) # amazon: # service: S3 diff --git a/actionmailbox/test/test_helper.rb b/actionmailbox/test/test_helper.rb index 74c67d32d7..fa474db5b2 100644 --- a/actionmailbox/test/test_helper.rb +++ b/actionmailbox/test/test_helper.rb @@ -7,7 +7,6 @@ require_relative "../test/dummy/config/environment" ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] require "rails/test_help" -require "byebug" require "webmock/minitest" require "rails/test_unit/reporter" diff --git a/actionmailbox/test/unit/inbound_email_test.rb b/actionmailbox/test/unit/inbound_email_test.rb index 6a56bc7ebf..264209c118 100644 --- a/actionmailbox/test/unit/inbound_email_test.rb +++ b/actionmailbox/test/unit/inbound_email_test.rb @@ -44,5 +44,41 @@ module ActionMailbox end end end + + test "email gets saved to the configured storage service" do + ActionMailbox.storage_service = :test_email + + assert_equal(:test_email, ActionMailbox.storage_service) + + email = create_inbound_email_from_fixture("welcome.eml") + + storage_service = ActiveStorage::Blob.services.fetch(ActionMailbox.storage_service) + raw = email.raw_email_blob + + # Not present in the main storage + assert_not(ActiveStorage::Blob.service.exist?(raw.key)) + # Present in the email storage + assert(storage_service.exist?(raw.key)) + ensure + ActionMailbox.storage_service = nil + end + + test "email gets saved to the default storage service, even if it gets changed" do + default_service = ActiveStorage::Blob.service + ActiveStorage::Blob.service = ActiveStorage::Blob.services.fetch(:test_email) + + # Doesn't change ActionMailbox.storage_service + assert_nil(ActionMailbox.storage_service) + + email = create_inbound_email_from_fixture("welcome.eml") + raw = email.raw_email_blob + + # Not present in the (previously) default storage + assert_not(default_service.exist?(raw.key)) + # Present in the current default storage (email) + assert(ActiveStorage::Blob.service.exist?(raw.key)) + ensure + ActiveStorage::Blob.service = default_service + end end end diff --git a/actionmailbox/test/unit/router_test.rb b/actionmailbox/test/unit/router_test.rb index 78a6c8acdc..eb863ce7b8 100644 --- a/actionmailbox/test/unit/router_test.rb +++ b/actionmailbox/test/unit/router_test.rb @@ -132,11 +132,11 @@ module ActionMailbox end test "missing route" do + inbound_email = create_inbound_email_from_mail(to: "going-nowhere@example.com", subject: "This is a reply") assert_raises(ActionMailbox::Router::RoutingError) do - inbound_email = create_inbound_email_from_mail(to: "going-nowhere@example.com", subject: "This is a reply") @router.route inbound_email - assert inbound_email.bounced? end + assert inbound_email.bounced? end test "invalid address" do diff --git a/actionmailer/lib/action_mailer/base.rb b/actionmailer/lib/action_mailer/base.rb index 40d38bfd18..846f9b0cb5 100644 --- a/actionmailer/lib/action_mailer/base.rb +++ b/actionmailer/lib/action_mailer/base.rb @@ -555,7 +555,7 @@ module ActionMailer # through a callback when you call :deliver on the Mail::Message, # calling +deliver_mail+ directly and passing a Mail::Message will do # nothing except tell the logger you sent the email. - def deliver_mail(mail) #:nodoc: + def deliver_mail(mail) # :nodoc: ActiveSupport::Notifications.instrument("deliver.action_mailer") do |payload| set_payload_for_mail(payload, mail) yield # Let Mail do the delivery actions @@ -606,7 +606,7 @@ module ActionMailer @_message = Mail.new end - def process(method_name, *args) #:nodoc: + def process(method_name, *args) # :nodoc: payload = { mailer: self.class.name, action: method_name, @@ -619,7 +619,7 @@ module ActionMailer end end - class NullMail #:nodoc: + class NullMail # :nodoc: def body; "" end def header; {} end diff --git a/actionmailer/lib/action_mailer/delivery_job.rb b/actionmailer/lib/action_mailer/delivery_job.rb index 28ce0842de..8edf34b519 100644 --- a/actionmailer/lib/action_mailer/delivery_job.rb +++ b/actionmailer/lib/action_mailer/delivery_job.rb @@ -20,7 +20,7 @@ module ActionMailer MSG end - def perform(mailer, mail_method, delivery_method, *args) #:nodoc: + def perform(mailer, mail_method, delivery_method, *args) # :nodoc: mailer.constantize.public_send(mail_method, *args).send(delivery_method) end ruby2_keywords(:perform) diff --git a/actionmailer/lib/action_mailer/inline_preview_interceptor.rb b/actionmailer/lib/action_mailer/inline_preview_interceptor.rb index 2b97ac5b94..0c43fe0572 100644 --- a/actionmailer/lib/action_mailer/inline_preview_interceptor.rb +++ b/actionmailer/lib/action_mailer/inline_preview_interceptor.rb @@ -17,15 +17,15 @@ module ActionMailer include Base64 - def self.previewing_email(message) #:nodoc: + def self.previewing_email(message) # :nodoc: new(message).transform! end - def initialize(message) #:nodoc: + def initialize(message) # :nodoc: @message = message end - def transform! #:nodoc: + def transform! # :nodoc: return message if html_part.blank? html_part.body = html_part.decoded.gsub(PATTERN) do |match| diff --git a/actionmailer/lib/action_mailer/message_delivery.rb b/actionmailer/lib/action_mailer/message_delivery.rb index 553872e234..5dd82dbea4 100644 --- a/actionmailer/lib/action_mailer/message_delivery.rb +++ b/actionmailer/lib/action_mailer/message_delivery.rb @@ -15,7 +15,7 @@ module ActionMailer # Notifier.welcome(User.first).deliver_later # enqueue email delivery as a job through Active Job # Notifier.welcome(User.first).message # a Mail::Message object class MessageDelivery < Delegator - def initialize(mailer_class, action, *args) #:nodoc: + def initialize(mailer_class, action, *args) # :nodoc: @mailer_class, @action, @args = mailer_class, action, args # The mail is only processed if we try to call any methods on it. @@ -26,12 +26,12 @@ module ActionMailer ruby2_keywords(:initialize) # Method calls are delegated to the Mail::Message that's ready to deliver. - def __getobj__ #:nodoc: + def __getobj__ # :nodoc: @mail_message ||= processed_mailer.message end # Unused except for delegator internals (dup, marshalling). - def __setobj__(mail_message) #:nodoc: + def __setobj__(mail_message) # :nodoc: @mail_message = mail_message end diff --git a/actionmailer/lib/action_mailer/preview.rb b/actionmailer/lib/action_mailer/preview.rb index cb3f42424c..67d22c0271 100644 --- a/actionmailer/lib/action_mailer/preview.rb +++ b/actionmailer/lib/action_mailer/preview.rb @@ -3,7 +3,7 @@ require "active_support/descendants_tracker" module ActionMailer - module Previews #:nodoc: + module Previews # :nodoc: extend ActiveSupport::Concern included do diff --git a/actionmailer/lib/action_mailer/rescuable.rb b/actionmailer/lib/action_mailer/rescuable.rb index 5b567eb500..5a9927ebb9 100644 --- a/actionmailer/lib/action_mailer/rescuable.rb +++ b/actionmailer/lib/action_mailer/rescuable.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActionMailer #:nodoc: +module ActionMailer # :nodoc: # Provides +rescue_from+ for mailers. Wraps mailer action processing, # mail job processing, and mail delivery. module Rescuable @@ -8,12 +8,12 @@ module ActionMailer #:nodoc: include ActiveSupport::Rescuable class_methods do - def handle_exception(exception) #:nodoc: + def handle_exception(exception) # :nodoc: rescue_with_handler(exception) || raise(exception) end end - def handle_exceptions #:nodoc: + def handle_exceptions # :nodoc: yield rescue => exception rescue_with_handler(exception) || raise diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 68cbe847d0..f2ca11c88f 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,6 +1,81 @@ -* Drop support for the `SERVER_ADDR` header +* Use a static error message when raising `ActionDispatch::Http::Parameters::ParseError` + to avoid inadvertently logging the HTTP request body at the `fatal` level when it contains + malformed JSON. - Following up https://github.com/rack/rack/pull/1573 and https://github.com/rails/rails/pull/42349 + Fixes #41145 + + *Aaron Lahey* + +* Add `Middleware#delete!` to delete middleware or raise if not found. + + `Middleware#delete!` works just like `Middleware#delete` but will + raise an error if the middleware isn't found. + + *Alex Ghiculescu*, *Petrik de Heus*, *Junichi Sato* + +* Raise error on unpermitted open redirects. + + Add `allow_other_host` options to `redirect_to`. + Opt in to this behaviour with `ActionController::Base.raise_on_open_redirects = true`. + + *Gannon McGibbon* + +* Deprecate `poltergeist` and `webkit` (capybara-webkit) driver registration for system testing (they will be removed in Rails 7.1). Add `cuprite` instead. + + [Poltergeist](https://github.com/teampoltergeist/poltergeist) and [capybara-webkit](https://github.com/thoughtbot/capybara-webkit) are already not maintained. These usage in Rails are removed for avoiding confusing users. + + [Cuprite](https://github.com/rubycdp/cuprite) is a good alternative to Poltergeist. Some guide descriptions are replaced from Poltergeist to Cuprite. + + *Yusuke Iwaki* + +* Exclude additional flash types from `ActionController::Base.action_methods`. + + Ensures that additional flash types defined on ActionController::Base subclasses + are not listed as actions on that controller. + + class MyController < ApplicationController + add_flash_types :hype + end + + MyController.action_methods.include?('hype') # => false + + *Gavin Morrice* + +* OpenSSL constants are now used for Digest computations. + + *Dirkjan Bussink* + +* Remove IE6-7-8 file download related hack/fix from ActionController::DataStreaming module. + + Due to the age of those versions of IE this fix is no longer relevant, more importantly it creates an edge-case for unexpected Cache-Control headers. + + *Tadas Sasnauskas* + +* Configuration setting to skip logging an uncaught exception backtrace when the exception is + present in `rescued_responses`. + + It may be too noisy to get all backtraces logged for applications that manage uncaught + exceptions via `rescued_responses` and `exceptions_app`. + `config.action_dispatch.log_rescued_responses` (defaults to `true`) can be set to `false` in + this case, so that only exceptions not found in `rescued_responses` will be logged. + + *Alexander Azarov*, *Mike Dalessio* + +* Ignore file fixtures on `db:fixtures:load`. + + *Kevin Sjöberg* + +* Fix ActionController::Live controller test deadlocks by removing the body buffer size limit for tests. + + *Dylan Thacker-Smith* + +* New `ActionController::ConditionalGet#no_store` method to set HTTP cache control `no-store` directive. + + *Tadas Sasnauskas* + +* Drop support for the `SERVER_ADDR` header. + + Following up https://github.com/rack/rack/pull/1573 and https://github.com/rails/rails/pull/42349. *Ricardo Díaz* @@ -8,7 +83,7 @@ *Gannon McGibbon* -* Add `cache_control: {}` option to `fresh_when` and `stale?` +* Add `cache_control: {}` option to `fresh_when` and `stale?`. Works as a shortcut to set `response.cache_control` with the above methods. @@ -22,7 +97,7 @@ * Add support for 'require-trusted-types-for' and 'trusted-types' headers. - Fixes #42034 + Fixes #42034. *lfalcao* @@ -91,7 +166,7 @@ *Janko Marohnić* -* Allow anything with `#to_str` (like `Addressable::URI`) as a `redirect_to` location +* Allow anything with `#to_str` (like `Addressable::URI`) as a `redirect_to` location. *ojab* @@ -103,7 +178,7 @@ as `RemoteIp` middleware behaves inconsistently depending on whether this is configured with a single value or an enumerable. - Fixes #40772 + Fixes #40772. *Christian Sutter* diff --git a/actionpack/lib/abstract_controller/asset_paths.rb b/actionpack/lib/abstract_controller/asset_paths.rb index d6ee84b87b..f08912b556 100644 --- a/actionpack/lib/abstract_controller/asset_paths.rb +++ b/actionpack/lib/abstract_controller/asset_paths.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module AbstractController - module AssetPaths #:nodoc: + module AssetPaths # :nodoc: extend ActiveSupport::Concern included do diff --git a/actionpack/lib/abstract_controller/base.rb b/actionpack/lib/abstract_controller/base.rb index f2ff5738e3..61fc35561e 100644 --- a/actionpack/lib/abstract_controller/base.rb +++ b/actionpack/lib/abstract_controller/base.rb @@ -9,34 +9,20 @@ require "active_support/core_ext/module/attr_internal" module AbstractController # Raised when a non-existing controller action is triggered. class ActionNotFound < StandardError - attr_reader :controller, :action - def initialize(message = nil, controller = nil, action = nil) + attr_reader :controller, :action # :nodoc: + + def initialize(message = nil, controller = nil, action = nil) # :nodoc: @controller = controller @action = action super(message) end - class Correction - def initialize(error) - @error = error + if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker) + include DidYouMean::Correctable # :nodoc: + + def corrections # :nodoc: + @corrections ||= DidYouMean::SpellChecker.new(dictionary: controller.class.action_methods).correct(action) end - - def corrections - if @error.action - maybe_these = @error.controller.class.action_methods - - maybe_these.sort_by { |n| - DidYouMean::Jaro.distance(@error.action.to_s, n) - }.reverse.first(4) - else - [] - end - end - end - - # We may not have DYM, and DYM might not let us register error handlers - if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) - DidYouMean.correct_error(self, Correction) end end diff --git a/actionpack/lib/abstract_controller/caching/fragments.rb b/actionpack/lib/abstract_controller/caching/fragments.rb index 18677ddd18..4b0881814b 100644 --- a/actionpack/lib/abstract_controller/caching/fragments.rb +++ b/actionpack/lib/abstract_controller/caching/fragments.rb @@ -142,8 +142,8 @@ module AbstractController end end - def instrument_fragment_cache(name, key) # :nodoc: - ActiveSupport::Notifications.instrument("#{name}.#{instrument_name}", instrument_payload(key)) { yield } + def instrument_fragment_cache(name, key, &block) # :nodoc: + ActiveSupport::Notifications.instrument("#{name}.#{instrument_name}", instrument_payload(key), &block) end end end diff --git a/actionpack/lib/abstract_controller/collector.rb b/actionpack/lib/abstract_controller/collector.rb index 0af546cc96..0d1954bc54 100644 --- a/actionpack/lib/abstract_controller/collector.rb +++ b/actionpack/lib/abstract_controller/collector.rb @@ -10,6 +10,7 @@ module AbstractController def #{sym}(*args, &block) custom(Mime[:#{sym}], *args, &block) end + ruby2_keywords(:#{sym}) RUBY end @@ -22,7 +23,7 @@ module AbstractController end private - def method_missing(symbol, &block) + def method_missing(symbol, *args, &block) unless mime_constant = Mime[symbol] raise NoMethodError, "To respond to a custom format, register it as a MIME type first: " \ "https://guides.rubyonrails.org/action_controller_overview.html#restful-downloads. " \ @@ -33,10 +34,11 @@ module AbstractController if Mime::SET.include?(mime_constant) AbstractController::Collector.generate_method_for_mime(mime_constant) - send(symbol, &block) + public_send(symbol, *args, &block) else super end end + ruby2_keywords(:method_missing) end end diff --git a/actionpack/lib/abstract_controller/error.rb b/actionpack/lib/abstract_controller/error.rb index 89a54f072e..88d863c719 100644 --- a/actionpack/lib/abstract_controller/error.rb +++ b/actionpack/lib/abstract_controller/error.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true module AbstractController - class Error < StandardError #:nodoc: + class Error < StandardError # :nodoc: end end diff --git a/actionpack/lib/abstract_controller/helpers.rb b/actionpack/lib/abstract_controller/helpers.rb index 6bb4bacf07..bb69e7997e 100644 --- a/actionpack/lib/abstract_controller/helpers.rb +++ b/actionpack/lib/abstract_controller/helpers.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_support/dependencies" +require "active_support/core_ext/name_error" module AbstractController module Helpers diff --git a/actionpack/lib/abstract_controller/logger.rb b/actionpack/lib/abstract_controller/logger.rb index 8d0acc1b5c..c7ab9c7737 100644 --- a/actionpack/lib/abstract_controller/logger.rb +++ b/actionpack/lib/abstract_controller/logger.rb @@ -3,7 +3,7 @@ require "active_support/benchmarkable" module AbstractController - module Logger #:nodoc: + module Logger # :nodoc: extend ActiveSupport::Concern included do diff --git a/actionpack/lib/abstract_controller/railties/routes_helpers.rb b/actionpack/lib/abstract_controller/railties/routes_helpers.rb index b728c7144a..33f6961129 100644 --- a/actionpack/lib/abstract_controller/railties/routes_helpers.rb +++ b/actionpack/lib/abstract_controller/railties/routes_helpers.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_support/core_ext/module/introspection" + module AbstractController module Railties module RoutesHelpers diff --git a/actionpack/lib/action_controller.rb b/actionpack/lib/action_controller.rb index bc184acb99..d80b4f5b03 100644 --- a/actionpack/lib/action_controller.rb +++ b/actionpack/lib/action_controller.rb @@ -18,10 +18,6 @@ module ActionController end autoload_under "metal" do - eager_autoload do - autoload :Live - end - autoload :ConditionalGet autoload :ContentSecurityPolicy autoload :Cookies @@ -37,9 +33,11 @@ module ActionController autoload :BasicImplicitRender autoload :ImplicitRender autoload :Instrumentation + autoload :Live autoload :Logging autoload :MimeResponds autoload :ParamsWrapper + autoload :QueryTags autoload :Redirecting autoload :Renderers autoload :Rendering diff --git a/actionpack/lib/action_controller/api.rb b/actionpack/lib/action_controller/api.rb index 6d01b4285b..36923aaf54 100644 --- a/actionpack/lib/action_controller/api.rb +++ b/actionpack/lib/action_controller/api.rb @@ -37,7 +37,7 @@ module ActionController # == Renders # # The default API Controller stack includes all renderers, which means you - # can use render :json and brothers freely in your controllers. Keep + # can use render :json and siblings freely in your controllers. Keep # in mind that templates are not going to be rendered, so you need to ensure # your controller is calling either render or redirect_to in # all actions, otherwise it will return 204 No Content. diff --git a/actionpack/lib/action_controller/metal.rb b/actionpack/lib/action_controller/metal.rb index e6848ee85e..f58005cdc9 100644 --- a/actionpack/lib/action_controller/metal.rb +++ b/actionpack/lib/action_controller/metal.rb @@ -2,8 +2,6 @@ require "active_support/core_ext/array/extract_options" require "action_dispatch/middleware/stack" -require "action_dispatch/http/request" -require "action_dispatch/http/response" module ActionController # Extend ActionDispatch middleware stack to make it aware of options @@ -13,8 +11,8 @@ module ActionController # use AuthenticationMiddleware, except: [:index, :show] # end # - class MiddlewareStack < ActionDispatch::MiddlewareStack #:nodoc: - class Middleware < ActionDispatch::MiddlewareStack::Middleware #:nodoc: + class MiddlewareStack < ActionDispatch::MiddlewareStack # :nodoc: + class Middleware < ActionDispatch::MiddlewareStack::Middleware # :nodoc: def initialize(klass, args, actions, strategy, block) @actions = actions @strategy = strategy @@ -184,7 +182,7 @@ module ActionController response_body || response.committed? end - def dispatch(name, request, response) #:nodoc: + def dispatch(name, request, response) # :nodoc: set_request!(request) set_response!(response) process(name) @@ -196,12 +194,12 @@ module ActionController @_response = response end - def set_request!(request) #:nodoc: + def set_request!(request) # :nodoc: @_request = request @_request.controller_instance = self end - def to_a #:nodoc: + def to_a # :nodoc: response.to_a end diff --git a/actionpack/lib/action_controller/metal/conditional_get.rb b/actionpack/lib/action_controller/metal/conditional_get.rb index d0bf89a4e4..4d82fae638 100644 --- a/actionpack/lib/action_controller/metal/conditional_get.rb +++ b/actionpack/lib/action_controller/metal/conditional_get.rb @@ -115,6 +115,7 @@ module ActionController # before_action { fresh_when @article, template: 'widgets/show' } # def fresh_when(object = nil, etag: nil, weak_etag: nil, strong_etag: nil, last_modified: nil, public: false, cache_control: {}, template: nil) + response.cache_control.delete(:no_store) weak_etag ||= etag || object unless strong_etag last_modified ||= object.try(:updated_at) || object.try(:maximum, :updated_at) @@ -273,6 +274,7 @@ module ActionController # # The method will also ensure an HTTP Date header for client compatibility. def expires_in(seconds, options = {}) + response.cache_control.delete(:no_store) response.cache_control.merge!( max_age: seconds, public: options.delete(:public), @@ -309,6 +311,12 @@ module ActionController public: public) end + # Sets an HTTP 1.1 Cache-Control header of no-store. This means the + # resource may not be stored in any cache. + def no_store + response.cache_control.replace(no_store: true) + end + private def combine_etags(validator, options) [validator, *etaggers.map { |etagger| instance_exec(options, &etagger) }].compact diff --git a/actionpack/lib/action_controller/metal/content_security_policy.rb b/actionpack/lib/action_controller/metal/content_security_policy.rb index 25fc110bfe..fe1afc512b 100644 --- a/actionpack/lib/action_controller/metal/content_security_policy.rb +++ b/actionpack/lib/action_controller/metal/content_security_policy.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActionController #:nodoc: +module ActionController # :nodoc: module ContentSecurityPolicy # TODO: Documentation extend ActiveSupport::Concern diff --git a/actionpack/lib/action_controller/metal/cookies.rb b/actionpack/lib/action_controller/metal/cookies.rb index b9f947e525..17b51d215f 100644 --- a/actionpack/lib/action_controller/metal/cookies.rb +++ b/actionpack/lib/action_controller/metal/cookies.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActionController #:nodoc: +module ActionController # :nodoc: module Cookies extend ActiveSupport::Concern diff --git a/actionpack/lib/action_controller/metal/data_streaming.rb b/actionpack/lib/action_controller/metal/data_streaming.rb index 879745a895..775bda491d 100644 --- a/actionpack/lib/action_controller/metal/data_streaming.rb +++ b/actionpack/lib/action_controller/metal/data_streaming.rb @@ -3,7 +3,7 @@ require "action_controller/metal/exceptions" require "action_dispatch/http/content_disposition" -module ActionController #:nodoc: +module ActionController # :nodoc: # Methods for sending arbitrary data and for streaming files to the browser, # instead of rendering. module DataStreaming @@ -11,8 +11,8 @@ module ActionController #:nodoc: include ActionController::Rendering - DEFAULT_SEND_FILE_TYPE = "application/octet-stream" #:nodoc: - DEFAULT_SEND_FILE_DISPOSITION = "attachment" #:nodoc: + DEFAULT_SEND_FILE_TYPE = "application/octet-stream" # :nodoc: + DEFAULT_SEND_FILE_DISPOSITION = "attachment" # :nodoc: private # Sends the file. This uses a server-appropriate method (such as X-Sendfile) @@ -66,7 +66,7 @@ module ActionController #:nodoc: # https://www.mnot.net/cache_docs/ for an overview of web caching and # https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9 # for the Cache-Control header spec. - def send_file(path, options = {}) #:doc: + def send_file(path, options = {}) # :doc: raise MissingFile, "Cannot read file #{path}" unless File.file?(path) && File.readable?(path) options[:filename] ||= File.basename(path) unless options[:url_based_filename] @@ -106,7 +106,7 @@ module ActionController #:nodoc: # send_data image.data, type: image.content_type, disposition: 'inline' # # See +send_file+ for more information on HTTP Content-* headers and caching. - def send_data(data, options = {}) #:doc: + def send_data(data, options = {}) # :doc: send_file_headers! options render options.slice(:status, :content_type).merge(body: data) end @@ -138,14 +138,6 @@ module ActionController #:nodoc: end headers["Content-Transfer-Encoding"] = "binary" - - # Fix a problem with IE 6.0 on opening downloaded files: - # If Cache-Control: no-cache is set (which Rails does by default), - # IE removes the file it just downloaded from its cache immediately - # after it displays the "open/save" dialog, which means that if you - # hit "open" the file isn't there anymore when the application that - # is called for handling the download is run, so let's workaround that - response.cache_control[:public] ||= false end end end diff --git a/actionpack/lib/action_controller/metal/etag_with_template_digest.rb b/actionpack/lib/action_controller/metal/etag_with_template_digest.rb index 983bd745bd..6607ed009e 100644 --- a/actionpack/lib/action_controller/metal/etag_with_template_digest.rb +++ b/actionpack/lib/action_controller/metal/etag_with_template_digest.rb @@ -44,7 +44,7 @@ module ActionController # template digest from the ETag. def pick_template_for_etag(options) unless options[:template] == false - options[:template] || "#{controller_path}/#{action_name}" + options[:template] || lookup_context.find_all(action_name, _prefixes).first&.virtual_path end end diff --git a/actionpack/lib/action_controller/metal/exceptions.rb b/actionpack/lib/action_controller/metal/exceptions.rb index 11fc816d08..8a8818d96a 100644 --- a/actionpack/lib/action_controller/metal/exceptions.rb +++ b/actionpack/lib/action_controller/metal/exceptions.rb @@ -1,20 +1,20 @@ # frozen_string_literal: true module ActionController - class ActionControllerError < StandardError #:nodoc: + class ActionControllerError < StandardError # :nodoc: end - class BadRequest < ActionControllerError #:nodoc: + class BadRequest < ActionControllerError # :nodoc: def initialize(msg = nil) super(msg) set_backtrace $!.backtrace if $! end end - class RenderError < ActionControllerError #:nodoc: + class RenderError < ActionControllerError # :nodoc: end - class RoutingError < ActionControllerError #:nodoc: + class RoutingError < ActionControllerError # :nodoc: attr_reader :failures def initialize(message, failures = []) super(message) @@ -22,7 +22,7 @@ module ActionController end end - class UrlGenerationError < ActionControllerError #:nodoc: + class UrlGenerationError < ActionControllerError # :nodoc: attr_reader :routes, :route_name, :method_name def initialize(message, routes = nil, route_name = nil, method_name = nil) @@ -33,44 +33,33 @@ module ActionController super(message) end - class Correction - def initialize(error) - @error = error - end + if defined?(DidYouMean::Correctable) && defined?(DidYouMean::SpellChecker) + include DidYouMean::Correctable def corrections - if @error.method_name - maybe_these = @error.routes.named_routes.helper_names.grep(/#{@error.route_name}/) - maybe_these -= [@error.method_name.to_s] # remove exact match + @corrections ||= begin + maybe_these = routes&.named_routes&.helper_names&.grep(/#{route_name}/) || [] + maybe_these -= [method_name.to_s] # remove exact match - maybe_these.sort_by { |n| - DidYouMean::Jaro.distance(@error.route_name, n) - }.reverse.first(4) - else - [] + DidYouMean::SpellChecker.new(dictionary: maybe_these).correct(route_name) end end end - - # We may not have DYM, and DYM might not let us register error handlers - if defined?(DidYouMean) && DidYouMean.respond_to?(:correct_error) - DidYouMean.correct_error(self, Correction) - end end - class MethodNotAllowed < ActionControllerError #:nodoc: + class MethodNotAllowed < ActionControllerError # :nodoc: def initialize(*allowed_methods) super("Only #{allowed_methods.to_sentence} requests are allowed.") end end - class NotImplemented < MethodNotAllowed #:nodoc: + class NotImplemented < MethodNotAllowed # :nodoc: end - class MissingFile < ActionControllerError #:nodoc: + class MissingFile < ActionControllerError # :nodoc: end - class SessionOverflowError < ActionControllerError #:nodoc: + class SessionOverflowError < ActionControllerError # :nodoc: DEFAULT_MESSAGE = "Your session data is larger than the data column in which it is to be stored. You must increase the size of your data column if you intend to store large data." def initialize(message = nil) @@ -78,10 +67,10 @@ module ActionController end end - class UnknownHttpMethod < ActionControllerError #:nodoc: + class UnknownHttpMethod < ActionControllerError # :nodoc: end - class UnknownFormat < ActionControllerError #:nodoc: + class UnknownFormat < ActionControllerError # :nodoc: end # Raised when a nested respond_to is triggered and the content types of each @@ -102,6 +91,6 @@ module ActionController end end - class MissingExactTemplate < UnknownFormat #:nodoc: + class MissingExactTemplate < UnknownFormat # :nodoc: end end diff --git a/actionpack/lib/action_controller/metal/flash.rb b/actionpack/lib/action_controller/metal/flash.rb index a4861dc2c0..22f0d34c25 100644 --- a/actionpack/lib/action_controller/metal/flash.rb +++ b/actionpack/lib/action_controller/metal/flash.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActionController #:nodoc: +module ActionController # :nodoc: module Flash extend ActiveSupport::Concern @@ -41,10 +41,14 @@ module ActionController #:nodoc: self._flash_types += [type] end end + + def action_methods # :nodoc: + @action_methods ||= super - _flash_types.map(&:to_s).to_set + end end private - def redirect_to(options = {}, response_options_and_flash = {}) #:doc: + def redirect_to(options = {}, response_options_and_flash = {}) # :doc: self.class._flash_types.each do |flash_type| if type = response_options_and_flash.delete(flash_type) flash[flash_type] = type diff --git a/actionpack/lib/action_controller/metal/http_authentication.rb b/actionpack/lib/action_controller/metal/http_authentication.rb index 76e9b44050..9db231f5af 100644 --- a/actionpack/lib/action_controller/metal/http_authentication.rb +++ b/actionpack/lib/action_controller/metal/http_authentication.rb @@ -138,11 +138,11 @@ module ActionController # # === Simple \Digest example # - # require "digest/md5" + # require "openssl" # class PostsController < ApplicationController # REALM = "SuperSecret" # USERS = {"dhh" => "secret", #plain text password - # "dap" => Digest::MD5.hexdigest(["dap",REALM,"secret"].join(":"))} #ha1 digest password + # "dap" => OpenSSL::Digest::MD5.hexdigest(["dap",REALM,"secret"].join(":"))} #ha1 digest password # # before_action :authenticate, except: [:index] # @@ -230,12 +230,12 @@ module ActionController # of a plain-text password. def expected_response(http_method, uri, credentials, password, password_is_ha1 = true) ha1 = password_is_ha1 ? password : ha1(credentials, password) - ha2 = ::Digest::MD5.hexdigest([http_method.to_s.upcase, uri].join(":")) - ::Digest::MD5.hexdigest([ha1, credentials[:nonce], credentials[:nc], credentials[:cnonce], credentials[:qop], ha2].join(":")) + ha2 = OpenSSL::Digest::MD5.hexdigest([http_method.to_s.upcase, uri].join(":")) + OpenSSL::Digest::MD5.hexdigest([ha1, credentials[:nonce], credentials[:nc], credentials[:cnonce], credentials[:qop], ha2].join(":")) end def ha1(credentials, password) - ::Digest::MD5.hexdigest([credentials[:username], credentials[:realm], password].join(":")) + OpenSSL::Digest::MD5.hexdigest([credentials[:username], credentials[:realm], password].join(":")) end def encode_credentials(http_method, credentials, password, password_is_ha1) @@ -309,7 +309,7 @@ module ActionController def nonce(secret_key, time = Time.now) t = time.to_i hashed = [t, secret_key] - digest = ::Digest::MD5.hexdigest(hashed.join(":")) + digest = OpenSSL::Digest::MD5.hexdigest(hashed.join(":")) ::Base64.strict_encode64("#{t}:#{digest}") end @@ -326,7 +326,7 @@ module ActionController # Opaque based on digest of secret key def opaque(secret_key) - ::Digest::MD5.hexdigest(secret_key) + OpenSSL::Digest::MD5.hexdigest(secret_key) end end diff --git a/actionpack/lib/action_controller/metal/instrumentation.rb b/actionpack/lib/action_controller/metal/instrumentation.rb index cc70d30aa1..cbc5c4fee2 100644 --- a/actionpack/lib/action_controller/metal/instrumentation.rb +++ b/actionpack/lib/action_controller/metal/instrumentation.rb @@ -99,7 +99,7 @@ module ActionController # A hook which allows other frameworks to log what happened during # controller process action. This method should return an array # with the messages to be added. - def log_process_action(payload) #:nodoc: + def log_process_action(payload) # :nodoc: messages, view_runtime = [], payload[:view_runtime] messages << ("Views: %.1fms" % view_runtime.to_f) if view_runtime messages diff --git a/actionpack/lib/action_controller/metal/live.rb b/actionpack/lib/action_controller/metal/live.rb index 5c684ba173..77c9d4a227 100644 --- a/actionpack/lib/action_controller/metal/live.rb +++ b/actionpack/lib/action_controller/metal/live.rb @@ -124,9 +124,14 @@ module ActionController class ClientDisconnected < RuntimeError end - class Buffer < ActionDispatch::Response::Buffer #:nodoc: + class Buffer < ActionDispatch::Response::Buffer # :nodoc: include MonitorMixin + class << self + attr_accessor :queue_size + end + @queue_size = 10 + # Ignore that the client has disconnected. # # If this value is `true`, calling `write` after the client @@ -136,7 +141,7 @@ module ActionController attr_accessor :ignore_disconnect def initialize(response) - super(response, SizedQueue.new(10)) + super(response, build_queue(self.class.queue_size)) @error_callback = lambda { true } @cv = new_cond @aborted = false @@ -219,9 +224,13 @@ module ActionController yield str end end + + def build_queue(queue_size) + queue_size ? SizedQueue.new(queue_size) : Queue.new + end end - class Response < ActionDispatch::Response #:nodoc: all + class Response < ActionDispatch::Response # :nodoc: all private def before_committed super diff --git a/actionpack/lib/action_controller/metal/mime_responds.rb b/actionpack/lib/action_controller/metal/mime_responds.rb index fda5dbbc03..fb32257ebe 100644 --- a/actionpack/lib/action_controller/metal/mime_responds.rb +++ b/actionpack/lib/action_controller/metal/mime_responds.rb @@ -2,7 +2,7 @@ require "abstract_controller/collector" -module ActionController #:nodoc: +module ActionController # :nodoc: module MimeResponds # Without web-service support, an action which collects the data for displaying a list of people # might look something like this: @@ -289,7 +289,7 @@ module ActionController #:nodoc: @format = request.negotiate_mime(@responses.keys) end - class VariantCollector #:nodoc: + class VariantCollector # :nodoc: def initialize(variant = nil) @variant = variant @variants = {} diff --git a/actionpack/lib/action_controller/metal/permissions_policy.rb b/actionpack/lib/action_controller/metal/permissions_policy.rb index 6325219549..dff1c065f5 100644 --- a/actionpack/lib/action_controller/metal/permissions_policy.rb +++ b/actionpack/lib/action_controller/metal/permissions_policy.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ActionController #:nodoc: +module ActionController # :nodoc: # HTTP Permissions Policy is a web standard for defining a mechanism to # allow and deny the use of browser permissions in its own context, and # in content within any