Remove circular dependency warnings in ActionCable javascript and publish source modules with fine-grained exports (#34370)

* Replace several ActionCable.* references with finer-grained imports

This reduces the number of circular dependencies among the module
imports from 4:

```
(!) Circular dependency: app/javascript/action_cable/index.js -> app/javascript/action_cable/connection.js -> app/javascript/action_cable/index.js
(!) Circular dependency: app/javascript/action_cable/index.js -> app/javascript/action_cable/connection_monitor.js -> app/javascript/action_cable/index.js
(!) Circular dependency: app/javascript/action_cable/index.js -> app/javascript/action_cable/consumer.js -> app/javascript/action_cable/index.js
(!) Circular dependency: app/javascript/action_cable/index.js -> app/javascript/action_cable/subscriptions.js -> app/javascript/action_cable/index.js
```

to 2:

```
(!) Circular dependency: app/javascript/action_cable/index.js -> app/javascript/action_cable/connection.js -> app/javascript/action_cable/index.js
(!) Circular dependency: app/javascript/action_cable/index.js -> app/javascript/action_cable/connection.js -> app/javascript/action_cable/connection_monitor.js -> app/javascript/action_cable/index.js
```

* Remove tests that only test javascript object property assignment

These tests really only assert that you can assign a property to
the ActionCable global object. That's true for pretty much any object
in javascript (it would only be false if the object has been frozen, or
has explicitly set some properties to be nonconfigurable).

* Refactor ActionCable to provide individual named exports

By providing individual named exports rather than a default export which
is an object with all of those properties, we enable applications to
only import the functions they need: any unused functions will be
removed via tree shaking.

Additionally, this restructuring removes the remaining circular
dependencies by extracting the separate adapters and logger modules, so
there are now no warnings when compiling the ActionCable bundle.

Note: This produces two small breaking API changes:

- The `ActionCable.WebSocket` getter and setter would be moved to
  `ActionCable.adapters.WebSocket`. If a user is currently configuring
  this, when upgrading they'd need to either add a delegated
  getter/setter themselves, or change it like this:
   ```diff
   -    ActionCable.WebSocket = MyWebSocket
   +    ActionCable.adapters.WebSocket = MyWebSocket
    ```
   Applications which don't change the WebSocket adapter would not need
   any changes for this when upgrading.

- Similarly, the `ActionCable.logger` getter and setter would be moved
  to `ActionCable.adapters.logger`. If a user is currently configuring
  this, when upgrading they'd need to either add a delegated
  getter/setter themselves, or change it like this:
   ```diff
   -    ActionCable.logger = myLogger
   +    ActionCable.adapters.logger = myLogger
    ```
   Applications which don't change the logger would not need any changes
   for this when upgrading.

These two aspects of the public API have to change because there's no
way to export a property setter for `WebSocket` (or `logger`) such that
this:
```js
import ActionCable from "actioncable"

ActionCable.WebSocket = MyWebSocket
```
would actually update `adapters.WebSocket`. (We can only offer that if
we have two separate source files like if `index.js` uses
`import * as ActionCable from "./action_cable" and then exports a
wrapper which has delegated getters and setters for those properties.)

This API change is very minor - it should be easy for applications to
add the `adapters.` prefix in their assignments or to patch in delegated
setters. And especially because most applications in the wild are not
ever changing the default value of `ActionCable.WebSocket` or
`ActionCable.logger` (because the default values are perfect), this API
breakage is worth the tree-shaking benefits we gain.

* Include source code in published actioncable npm package

This allows actioncable users to ship smaller javascript bundles to
visitors using modern browsers, as demonstrated in this repository:
https://github.com/rmacklin/actioncable-es2015-build-example

In that example, the bundle shrinks by 2.8K (25.2%) when you simply
change the actioncable import to point to the untranspiled src.

If you go a step further, like this:
```
diff --git a/app/scripts/main.js b/app/scripts/main.js
index 17bc031..1a2b2e0 100644
--- a/app/scripts/main.js
+++ b/app/scripts/main.js
@@ -1,6 +1,6 @@
-import ActionCable from 'actioncable';
+import * as ActionCable from 'actioncable';

 let cable = ActionCable.createConsumer('wss://cable.example.com');

 cable.subscriptions.create('AppearanceChannel', {
```

then the bundle shrinks by 3.6K (31.7%)!

In addition to allowing smaller bundles for those who ship untranspiled
code to modern browsers, including the source code in the published
package can be useful in other ways:

1. Users can import individual modules rather than the whole library
2. As a result of (1), users can also monkey patch parts of actioncable
   by importing the relevant module, modifying the exported object, and
   then importing the rest of actioncable (which would then use the
   patched object).

Note: This is the same enhancement that we made to activestorage in
c0368ad090

* Remove unused commonjs & resolve plugins from ActionCable rollup config

These were added when we copied the rollup config from ActiveStorage,
but ActionCable does not have any commonjs dependencies (it doesn't have
any external dependencies at all), so these plugins are unnecessary here

* Change ActionCable.startDebugging() -> ActionCable.logger.enabled=true

and ActionCable.stopDebugging() -> ActionCable.logger.enabled=false

This API is simpler and more clearly describes what it does

* Change Travis configuration to run yarn install at the root for ActionCable builds

This is necessary now that the repository is using Yarn Workspaces
This commit is contained in:
rmacklin 2018-12-01 13:25:02 -08:00 committed by Javan Makhmali
parent a429b29425
commit aa1ba9cb24
16 changed files with 326 additions and 291 deletions

View File

@ -37,7 +37,7 @@ before_install:
- "travis_retry gem update --system"
- "travis_retry gem install bundler"
- "[[ -z $encrypted_0fb9444d0374_key && -z $encrypted_0fb9444d0374_iv ]] || openssl aes-256-cbc -K $encrypted_0fb9444d0374_key -iv $encrypted_0fb9444d0374_iv -in activestorage/test/service/configurations.yml.enc -out activestorage/test/service/configurations.yml -d"
- "[[ $GEM != 'ac:integration' ]] || (cd actioncable && yarn install)"
- "[[ $GEM != 'ac:integration' ]] || yarn install"
- "[[ $GEM != 'av:ujs' ]] || nvm install node"
- "[[ $GEM != 'av:ujs' ]] || node --version"
- "[[ $GEM != 'av:ujs' ]] || (cd actionview && npm install)"

View File

@ -1,3 +1,4 @@
/app/javascript/action_cable/internal.js
/src
/test/javascript/compiled/
/tmp/

View File

@ -1,3 +1,42 @@
* The ActionCable javascript package has been converted from CoffeeScript
to ES2015, and we now publish the source code in the npm distribution.
This allows ActionCable users to depend on the javascript source code
rather than the compiled code, which can produce smaller javascript bundles.
This change includes some breaking changes to optional parts of the
ActionCable javascript API:
- Configuration of the WebSocket adapter and logger adapter have been moved
from properties of `ActionCable` to properties of `ActionCable.adapters`.
If you are currently configuring these adapters you will need to make
these changes when upgrading:
```diff
- ActionCable.WebSocket = MyWebSocket
+ ActionCable.adapters.WebSocket = MyWebSocket
```
```diff
- ActionCable.logger = myLogger
+ ActionCable.adapters.logger = myLogger
```
- The `ActionCable.startDebugging()` and `ActionCable.stopDebugging()`
methods have been removed and replaced with the property
`ActionCable.logger.enabled`. If you are currently using these methods you
will need to make these changes when upgrading:
```diff
- ActionCable.startDebugging()
+ ActionCable.logger.enabled = true
```
```diff
- ActionCable.stopDebugging()
+ ActionCable.logger.enabled = false
```
*Richard Macklin*
* Add `id` option to redis adapter so now you can distinguish
ActionCable's redis connections among others. Also, you can set
custom id in options.

View File

@ -1,16 +1,22 @@
(function(global, factory) {
typeof exports === "object" && typeof module !== "undefined" ? module.exports = factory() : typeof define === "function" && define.amd ? define(factory) : global.ActionCable = factory();
})(this, function() {
typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : factory(global.ActionCable = {});
})(this, function(exports) {
"use strict";
var INTERNAL = {
message_types: {
welcome: "welcome",
ping: "ping",
confirmation: "confirm_subscription",
rejection: "reject_subscription"
},
default_mount_path: "/cable",
protocols: [ "actioncable-v1-json", "actioncable-unsupported" ]
var adapters = {
logger: window.console,
WebSocket: window.WebSocket
};
var logger = {
log: function log() {
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));
}
}
};
var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function(obj) {
return typeof obj;
@ -22,6 +28,121 @@
throw new TypeError("Cannot call a class as a function");
}
};
var now = function now() {
return new Date().getTime();
};
var secondsSince = function secondsSince(time) {
return (now() - time) / 1e3;
};
var clamp = function clamp(number, min, max) {
return Math.max(min, Math.min(max, number));
};
var ConnectionMonitor = function() {
function ConnectionMonitor(connection) {
classCallCheck(this, ConnectionMonitor);
this.visibilityDidChange = this.visibilityDidChange.bind(this);
this.connection = connection;
this.reconnectAttempts = 0;
}
ConnectionMonitor.prototype.start = function start() {
if (!this.isRunning()) {
this.startedAt = now();
delete this.stoppedAt;
this.startPolling();
document.addEventListener("visibilitychange", this.visibilityDidChange);
logger.log("ConnectionMonitor started. pollInterval = " + this.getPollInterval() + " ms");
}
};
ConnectionMonitor.prototype.stop = function stop() {
if (this.isRunning()) {
this.stoppedAt = now();
this.stopPolling();
document.removeEventListener("visibilitychange", this.visibilityDidChange);
logger.log("ConnectionMonitor stopped");
}
};
ConnectionMonitor.prototype.isRunning = function isRunning() {
return this.startedAt && !this.stoppedAt;
};
ConnectionMonitor.prototype.recordPing = function recordPing() {
this.pingedAt = now();
};
ConnectionMonitor.prototype.recordConnect = function recordConnect() {
this.reconnectAttempts = 0;
this.recordPing();
delete this.disconnectedAt;
logger.log("ConnectionMonitor recorded connect");
};
ConnectionMonitor.prototype.recordDisconnect = function recordDisconnect() {
this.disconnectedAt = now();
logger.log("ConnectionMonitor recorded disconnect");
};
ConnectionMonitor.prototype.startPolling = function startPolling() {
this.stopPolling();
this.poll();
};
ConnectionMonitor.prototype.stopPolling = function 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$pollInte = this.constructor.pollInterval, min = _constructor$pollInte.min, max = _constructor$pollInte.max, multiplier = _constructor$pollInte.multiplier;
var interval = multiplier * Math.log(this.reconnectAttempts + 1);
return Math.round(clamp(interval, min, max) * 1e3);
};
ConnectionMonitor.prototype.reconnectIfStale = function reconnectIfStale() {
if (this.connectionIsStale()) {
logger.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()) {
logger.log("ConnectionMonitor skipping reopening recent disconnect");
} else {
logger.log("ConnectionMonitor reopening");
this.connection.reopen();
}
}
};
ConnectionMonitor.prototype.connectionIsStale = function connectionIsStale() {
return secondsSince(this.pingedAt ? this.pingedAt : this.startedAt) > this.constructor.staleThreshold;
};
ConnectionMonitor.prototype.disconnectedRecently = function disconnectedRecently() {
return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold;
};
ConnectionMonitor.prototype.visibilityDidChange = function visibilityDidChange() {
var _this2 = this;
if (document.visibilityState === "visible") {
setTimeout(function() {
if (_this2.connectionIsStale() || !_this2.connection.isOpen()) {
logger.log("ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = " + document.visibilityState);
_this2.connection.reopen();
}
}, 200);
}
};
return ConnectionMonitor;
}();
ConnectionMonitor.pollInterval = {
min: 3,
max: 30,
multiplier: 5
};
ConnectionMonitor.staleThreshold = 6;
var INTERNAL = {
message_types: {
welcome: "welcome",
ping: "ping",
confirmation: "confirm_subscription",
rejection: "reject_subscription"
},
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;
@ -31,7 +152,7 @@
this.open = this.open.bind(this);
this.consumer = consumer;
this.subscriptions = this.consumer.subscriptions;
this.monitor = new ActionCable.ConnectionMonitor(this);
this.monitor = new ConnectionMonitor(this);
this.disconnected = true;
}
Connection.prototype.send = function send(data) {
@ -44,14 +165,14 @@
};
Connection.prototype.open = function open() {
if (this.isActive()) {
ActionCable.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 {
ActionCable.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();
}
this.webSocket = new ActionCable.WebSocket(this.consumer.url, protocols);
this.webSocket = new adapters.WebSocket(this.consumer.url, protocols);
this.installEventHandlers();
this.monitor.start();
return true;
@ -69,14 +190,14 @@
}
};
Connection.prototype.reopen = function reopen() {
ActionCable.log("Reopening WebSocket, current state is " + this.getState());
logger.log("Reopening WebSocket, current state is " + this.getState());
if (this.isActive()) {
try {
return this.close();
} catch (error) {
ActionCable.log("Failed to reopen WebSocket", error);
logger.log("Failed to reopen WebSocket", error);
} finally {
ActionCable.log("Reopening WebSocket in " + this.constructor.reopenDelay + "ms");
logger.log("Reopening WebSocket in " + this.constructor.reopenDelay + "ms");
setTimeout(this.open, this.constructor.reopenDelay);
}
} else {
@ -150,17 +271,17 @@
}
},
open: function open() {
ActionCable.log("WebSocket onopen event, using '" + this.getProtocol() + "' subprotocol");
logger.log("WebSocket onopen event, using '" + this.getProtocol() + "' subprotocol");
this.disconnected = false;
if (!this.isProtocolSupported()) {
ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting.");
logger.log("Protocol is unsupported. Stopping monitor and disconnecting.");
return this.close({
allowReconnect: false
});
}
},
close: function close(event) {
ActionCable.log("WebSocket onclose event");
logger.log("WebSocket onclose event");
if (this.disconnected) {
return;
}
@ -171,139 +292,9 @@
});
},
error: function error() {
ActionCable.log("WebSocket onerror event");
logger.log("WebSocket onerror event");
}
};
var now = function now() {
return new Date().getTime();
};
var secondsSince = function secondsSince(time) {
return (now() - time) / 1e3;
};
var clamp = function clamp(number, min, max) {
return Math.max(min, Math.min(max, number));
};
var ConnectionMonitor = function() {
function ConnectionMonitor(connection) {
classCallCheck(this, ConnectionMonitor);
this.visibilityDidChange = this.visibilityDidChange.bind(this);
this.connection = connection;
this.reconnectAttempts = 0;
}
ConnectionMonitor.prototype.start = function 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");
}
};
ConnectionMonitor.prototype.stop = function stop() {
if (this.isRunning()) {
this.stoppedAt = now();
this.stopPolling();
document.removeEventListener("visibilitychange", this.visibilityDidChange);
ActionCable.log("ConnectionMonitor stopped");
}
};
ConnectionMonitor.prototype.isRunning = function isRunning() {
return this.startedAt && !this.stoppedAt;
};
ConnectionMonitor.prototype.recordPing = function recordPing() {
this.pingedAt = now();
};
ConnectionMonitor.prototype.recordConnect = function recordConnect() {
this.reconnectAttempts = 0;
this.recordPing();
delete this.disconnectedAt;
ActionCable.log("ConnectionMonitor recorded connect");
};
ConnectionMonitor.prototype.recordDisconnect = function recordDisconnect() {
this.disconnectedAt = now();
ActionCable.log("ConnectionMonitor recorded disconnect");
};
ConnectionMonitor.prototype.startPolling = function startPolling() {
this.stopPolling();
this.poll();
};
ConnectionMonitor.prototype.stopPolling = function 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$pollInte = this.constructor.pollInterval, min = _constructor$pollInte.min, max = _constructor$pollInte.max, multiplier = _constructor$pollInte.multiplier;
var interval = multiplier * Math.log(this.reconnectAttempts + 1);
return Math.round(clamp(interval, min, max) * 1e3);
};
ConnectionMonitor.prototype.reconnectIfStale = function 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();
}
}
};
ConnectionMonitor.prototype.connectionIsStale = function connectionIsStale() {
return secondsSince(this.pingedAt ? this.pingedAt : this.startedAt) > this.constructor.staleThreshold;
};
ConnectionMonitor.prototype.disconnectedRecently = function disconnectedRecently() {
return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold;
};
ConnectionMonitor.prototype.visibilityDidChange = function visibilityDidChange() {
var _this2 = this;
if (document.visibilityState === "visible") {
setTimeout(function() {
if (_this2.connectionIsStale() || !_this2.connection.isOpen()) {
ActionCable.log("ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = " + document.visibilityState);
_this2.connection.reopen();
}
}, 200);
}
};
return ConnectionMonitor;
}();
ConnectionMonitor.pollInterval = {
min: 3,
max: 30,
multiplier: 5
};
ConnectionMonitor.staleThreshold = 6;
var Consumer = function() {
function Consumer(url) {
classCallCheck(this, Consumer);
this.url = url;
this.subscriptions = new ActionCable.Subscriptions(this);
this.connection = new ActionCable.Connection(this);
}
Consumer.prototype.send = function send(data) {
return this.connection.send(data);
};
Consumer.prototype.connect = function connect() {
return this.connection.open();
};
Consumer.prototype.disconnect = function disconnect() {
return this.connection.close({
allowReconnect: false
});
};
Consumer.prototype.ensureActiveConnection = function ensureActiveConnection() {
if (!this.connection.isActive()) {
return this.connection.open();
}
};
return Consumer;
}();
var extend = function extend(object, properties) {
if (properties != null) {
for (var key in properties) {
@ -350,7 +341,7 @@
var params = (typeof channel === "undefined" ? "undefined" : _typeof(channel)) === "object" ? channel : {
channel: channel
};
var subscription = new ActionCable.Subscription(this.consumer, params, mixin);
var subscription = new Subscription(this.consumer, params, mixin);
return this.add(subscription);
};
Subscriptions.prototype.add = function add(subscription) {
@ -424,53 +415,65 @@
};
return Subscriptions;
}();
var ActionCable = {
Connection: Connection,
ConnectionMonitor: ConnectionMonitor,
Consumer: Consumer,
INTERNAL: INTERNAL,
Subscription: Subscription,
Subscriptions: Subscriptions,
WebSocket: window.WebSocket,
logger: window.console,
createConsumer: function createConsumer(url) {
if (url == null) {
var urlConfig = this.getConfig("url");
url = urlConfig ? urlConfig : this.INTERNAL.default_mount_path;
}
return new Consumer(this.createWebSocketURL(url));
},
getConfig: function getConfig(name) {
var element = document.head.querySelector("meta[name='action-cable-" + name + "']");
return element ? element.getAttribute("content") : undefined;
},
createWebSocketURL: function createWebSocketURL(url) {
if (url && !/^wss?:/i.test(url)) {
var a = document.createElement("a");
a.href = url;
a.href = a.href;
a.protocol = a.protocol.replace("http", "ws");
return a.href;
} else {
return url;
}
},
startDebugging: function startDebugging() {
this.debugging = true;
},
stopDebugging: function stopDebugging() {
this.debugging = null;
},
log: function log() {
if (this.debugging) {
var _logger;
for (var _len = arguments.length, messages = Array(_len), _key = 0; _key < _len; _key++) {
messages[_key] = arguments[_key];
}
messages.push(Date.now());
(_logger = this.logger).log.apply(_logger, [ "[ActionCable]" ].concat(messages));
}
var Consumer = function() {
function Consumer(url) {
classCallCheck(this, Consumer);
this.url = url;
this.subscriptions = new Subscriptions(this);
this.connection = new Connection(this);
}
};
return ActionCable;
Consumer.prototype.send = function send(data) {
return this.connection.send(data);
};
Consumer.prototype.connect = function connect() {
return this.connection.open();
};
Consumer.prototype.disconnect = function disconnect() {
return this.connection.close({
allowReconnect: false
});
};
Consumer.prototype.ensureActiveConnection = function ensureActiveConnection() {
if (!this.connection.isActive()) {
return this.connection.open();
}
};
return Consumer;
}();
function createConsumer(url) {
if (url == null) {
var urlConfig = getConfig("url");
url = urlConfig ? urlConfig : INTERNAL.default_mount_path;
}
return new Consumer(createWebSocketURL(url));
}
function getConfig(name) {
var element = document.head.querySelector("meta[name='action-cable-" + name + "']");
return element ? element.getAttribute("content") : undefined;
}
function createWebSocketURL(url) {
if (url && !/^wss?:/i.test(url)) {
var a = document.createElement("a");
a.href = url;
a.href = a.href;
a.protocol = a.protocol.replace("http", "ws");
return a.href;
} else {
return url;
}
}
exports.Connection = Connection;
exports.ConnectionMonitor = ConnectionMonitor;
exports.Consumer = Consumer;
exports.INTERNAL = INTERNAL;
exports.Subscription = Subscription;
exports.Subscriptions = Subscriptions;
exports.adapters = adapters;
exports.logger = logger;
exports.createConsumer = createConsumer;
exports.getConfig = getConfig;
exports.createWebSocketURL = createWebSocketURL;
Object.defineProperty(exports, "__esModule", {
value: true
});
});

View File

@ -0,0 +1,4 @@
export default {
logger: window.console,
WebSocket: window.WebSocket
}

View File

@ -1,5 +1,7 @@
import ActionCable from "./index"
import adapters from "./adapters"
import ConnectionMonitor from "./connection_monitor"
import INTERNAL from "./internal"
import logger from "./logger"
// Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation.
@ -13,7 +15,7 @@ class Connection {
this.open = this.open.bind(this)
this.consumer = consumer
this.subscriptions = this.consumer.subscriptions
this.monitor = new ActionCable.ConnectionMonitor(this)
this.monitor = new ConnectionMonitor(this)
this.disconnected = true
}
@ -28,12 +30,12 @@ class Connection {
open() {
if (this.isActive()) {
ActionCable.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 {
ActionCable.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() }
this.webSocket = new ActionCable.WebSocket(this.consumer.url, protocols)
this.webSocket = new adapters.WebSocket(this.consumer.url, protocols)
this.installEventHandlers()
this.monitor.start()
return true
@ -46,15 +48,15 @@ class Connection {
}
reopen() {
ActionCable.log(`Reopening WebSocket, current state is ${this.getState()}`)
logger.log(`Reopening WebSocket, current state is ${this.getState()}`)
if (this.isActive()) {
try {
return this.close()
} catch (error) {
ActionCable.log("Failed to reopen WebSocket", error)
logger.log("Failed to reopen WebSocket", error)
}
finally {
ActionCable.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`)
logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`)
setTimeout(this.open, this.constructor.reopenDelay)
}
} else {
@ -132,16 +134,16 @@ Connection.prototype.events = {
},
open() {
ActionCable.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`)
logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`)
this.disconnected = false
if (!this.isProtocolSupported()) {
ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting.")
logger.log("Protocol is unsupported. Stopping monitor and disconnecting.")
return this.close({allowReconnect: false})
}
},
close(event) {
ActionCable.log("WebSocket onclose event")
logger.log("WebSocket onclose event")
if (this.disconnected) { return }
this.disconnected = true
this.monitor.recordDisconnect()
@ -149,7 +151,7 @@ Connection.prototype.events = {
},
error() {
ActionCable.log("WebSocket onerror event")
logger.log("WebSocket onerror event")
}
}

View File

@ -1,4 +1,4 @@
import ActionCable from "./index"
import logger from "./logger"
// 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.
@ -22,7 +22,7 @@ class ConnectionMonitor {
delete this.stoppedAt
this.startPolling()
document.addEventListener("visibilitychange", this.visibilityDidChange)
ActionCable.log(`ConnectionMonitor started. pollInterval = ${this.getPollInterval()} ms`)
logger.log(`ConnectionMonitor started. pollInterval = ${this.getPollInterval()} ms`)
}
}
@ -31,7 +31,7 @@ class ConnectionMonitor {
this.stoppedAt = now()
this.stopPolling()
document.removeEventListener("visibilitychange", this.visibilityDidChange)
ActionCable.log("ConnectionMonitor stopped")
logger.log("ConnectionMonitor stopped")
}
}
@ -47,12 +47,12 @@ class ConnectionMonitor {
this.reconnectAttempts = 0
this.recordPing()
delete this.disconnectedAt
ActionCable.log("ConnectionMonitor recorded connect")
logger.log("ConnectionMonitor recorded connect")
}
recordDisconnect() {
this.disconnectedAt = now()
ActionCable.log("ConnectionMonitor recorded disconnect")
logger.log("ConnectionMonitor recorded disconnect")
}
// Private
@ -82,12 +82,12 @@ class ConnectionMonitor {
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`)
logger.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")
logger.log("ConnectionMonitor skipping reopening recent disconnect")
} else {
ActionCable.log("ConnectionMonitor reopening")
logger.log("ConnectionMonitor reopening")
this.connection.reopen()
}
}
@ -105,7 +105,7 @@ class ConnectionMonitor {
if (document.visibilityState === "visible") {
setTimeout(() => {
if (this.connectionIsStale() || !this.connection.isOpen()) {
ActionCable.log(`ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = ${document.visibilityState}`)
logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = ${document.visibilityState}`)
this.connection.reopen()
}
}

View File

@ -1,4 +1,5 @@
import ActionCable from "./index"
import Connection from "./connection"
import Subscriptions from "./subscriptions"
// 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.
@ -29,8 +30,8 @@ import ActionCable from "./index"
export default class Consumer {
constructor(url) {
this.url = url
this.subscriptions = new ActionCable.Subscriptions(this)
this.connection = new ActionCable.Connection(this)
this.subscriptions = new Subscriptions(this)
this.connection = new Connection(this)
}
send(data) {

View File

@ -4,55 +4,42 @@ import Consumer from "./consumer"
import INTERNAL from "./internal"
import Subscription from "./subscription"
import Subscriptions from "./subscriptions"
import adapters from "./adapters"
import logger from "./logger"
export default {
export {
Connection,
ConnectionMonitor,
Consumer,
INTERNAL,
Subscription,
Subscriptions,
WebSocket: window.WebSocket,
logger: window.console,
adapters,
logger,
}
createConsumer(url) {
if (url == null) {
const urlConfig = this.getConfig("url")
url = (urlConfig ? urlConfig : this.INTERNAL.default_mount_path)
}
return new Consumer(this.createWebSocketURL(url))
},
export function createConsumer(url) {
if (url == null) {
const urlConfig = getConfig("url")
url = (urlConfig ? urlConfig : INTERNAL.default_mount_path)
}
return new Consumer(createWebSocketURL(url))
}
getConfig(name) {
const element = document.head.querySelector(`meta[name='action-cable-${name}']`)
return (element ? element.getAttribute("content") : undefined)
},
export function getConfig(name) {
const element = document.head.querySelector(`meta[name='action-cable-${name}']`)
return (element ? element.getAttribute("content") : undefined)
}
createWebSocketURL(url) {
if (url && !/^wss?:/i.test(url)) {
const a = document.createElement("a")
a.href = url
// Fix populating Location properties in IE. Otherwise, protocol will be blank.
a.href = a.href
a.protocol = a.protocol.replace("http", "ws")
return a.href
} else {
return url
}
},
startDebugging() {
this.debugging = true
},
stopDebugging() {
this.debugging = null
},
log(...messages) {
if (this.debugging) {
messages.push(Date.now())
this.logger.log("[ActionCable]", ...messages)
}
export function createWebSocketURL(url) {
if (url && !/^wss?:/i.test(url)) {
const a = document.createElement("a")
a.href = url
// Fix populating Location properties in IE. Otherwise, protocol will be blank.
a.href = a.href
a.protocol = a.protocol.replace("http", "ws")
return a.href
} else {
return url
}
}

View File

@ -0,0 +1,10 @@
import adapters from "./adapters"
export default {
log(...messages) {
if (this.enabled) {
messages.push(Date.now())
adapters.logger.log("[ActionCable]", ...messages)
}
},
}

View File

@ -1,4 +1,4 @@
import ActionCable from "./index"
import Subscription from "./subscription"
// 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:
@ -18,7 +18,7 @@ export default class Subscriptions {
create(channelName, mixin) {
const channel = channelName
const params = typeof channel === "object" ? channel : {channel}
const subscription = new ActionCable.Subscription(this.consumer, params, mixin)
const subscription = new Subscription(this.consumer, params, mixin)
return this.add(subscription)
}

View File

@ -4,7 +4,8 @@
"description": "WebSocket framework for Ruby on Rails.",
"main": "app/assets/javascripts/action_cable.js",
"files": [
"app/assets/javascripts/*.js"
"app/assets/javascripts/*.js",
"src/*.js"
],
"repository": {
"type": "git",
@ -43,6 +44,7 @@
"prebuild": "yarn lint && bundle exec rake assets:codegen",
"build": "rollup --config rollup.config.js",
"lint": "eslint app/javascript",
"prepublishOnly": "rm -rf src && cp -R app/javascript/action_cable src",
"pretest": "bundle exec rake assets:codegen && rollup --config rollup.config.test.js",
"test": "karma start"
}

View File

@ -1,5 +1,3 @@
import resolve from "rollup-plugin-node-resolve"
import commonjs from "rollup-plugin-commonjs"
import babel from "rollup-plugin-babel"
import uglify from "rollup-plugin-uglify"
@ -20,8 +18,6 @@ export default {
name: "ActionCable"
},
plugins: [
resolve(),
commonjs(),
babel(),
uglify(uglifyOptions)
]

View File

@ -1,5 +1,5 @@
import { WebSocket as MockWebSocket, Server as MockServer } from "mock-socket"
import ActionCable from "../../../../app/javascript/action_cable/index"
import * as ActionCable from "../../../../app/javascript/action_cable/index"
import {defer, testURL} from "./index"
export default function(name, options, callback) {
@ -14,7 +14,7 @@ export default function(name, options, callback) {
return QUnit.test(name, function(assert) {
const doneAsync = assert.async()
ActionCable.WebSocket = MockWebSocket
ActionCable.adapters.WebSocket = MockWebSocket
const server = new MockServer(options.url)
const consumer = ActionCable.createConsumer(options.url)

View File

@ -1,4 +1,4 @@
import ActionCable from "../../../../app/javascript/action_cable/index"
import * as ActionCable from "../../../../app/javascript/action_cable/index"
export const testURL = "ws://cable.example.com/"
@ -6,5 +6,5 @@ export function defer(callback) {
setTimeout(callback, 1)
}
const originalWebSocket = ActionCable.WebSocket
QUnit.testDone(() => ActionCable.WebSocket = originalWebSocket)
const originalWebSocket = ActionCable.adapters.WebSocket
QUnit.testDone(() => ActionCable.adapters.WebSocket = originalWebSocket)

View File

@ -1,4 +1,4 @@
import ActionCable from "../../../../app/javascript/action_cable/index"
import * as ActionCable from "../../../../app/javascript/action_cable/index"
import {testURL} from "../test_helpers/index"
const {module, test} = QUnit
@ -7,23 +7,13 @@ module("ActionCable", () => {
module("Adapters", () => {
module("WebSocket", () => {
test("default is window.WebSocket", assert => {
assert.equal(ActionCable.WebSocket, window.WebSocket)
})
test("configurable", assert => {
ActionCable.WebSocket = ""
assert.equal(ActionCable.WebSocket, "")
assert.equal(ActionCable.adapters.WebSocket, window.WebSocket)
})
})
module("logger", () => {
test("default is window.console", assert => {
assert.equal(ActionCable.logger, window.console)
})
test("configurable", assert => {
ActionCable.logger = ""
assert.equal(ActionCable.logger, "")
assert.equal(ActionCable.adapters.logger, window.console)
})
})
})