diff options
author | Richard Macklin <richard.github@nrm.com> | 2018-01-20 23:33:32 -0800 |
---|---|---|
committer | Richard Macklin <richard.github@nrm.com> | 2018-11-02 08:41:05 -0700 |
commit | c96139af71e6f7c36e25bccea6b05ccd9523531a (patch) | |
tree | 119448211713b6a172b301ab164dd54dd5718654 /actioncable/app/javascript | |
parent | 0eb6b86e9606cace49afba0b35ec18916c73646e (diff) | |
download | rails-c96139af71e6f7c36e25bccea6b05ccd9523531a.tar.gz rails-c96139af71e6f7c36e25bccea6b05ccd9523531a.tar.bz2 rails-c96139af71e6f7c36e25bccea6b05ccd9523531a.zip |
Convert ActionCable javascript to ES2015 modules with modern build environment
We've replaced the sprockets `//= require` directives with ES2015
imports. As a result, the ActionCable javascript can now be compiled
with rollup (like ActiveStorage already is).
- Rename action_cable/index.js.erb -> action_cable/index.js
- Add rake task to generate a javascript module of the ActionCable::INTERNAL ruby hash
This will allow us to get rid of ERB from the actioncable javascript,
since it is only used to interpolate ActionCable::INTERNAL.to_json.
- Import INTERNAL directly in ActionCable Connection module
This is necessary to remove a load-order dependency conflict in the
rollup-compiled build. Using ActionCable.INTERNAL would result in a
runtime error:
```
TypeError: Cannot read property 'INTERNAL' of undefined
```
because ActionCable.INTERNAL is not set before the Connection module
is executed.
All other ActionCable.* references are executed inside of the body of a
function, so there is no load-order dependency there.
- Add eslint and eslint-plugin-import devDependencies to actioncable
These will be used to add a linting setup to actioncable like the one
in activestorage.
- Add .eslintrc to actioncable
This lint configuration was copied from activestorage
- Add lint script to actioncable
This is the same as the lint script in activestorage
- Add babel-core, babel-plugin-external-helpers, and babel-preset-env devDependencies to actioncable
These will be used to add ES2015 transpilation support to actioncable
like we have in activestorage.
- Add .babelrc to actioncable
This configuration was copied from activestorage
- Enable loose mode in ActionCable's babel config
This generates a smaller bundle when compiled
- Add rollup devDependencies to actioncable
These will be used to add a modern build pipeline to actioncable like
the one in activestorage.
- Add rollup config to actioncable
This is essentially the same as the rollup config from activestorage
- Add prebuild and build scripts to actioncable package
These scripts were copied from activestorage
- Invoke code generation task as part of actioncable's prebuild script
This will guarantee that the action_cable/internal.js module is
available at build time (which is important, because two other modules
now depend on it).
- Update actioncable package to reference the rollup-compiled files
Now that we have a fully functional rollup pipeline in actioncable, we
can use the compiled output in our npm package.
- Remove build section from ActionCable blade config
Now that rollup is responsible for building ActionCable, we can remove
that responsibility from Blade.
- Remove assets:compile and assets:verify tasks from ActionCable
Now that we've added a compiled ActionCable bundle to version control,
we don't need to compile and verify it at publish-time.
(We're following the pattern set in ActiveStorage.)
- Include compiled ActionCable javascript bundle in published gem
This is necessary to maintain support for depending on the ActionCable
javascript through the Sprockets asset pipeline.
- Add compiled ActionCable bundle to version control
This mirrors what we do in ActiveStorage, and allows ActionCable to
continue to be consumed via the sprockets-based asset pipeline when
using a git source instead of a published version of the gem.
Diffstat (limited to 'actioncable/app/javascript')
-rw-r--r-- | actioncable/app/javascript/action_cable/connection.js | 248 | ||||
-rw-r--r-- | actioncable/app/javascript/action_cable/connection_monitor.js | 184 | ||||
-rw-r--r-- | actioncable/app/javascript/action_cable/consumer.js | 7 | ||||
-rw-r--r-- | actioncable/app/javascript/action_cable/index.js (renamed from actioncable/app/javascript/action_cable/index.js.erb) | 20 | ||||
-rw-r--r-- | actioncable/app/javascript/action_cable/subscription.js | 53 | ||||
-rw-r--r-- | actioncable/app/javascript/action_cable/subscriptions.js | 5 |
6 files changed, 261 insertions, 256 deletions
diff --git a/actioncable/app/javascript/action_cable/connection.js b/actioncable/app/javascript/action_cable/connection.js index 540f597303..4ad436f2c0 100644 --- a/actioncable/app/javascript/action_cable/connection.js +++ b/actioncable/app/javascript/action_cable/connection.js @@ -1,158 +1,156 @@ -//= require ./connection_monitor +import ActionCable from "./index" +import INTERNAL from "./internal" // Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. -const {message_types, protocols} = ActionCable.INTERNAL +const {message_types, protocols} = INTERNAL const supportedProtocols = protocols.slice(0, protocols.length - 1) -ActionCable.Connection = (function() { - const indexOf = [].indexOf +const indexOf = [].indexOf - class Connection { - constructor(consumer) { - this.open = this.open.bind(this) - this.consumer = consumer - this.subscriptions = this.consumer.subscriptions - this.monitor = new ActionCable.ConnectionMonitor(this) - this.disconnected = true - } +class Connection { + constructor(consumer) { + this.open = this.open.bind(this) + this.consumer = consumer + this.subscriptions = this.consumer.subscriptions + this.monitor = new ActionCable.ConnectionMonitor(this) + this.disconnected = true + } - send(data) { - if (this.isOpen()) { - this.webSocket.send(JSON.stringify(data)) - return true - } else { - return false - } + send(data) { + if (this.isOpen()) { + this.webSocket.send(JSON.stringify(data)) + return true + } else { + return false } + } - open() { - if (this.isActive()) { - ActionCable.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`) - return false - } else { - ActionCable.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${protocols}`) - if (this.webSocket) { this.uninstallEventHandlers() } - this.webSocket = new ActionCable.WebSocket(this.consumer.url, protocols) - this.installEventHandlers() - this.monitor.start() - return true - } + open() { + if (this.isActive()) { + ActionCable.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`) + return false + } else { + ActionCable.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${protocols}`) + if (this.webSocket) { this.uninstallEventHandlers() } + this.webSocket = new ActionCable.WebSocket(this.consumer.url, protocols) + this.installEventHandlers() + this.monitor.start() + return true } + } - close({allowReconnect} = {allowReconnect: true}) { - if (!allowReconnect) { this.monitor.stop() } - if (this.isActive()) { return (this.webSocket ? this.webSocket.close() : undefined) } - } + close({allowReconnect} = {allowReconnect: true}) { + if (!allowReconnect) { this.monitor.stop() } + if (this.isActive()) { return (this.webSocket ? this.webSocket.close() : undefined) } + } - reopen() { - ActionCable.log(`Reopening WebSocket, current state is ${this.getState()}`) - if (this.isActive()) { - try { - return this.close() - } catch (error) { - ActionCable.log("Failed to reopen WebSocket", error) - } - finally { - ActionCable.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`) - setTimeout(this.open, this.constructor.reopenDelay) - } - } else { - return this.open() + reopen() { + ActionCable.log(`Reopening WebSocket, current state is ${this.getState()}`) + if (this.isActive()) { + try { + return this.close() + } catch (error) { + ActionCable.log("Failed to reopen WebSocket", error) } + finally { + ActionCable.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`) + setTimeout(this.open, this.constructor.reopenDelay) + } + } else { + return this.open() } + } - getProtocol() { - return (this.webSocket ? this.webSocket.protocol : undefined) - } + getProtocol() { + return (this.webSocket ? this.webSocket.protocol : undefined) + } - isOpen() { - return this.isState("open") - } + isOpen() { + return this.isState("open") + } - isActive() { - return this.isState("open", "connecting") - } + isActive() { + return this.isState("open", "connecting") + } - // Private + // Private - isProtocolSupported() { - return indexOf.call(supportedProtocols, this.getProtocol()) >= 0 - } + isProtocolSupported() { + return indexOf.call(supportedProtocols, this.getProtocol()) >= 0 + } - isState(...states) { - return indexOf.call(states, this.getState()) >= 0 - } + isState(...states) { + return indexOf.call(states, this.getState()) >= 0 + } - getState() { - if (this.webSocket) { - for (let state in WebSocket) { - if (WebSocket[state] === this.webSocket.readyState) { - return state.toLowerCase() - } + getState() { + if (this.webSocket) { + for (let state in WebSocket) { + if (WebSocket[state] === this.webSocket.readyState) { + return state.toLowerCase() } } - return null } + return null + } - installEventHandlers() { - for (let eventName in this.events) { - const 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 } + } - uninstallEventHandlers() { - for (let eventName in this.events) { - this.webSocket[`on${eventName}`] = function() {} - } + 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, message, type} = JSON.parse(event.data) - switch (type) { - case message_types.welcome: - this.monitor.recordConnect() - return this.subscriptions.reload() - 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() { - ActionCable.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`) - this.disconnected = false - if (!this.isProtocolSupported()) { - ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting.") - return this.close({allowReconnect: false}) - } - }, - - close(event) { - ActionCable.log("WebSocket onclose event") - if (this.disconnected) { return } - this.disconnected = true - this.monitor.recordDisconnect() - return this.subscriptions.notifyAll("disconnected", {willAttemptReconnect: this.monitor.isRunning()}) - }, - - error() { - ActionCable.log("WebSocket onerror event") +} + +Connection.reopenDelay = 500 + +Connection.prototype.events = { + message(event) { + if (!this.isProtocolSupported()) { return } + const {identifier, message, type} = JSON.parse(event.data) + switch (type) { + case message_types.welcome: + this.monitor.recordConnect() + return this.subscriptions.reload() + 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() { + ActionCable.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`) + this.disconnected = false + if (!this.isProtocolSupported()) { + ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting.") + return this.close({allowReconnect: false}) } + }, + + close(event) { + ActionCable.log("WebSocket onclose event") + if (this.disconnected) { return } + this.disconnected = true + this.monitor.recordDisconnect() + return this.subscriptions.notifyAll("disconnected", {willAttemptReconnect: this.monitor.isRunning()}) + }, + + error() { + ActionCable.log("WebSocket onerror event") } +} - return Connection - -})() +export default Connection diff --git a/actioncable/app/javascript/action_cable/connection_monitor.js b/actioncable/app/javascript/action_cable/connection_monitor.js index 4d2db5b4ae..8d6ac1f682 100644 --- a/actioncable/app/javascript/action_cable/connection_monitor.js +++ b/actioncable/app/javascript/action_cable/connection_monitor.js @@ -1,125 +1,125 @@ +import ActionCable from "./index" + // Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting // revival reconnections if things go astray. Internal class, not intended for direct user manipulation. -ActionCable.ConnectionMonitor = (function() { - const now = () => new Date().getTime() - const secondsSince = time => (now() - time) / 1000 +const now = () => new Date().getTime() - const clamp = (number, min, max) => Math.max(min, Math.min(max, number)) +const secondsSince = time => (now() - time) / 1000 - class ConnectionMonitor { - constructor(connection) { - this.visibilityDidChange = this.visibilityDidChange.bind(this) - this.connection = connection - this.reconnectAttempts = 0 - } +const clamp = (number, min, max) => Math.max(min, Math.min(max, number)) - start() { - if (!this.isRunning()) { - this.startedAt = now() - delete this.stoppedAt - this.startPolling() - document.addEventListener("visibilitychange", this.visibilityDidChange) - ActionCable.log(`ConnectionMonitor started. pollInterval = ${this.getPollInterval()} ms`) - } - } +class ConnectionMonitor { + constructor(connection) { + this.visibilityDidChange = this.visibilityDidChange.bind(this) + this.connection = connection + this.reconnectAttempts = 0 + } - stop() { - if (this.isRunning()) { - this.stoppedAt = now() - this.stopPolling() - document.removeEventListener("visibilitychange", this.visibilityDidChange) - ActionCable.log("ConnectionMonitor stopped") - } + start() { + if (!this.isRunning()) { + this.startedAt = now() + delete this.stoppedAt + this.startPolling() + document.addEventListener("visibilitychange", this.visibilityDidChange) + ActionCable.log(`ConnectionMonitor started. pollInterval = ${this.getPollInterval()} ms`) } + } - isRunning() { - return this.startedAt && !this.stoppedAt + stop() { + if (this.isRunning()) { + this.stoppedAt = now() + this.stopPolling() + document.removeEventListener("visibilitychange", this.visibilityDidChange) + ActionCable.log("ConnectionMonitor stopped") } + } - recordPing() { - this.pingedAt = now() - } + isRunning() { + return this.startedAt && !this.stoppedAt + } - recordConnect() { - this.reconnectAttempts = 0 - this.recordPing() - delete this.disconnectedAt - ActionCable.log("ConnectionMonitor recorded connect") - } + recordPing() { + this.pingedAt = now() + } - recordDisconnect() { - this.disconnectedAt = now() - ActionCable.log("ConnectionMonitor recorded disconnect") - } + recordConnect() { + this.reconnectAttempts = 0 + this.recordPing() + delete this.disconnectedAt + ActionCable.log("ConnectionMonitor recorded connect") + } - // Private + recordDisconnect() { + this.disconnectedAt = now() + ActionCable.log("ConnectionMonitor recorded disconnect") + } - startPolling() { - this.stopPolling() - this.poll() - } + // Private - stopPolling() { - clearTimeout(this.pollTimeout) - } + startPolling() { + this.stopPolling() + this.poll() + } - poll() { - this.pollTimeout = setTimeout(() => { - this.reconnectIfStale() - this.poll() - } - , this.getPollInterval()) - } + stopPolling() { + clearTimeout(this.pollTimeout) + } - getPollInterval() { - const {min, max} = this.constructor.pollInterval - const interval = 5 * Math.log(this.reconnectAttempts + 1) - return Math.round(clamp(interval, min, max) * 1000) + poll() { + this.pollTimeout = setTimeout(() => { + this.reconnectIfStale() + this.poll() } + , this.getPollInterval()) + } - reconnectIfStale() { - if (this.connectionIsStale()) { - ActionCable.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, pollInterval = ${this.getPollInterval()} ms, time disconnected = ${secondsSince(this.disconnectedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`) - this.reconnectAttempts++ - if (this.disconnectedRecently()) { - ActionCable.log("ConnectionMonitor skipping reopening recent disconnect") - } else { - ActionCable.log("ConnectionMonitor reopening") - this.connection.reopen() - } + getPollInterval() { + const {min, max} = this.constructor.pollInterval + const interval = 5 * Math.log(this.reconnectAttempts + 1) + return Math.round(clamp(interval, min, max) * 1000) + } + + reconnectIfStale() { + if (this.connectionIsStale()) { + ActionCable.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, pollInterval = ${this.getPollInterval()} ms, time disconnected = ${secondsSince(this.disconnectedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`) + this.reconnectAttempts++ + if (this.disconnectedRecently()) { + ActionCable.log("ConnectionMonitor skipping reopening recent disconnect") + } else { + ActionCable.log("ConnectionMonitor reopening") + this.connection.reopen() } } + } - connectionIsStale() { - return secondsSince(this.pingedAt ? this.pingedAt : this.startedAt) > this.constructor.staleThreshold - } + connectionIsStale() { + return secondsSince(this.pingedAt ? this.pingedAt : this.startedAt) > this.constructor.staleThreshold + } - disconnectedRecently() { - return this.disconnectedAt && (secondsSince(this.disconnectedAt) < 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()) { - ActionCable.log(`ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = ${document.visibilityState}`) - this.connection.reopen() - } + visibilityDidChange() { + if (document.visibilityState === "visible") { + setTimeout(() => { + if (this.connectionIsStale() || !this.connection.isOpen()) { + ActionCable.log(`ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = ${document.visibilityState}`) + this.connection.reopen() } - , 200) } + , 200) } - } - ConnectionMonitor.pollInterval = { - min: 3, - max: 30 - } +} - ConnectionMonitor.staleThreshold = 6 // Server::Connections::BEAT_INTERVAL * 2 (missed two pings) +ConnectionMonitor.pollInterval = { + min: 3, + max: 30 +} - return ConnectionMonitor +ConnectionMonitor.staleThreshold = 6 // Server::Connections::BEAT_INTERVAL * 2 (missed two pings) -})() +export default ConnectionMonitor diff --git a/actioncable/app/javascript/action_cable/consumer.js b/actioncable/app/javascript/action_cable/consumer.js index 731e00996d..c484ceebbd 100644 --- a/actioncable/app/javascript/action_cable/consumer.js +++ b/actioncable/app/javascript/action_cable/consumer.js @@ -1,6 +1,4 @@ -//= require ./connection -//= require ./subscriptions -//= require ./subscription +import ActionCable from "./index" // The ActionCable.Consumer establishes the connection to a server-side Ruby Connection object. Once established, // the ActionCable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates. @@ -27,7 +25,8 @@ // // Any channel subscriptions which existed prior to disconnecting will // automatically resubscribe. -ActionCable.Consumer = class Consumer { + +export default class Consumer { constructor(url) { this.url = url this.subscriptions = new ActionCable.Subscriptions(this) diff --git a/actioncable/app/javascript/action_cable/index.js.erb b/actioncable/app/javascript/action_cable/index.js index eb85eba722..eb0c4844df 100644 --- a/actioncable/app/javascript/action_cable/index.js.erb +++ b/actioncable/app/javascript/action_cable/index.js @@ -1,9 +1,17 @@ -//= export ActionCable -//= require_self -//= require ./action_cable/consumer +import Connection from "./connection" +import ConnectionMonitor from "./connection_monitor" +import Consumer from "./consumer" +import INTERNAL from "./internal" +import Subscription from "./subscription" +import Subscriptions from "./subscriptions" -this.ActionCable = { - INTERNAL: <%= ActionCable::INTERNAL.to_json %>, +export default { + Connection, + ConnectionMonitor, + Consumer, + INTERNAL, + Subscription, + Subscriptions, WebSocket: window.WebSocket, logger: window.console, @@ -12,7 +20,7 @@ this.ActionCable = { const urlConfig = this.getConfig("url") url = (urlConfig ? urlConfig : this.INTERNAL.default_mount_path) } - return new ActionCable.Consumer(this.createWebSocketURL(url)) + return new Consumer(this.createWebSocketURL(url)) }, getConfig(name) { diff --git a/actioncable/app/javascript/action_cable/subscription.js b/actioncable/app/javascript/action_cable/subscription.js index 95b9ff6042..7de08f93b3 100644 --- a/actioncable/app/javascript/action_cable/subscription.js +++ b/actioncable/app/javascript/action_cable/subscription.js @@ -55,38 +55,35 @@ // // The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name. // The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the perform method. -ActionCable.Subscription = (function() { - 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) +const extend = function(object, properties) { + if (properties != null) { + for (let key in properties) { + const value = properties[key] + object[key] = value } + } + return object +} - // Perform a channel action with the optional data passed as an attribute - perform(action, data = {}) { - data.action = action - return this.send(data) - } +export default class Subscription { + constructor(consumer, params = {}, mixin) { + this.consumer = consumer + this.identifier = JSON.stringify(params) + extend(this, mixin) + } - send(data) { - return this.consumer.send({command: "message", identifier: this.identifier, data: JSON.stringify(data)}) - } + // Perform a channel action with the optional data passed as an attribute + perform(action, data = {}) { + data.action = action + return this.send(data) + } - unsubscribe() { - return this.consumer.subscriptions.remove(this) - } + send(data) { + return this.consumer.send({command: "message", identifier: this.identifier, data: JSON.stringify(data)}) } - return Subscription -})() + unsubscribe() { + return this.consumer.subscriptions.remove(this) + } +} diff --git a/actioncable/app/javascript/action_cable/subscriptions.js b/actioncable/app/javascript/action_cable/subscriptions.js index 65bdcc4ece..712ff50d28 100644 --- a/actioncable/app/javascript/action_cable/subscriptions.js +++ b/actioncable/app/javascript/action_cable/subscriptions.js @@ -1,3 +1,5 @@ +import ActionCable from "./index" + // Collection class for creating (and internally managing) channel subscriptions. The only method intended to be triggered by the user // us ActionCable.Subscriptions#create, and it should be called through the consumer like so: // @@ -6,7 +8,8 @@ // App.appearance = App.cable.subscriptions.create("AppearanceChannel") // // For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. -ActionCable.Subscriptions = class Subscriptions { + +export default class Subscriptions { constructor(consumer) { this.consumer = consumer this.subscriptions = [] |