From 7b0b37240a20c74197408ac3b53519e7e18347e9 Mon Sep 17 00:00:00 2001 From: Richard Macklin Date: Mon, 11 Dec 2017 14:29:45 +0800 Subject: Move actioncable javascript to app/javascript and change .coffee -> .js - Rename action_cable/*.coffee -> *.js - Move app/assets/javascripts/* -> app/javascript/* - Rename action_cable.js.erb -> action_cable/index.js.erb Renaming the extension to .js is in preparation for converting these files from coffeescript to ES2015. Moving the files to app/javascript and putting the entry point in index.js.erb changes the structure of ActionCable's javascript to match the structure of ActiveStorage's javascript. (We are doing the file moving and renaming in a separate commit to ensure that the git history of the files will be preserved - i.e. git will track these as file renames rather than unrelated file additions/deletions. In particular, git blame will still trace back to the original authorship.) --- .../app/assets/javascripts/action_cable.coffee.erb | 38 ------- .../javascripts/action_cable/connection.coffee | 116 --------------------- .../action_cable/connection_monitor.coffee | 95 ----------------- .../javascripts/action_cable/consumer.coffee | 46 -------- .../javascripts/action_cable/subscription.coffee | 72 ------------- .../javascripts/action_cable/subscriptions.coffee | 66 ------------ .../app/javascript/action_cable/connection.js | 116 +++++++++++++++++++++ .../javascript/action_cable/connection_monitor.js | 95 +++++++++++++++++ .../app/javascript/action_cable/consumer.js | 46 ++++++++ .../app/javascript/action_cable/index.js.erb | 38 +++++++ .../app/javascript/action_cable/subscription.js | 72 +++++++++++++ .../app/javascript/action_cable/subscriptions.js | 66 ++++++++++++ 12 files changed, 433 insertions(+), 433 deletions(-) delete mode 100644 actioncable/app/assets/javascripts/action_cable.coffee.erb delete mode 100644 actioncable/app/assets/javascripts/action_cable/connection.coffee delete mode 100644 actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee delete mode 100644 actioncable/app/assets/javascripts/action_cable/consumer.coffee delete mode 100644 actioncable/app/assets/javascripts/action_cable/subscription.coffee delete mode 100644 actioncable/app/assets/javascripts/action_cable/subscriptions.coffee create mode 100644 actioncable/app/javascript/action_cable/connection.js create mode 100644 actioncable/app/javascript/action_cable/connection_monitor.js create mode 100644 actioncable/app/javascript/action_cable/consumer.js create mode 100644 actioncable/app/javascript/action_cable/index.js.erb create mode 100644 actioncable/app/javascript/action_cable/subscription.js create mode 100644 actioncable/app/javascript/action_cable/subscriptions.js (limited to 'actioncable/app') diff --git a/actioncable/app/assets/javascripts/action_cable.coffee.erb b/actioncable/app/assets/javascripts/action_cable.coffee.erb deleted file mode 100644 index e0758dae72..0000000000 --- a/actioncable/app/assets/javascripts/action_cable.coffee.erb +++ /dev/null @@ -1,38 +0,0 @@ -#= export ActionCable -#= require_self -#= require ./action_cable/consumer - -@ActionCable = - INTERNAL: <%= ActionCable::INTERNAL.to_json %> - WebSocket: window.WebSocket - logger: window.console - - createConsumer: (url) -> - url ?= @getConfig("url") ? @INTERNAL.default_mount_path - new ActionCable.Consumer @createWebSocketURL(url) - - getConfig: (name) -> - element = document.head.querySelector("meta[name='action-cable-#{name}']") - element?.getAttribute("content") - - createWebSocketURL: (url) -> - if url and not /^wss?:/i.test(url) - 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") - a.href - else - url - - startDebugging: -> - @debugging = true - - stopDebugging: -> - @debugging = null - - log: (messages...) -> - if @debugging - messages.push(Date.now()) - @logger.log("[ActionCable]", messages...) diff --git a/actioncable/app/assets/javascripts/action_cable/connection.coffee b/actioncable/app/assets/javascripts/action_cable/connection.coffee deleted file mode 100644 index 7fd68cad2f..0000000000 --- a/actioncable/app/assets/javascripts/action_cable/connection.coffee +++ /dev/null @@ -1,116 +0,0 @@ -#= require ./connection_monitor - -# Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. - -{message_types, protocols} = ActionCable.INTERNAL -[supportedProtocols..., unsupportedProtocol] = protocols - -class ActionCable.Connection - @reopenDelay: 500 - - constructor: (@consumer) -> - {@subscriptions} = @consumer - @monitor = new ActionCable.ConnectionMonitor this - @disconnected = true - - send: (data) -> - if @isOpen() - @webSocket.send(JSON.stringify(data)) - true - else - false - - open: => - if @isActive() - ActionCable.log("Attempted to open WebSocket, but existing socket is #{@getState()}") - false - else - ActionCable.log("Opening WebSocket, current state is #{@getState()}, subprotocols: #{protocols}") - @uninstallEventHandlers() if @webSocket? - @webSocket = new ActionCable.WebSocket(@consumer.url, protocols) - @installEventHandlers() - @monitor.start() - true - - close: ({allowReconnect} = {allowReconnect: true}) -> - @monitor.stop() unless allowReconnect - @webSocket?.close() if @isActive() - - reopen: -> - ActionCable.log("Reopening WebSocket, current state is #{@getState()}") - if @isActive() - try - @close() - catch error - ActionCable.log("Failed to reopen WebSocket", error) - finally - ActionCable.log("Reopening WebSocket in #{@constructor.reopenDelay}ms") - setTimeout(@open, @constructor.reopenDelay) - else - @open() - - getProtocol: -> - @webSocket?.protocol - - isOpen: -> - @isState("open") - - isActive: -> - @isState("open", "connecting") - - # Private - - isProtocolSupported: -> - @getProtocol() in supportedProtocols - - isState: (states...) -> - @getState() in states - - getState: -> - return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState - null - - installEventHandlers: -> - for eventName of @events - handler = @events[eventName].bind(this) - @webSocket["on#{eventName}"] = handler - return - - uninstallEventHandlers: -> - for eventName of @events - @webSocket["on#{eventName}"] = -> - return - - events: - message: (event) -> - return unless @isProtocolSupported() - {identifier, message, type} = JSON.parse(event.data) - switch type - when message_types.welcome - @monitor.recordConnect() - @subscriptions.reload() - when message_types.ping - @monitor.recordPing() - when message_types.confirmation - @subscriptions.notify(identifier, "connected") - when message_types.rejection - @subscriptions.reject(identifier) - else - @subscriptions.notify(identifier, "received", message) - - open: -> - ActionCable.log("WebSocket onopen event, using '#{@getProtocol()}' subprotocol") - @disconnected = false - if not @isProtocolSupported() - ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting.") - @close(allowReconnect: false) - - close: (event) -> - ActionCable.log("WebSocket onclose event") - return if @disconnected - @disconnected = true - @monitor.recordDisconnect() - @subscriptions.notifyAll("disconnected", {willAttemptReconnect: @monitor.isRunning()}) - - error: -> - ActionCable.log("WebSocket onerror event") diff --git a/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee b/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee deleted file mode 100644 index 0cc675fa94..0000000000 --- a/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee +++ /dev/null @@ -1,95 +0,0 @@ -# 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. -class ActionCable.ConnectionMonitor - @pollInterval: - min: 3 - max: 30 - - @staleThreshold: 6 # Server::Connections::BEAT_INTERVAL * 2 (missed two pings) - - constructor: (@connection) -> - @reconnectAttempts = 0 - - start: -> - unless @isRunning() - @startedAt = now() - delete @stoppedAt - @startPolling() - document.addEventListener("visibilitychange", @visibilityDidChange) - ActionCable.log("ConnectionMonitor started. pollInterval = #{@getPollInterval()} ms") - - stop: -> - if @isRunning() - @stoppedAt = now() - @stopPolling() - document.removeEventListener("visibilitychange", @visibilityDidChange) - ActionCable.log("ConnectionMonitor stopped") - - isRunning: -> - @startedAt? and not @stoppedAt? - - recordPing: -> - @pingedAt = now() - - recordConnect: -> - @reconnectAttempts = 0 - @recordPing() - delete @disconnectedAt - ActionCable.log("ConnectionMonitor recorded connect") - - recordDisconnect: -> - @disconnectedAt = now() - ActionCable.log("ConnectionMonitor recorded disconnect") - - # Private - - startPolling: -> - @stopPolling() - @poll() - - stopPolling: -> - clearTimeout(@pollTimeout) - - poll: -> - @pollTimeout = setTimeout => - @reconnectIfStale() - @poll() - , @getPollInterval() - - getPollInterval: -> - {min, max} = @constructor.pollInterval - interval = 5 * Math.log(@reconnectAttempts + 1) - Math.round(clamp(interval, min, max) * 1000) - - reconnectIfStale: -> - if @connectionIsStale() - ActionCable.log("ConnectionMonitor detected stale connection. reconnectAttempts = #{@reconnectAttempts}, pollInterval = #{@getPollInterval()} ms, time disconnected = #{secondsSince(@disconnectedAt)} s, stale threshold = #{@constructor.staleThreshold} s") - @reconnectAttempts++ - if @disconnectedRecently() - ActionCable.log("ConnectionMonitor skipping reopening recent disconnect") - else - ActionCable.log("ConnectionMonitor reopening") - @connection.reopen() - - connectionIsStale: -> - secondsSince(@pingedAt ? @startedAt) > @constructor.staleThreshold - - disconnectedRecently: -> - @disconnectedAt and secondsSince(@disconnectedAt) < @constructor.staleThreshold - - visibilityDidChange: => - if document.visibilityState is "visible" - setTimeout => - if @connectionIsStale() or not @connection.isOpen() - ActionCable.log("ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = #{document.visibilityState}") - @connection.reopen() - , 200 - - now = -> - new Date().getTime() - - secondsSince = (time) -> - (now() - time) / 1000 - - clamp = (number, min, max) -> - Math.max(min, Math.min(max, number)) diff --git a/actioncable/app/assets/javascripts/action_cable/consumer.coffee b/actioncable/app/assets/javascripts/action_cable/consumer.coffee deleted file mode 100644 index 3298be717f..0000000000 --- a/actioncable/app/assets/javascripts/action_cable/consumer.coffee +++ /dev/null @@ -1,46 +0,0 @@ -#= require ./connection -#= require ./subscriptions -#= require ./subscription - -# 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. -# The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription -# method. -# -# The following example shows how this can be setup: -# -# @App = {} -# App.cable = ActionCable.createConsumer "ws://example.com/accounts/1" -# App.appearance = App.cable.subscriptions.create "AppearanceChannel" -# -# For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. -# -# When a consumer is created, it automatically connects with the server. -# -# To disconnect from the server, call -# -# App.cable.disconnect() -# -# and to restart the connection: -# -# App.cable.connect() -# -# Any channel subscriptions which existed prior to disconnecting will -# automatically resubscribe. -class ActionCable.Consumer - constructor: (@url) -> - @subscriptions = new ActionCable.Subscriptions this - @connection = new ActionCable.Connection this - - send: (data) -> - @connection.send(data) - - connect: -> - @connection.open() - - disconnect: -> - @connection.close(allowReconnect: false) - - ensureActiveConnection: -> - unless @connection.isActive() - @connection.open() diff --git a/actioncable/app/assets/javascripts/action_cable/subscription.coffee b/actioncable/app/assets/javascripts/action_cable/subscription.coffee deleted file mode 100644 index 8e0805a174..0000000000 --- a/actioncable/app/assets/javascripts/action_cable/subscription.coffee +++ /dev/null @@ -1,72 +0,0 @@ -# A new subscription is created through the ActionCable.Subscriptions instance available on the consumer. -# It provides a number of callbacks and a method for calling remote procedure calls on the corresponding -# Channel instance on the server side. -# -# An example demonstrates the basic functionality: -# -# App.appearance = App.cable.subscriptions.create "AppearanceChannel", -# connected: -> -# # Called once the subscription has been successfully completed -# -# disconnected: ({ willAttemptReconnect: boolean }) -> -# # Called when the client has disconnected with the server. -# # The object will have an `willAttemptReconnect` property which -# # says whether the client has the intention of attempting -# # to reconnect. -# -# appear: -> -# @perform 'appear', appearing_on: @appearingOn() -# -# away: -> -# @perform 'away' -# -# appearingOn: -> -# $('main').data 'appearing-on' -# -# The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server -# by calling the `@perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). -# The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. -# -# This is how the server component would look: -# -# class AppearanceChannel < ApplicationActionCable::Channel -# def subscribed -# current_user.appear -# end -# -# def unsubscribed -# current_user.disappear -# end -# -# def appear(data) -# current_user.appear on: data['appearing_on'] -# end -# -# def away -# current_user.away -# end -# end -# -# 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. -class ActionCable.Subscription - constructor: (@consumer, params = {}, mixin) -> - @identifier = JSON.stringify(params) - extend(this, mixin) - - # Perform a channel action with the optional data passed as an attribute - perform: (action, data = {}) -> - data.action = action - @send(data) - - send: (data) -> - @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) - - unsubscribe: -> - @consumer.subscriptions.remove(this) - - extend = (object, properties) -> - if properties? - for key, value of properties - object[key] = value - object diff --git a/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee b/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee deleted file mode 100644 index aa052bf5d8..0000000000 --- a/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee +++ /dev/null @@ -1,66 +0,0 @@ -# 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: -# -# @App = {} -# App.cable = ActionCable.createConsumer "ws://example.com/accounts/1" -# App.appearance = App.cable.subscriptions.create "AppearanceChannel" -# -# For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. -class ActionCable.Subscriptions - constructor: (@consumer) -> - @subscriptions = [] - - create: (channelName, mixin) -> - channel = channelName - params = if typeof channel is "object" then channel else {channel} - subscription = new ActionCable.Subscription @consumer, params, mixin - @add(subscription) - - # Private - - add: (subscription) -> - @subscriptions.push(subscription) - @consumer.ensureActiveConnection() - @notify(subscription, "initialized") - @sendCommand(subscription, "subscribe") - subscription - - remove: (subscription) -> - @forget(subscription) - unless @findAll(subscription.identifier).length - @sendCommand(subscription, "unsubscribe") - subscription - - reject: (identifier) -> - for subscription in @findAll(identifier) - @forget(subscription) - @notify(subscription, "rejected") - subscription - - forget: (subscription) -> - @subscriptions = (s for s in @subscriptions when s isnt subscription) - subscription - - findAll: (identifier) -> - s for s in @subscriptions when s.identifier is identifier - - reload: -> - for subscription in @subscriptions - @sendCommand(subscription, "subscribe") - - notifyAll: (callbackName, args...) -> - for subscription in @subscriptions - @notify(subscription, callbackName, args...) - - notify: (subscription, callbackName, args...) -> - if typeof subscription is "string" - subscriptions = @findAll(subscription) - else - subscriptions = [subscription] - - for subscription in subscriptions - subscription[callbackName]?(args...) - - sendCommand: (subscription, command) -> - {identifier} = subscription - @consumer.send({command, identifier}) diff --git a/actioncable/app/javascript/action_cable/connection.js b/actioncable/app/javascript/action_cable/connection.js new file mode 100644 index 0000000000..7fd68cad2f --- /dev/null +++ b/actioncable/app/javascript/action_cable/connection.js @@ -0,0 +1,116 @@ +#= require ./connection_monitor + +# Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. + +{message_types, protocols} = ActionCable.INTERNAL +[supportedProtocols..., unsupportedProtocol] = protocols + +class ActionCable.Connection + @reopenDelay: 500 + + constructor: (@consumer) -> + {@subscriptions} = @consumer + @monitor = new ActionCable.ConnectionMonitor this + @disconnected = true + + send: (data) -> + if @isOpen() + @webSocket.send(JSON.stringify(data)) + true + else + false + + open: => + if @isActive() + ActionCable.log("Attempted to open WebSocket, but existing socket is #{@getState()}") + false + else + ActionCable.log("Opening WebSocket, current state is #{@getState()}, subprotocols: #{protocols}") + @uninstallEventHandlers() if @webSocket? + @webSocket = new ActionCable.WebSocket(@consumer.url, protocols) + @installEventHandlers() + @monitor.start() + true + + close: ({allowReconnect} = {allowReconnect: true}) -> + @monitor.stop() unless allowReconnect + @webSocket?.close() if @isActive() + + reopen: -> + ActionCable.log("Reopening WebSocket, current state is #{@getState()}") + if @isActive() + try + @close() + catch error + ActionCable.log("Failed to reopen WebSocket", error) + finally + ActionCable.log("Reopening WebSocket in #{@constructor.reopenDelay}ms") + setTimeout(@open, @constructor.reopenDelay) + else + @open() + + getProtocol: -> + @webSocket?.protocol + + isOpen: -> + @isState("open") + + isActive: -> + @isState("open", "connecting") + + # Private + + isProtocolSupported: -> + @getProtocol() in supportedProtocols + + isState: (states...) -> + @getState() in states + + getState: -> + return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState + null + + installEventHandlers: -> + for eventName of @events + handler = @events[eventName].bind(this) + @webSocket["on#{eventName}"] = handler + return + + uninstallEventHandlers: -> + for eventName of @events + @webSocket["on#{eventName}"] = -> + return + + events: + message: (event) -> + return unless @isProtocolSupported() + {identifier, message, type} = JSON.parse(event.data) + switch type + when message_types.welcome + @monitor.recordConnect() + @subscriptions.reload() + when message_types.ping + @monitor.recordPing() + when message_types.confirmation + @subscriptions.notify(identifier, "connected") + when message_types.rejection + @subscriptions.reject(identifier) + else + @subscriptions.notify(identifier, "received", message) + + open: -> + ActionCable.log("WebSocket onopen event, using '#{@getProtocol()}' subprotocol") + @disconnected = false + if not @isProtocolSupported() + ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting.") + @close(allowReconnect: false) + + close: (event) -> + ActionCable.log("WebSocket onclose event") + return if @disconnected + @disconnected = true + @monitor.recordDisconnect() + @subscriptions.notifyAll("disconnected", {willAttemptReconnect: @monitor.isRunning()}) + + error: -> + ActionCable.log("WebSocket onerror event") diff --git a/actioncable/app/javascript/action_cable/connection_monitor.js b/actioncable/app/javascript/action_cable/connection_monitor.js new file mode 100644 index 0000000000..0cc675fa94 --- /dev/null +++ b/actioncable/app/javascript/action_cable/connection_monitor.js @@ -0,0 +1,95 @@ +# 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. +class ActionCable.ConnectionMonitor + @pollInterval: + min: 3 + max: 30 + + @staleThreshold: 6 # Server::Connections::BEAT_INTERVAL * 2 (missed two pings) + + constructor: (@connection) -> + @reconnectAttempts = 0 + + start: -> + unless @isRunning() + @startedAt = now() + delete @stoppedAt + @startPolling() + document.addEventListener("visibilitychange", @visibilityDidChange) + ActionCable.log("ConnectionMonitor started. pollInterval = #{@getPollInterval()} ms") + + stop: -> + if @isRunning() + @stoppedAt = now() + @stopPolling() + document.removeEventListener("visibilitychange", @visibilityDidChange) + ActionCable.log("ConnectionMonitor stopped") + + isRunning: -> + @startedAt? and not @stoppedAt? + + recordPing: -> + @pingedAt = now() + + recordConnect: -> + @reconnectAttempts = 0 + @recordPing() + delete @disconnectedAt + ActionCable.log("ConnectionMonitor recorded connect") + + recordDisconnect: -> + @disconnectedAt = now() + ActionCable.log("ConnectionMonitor recorded disconnect") + + # Private + + startPolling: -> + @stopPolling() + @poll() + + stopPolling: -> + clearTimeout(@pollTimeout) + + poll: -> + @pollTimeout = setTimeout => + @reconnectIfStale() + @poll() + , @getPollInterval() + + getPollInterval: -> + {min, max} = @constructor.pollInterval + interval = 5 * Math.log(@reconnectAttempts + 1) + Math.round(clamp(interval, min, max) * 1000) + + reconnectIfStale: -> + if @connectionIsStale() + ActionCable.log("ConnectionMonitor detected stale connection. reconnectAttempts = #{@reconnectAttempts}, pollInterval = #{@getPollInterval()} ms, time disconnected = #{secondsSince(@disconnectedAt)} s, stale threshold = #{@constructor.staleThreshold} s") + @reconnectAttempts++ + if @disconnectedRecently() + ActionCable.log("ConnectionMonitor skipping reopening recent disconnect") + else + ActionCable.log("ConnectionMonitor reopening") + @connection.reopen() + + connectionIsStale: -> + secondsSince(@pingedAt ? @startedAt) > @constructor.staleThreshold + + disconnectedRecently: -> + @disconnectedAt and secondsSince(@disconnectedAt) < @constructor.staleThreshold + + visibilityDidChange: => + if document.visibilityState is "visible" + setTimeout => + if @connectionIsStale() or not @connection.isOpen() + ActionCable.log("ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = #{document.visibilityState}") + @connection.reopen() + , 200 + + now = -> + new Date().getTime() + + secondsSince = (time) -> + (now() - time) / 1000 + + clamp = (number, min, max) -> + Math.max(min, Math.min(max, number)) diff --git a/actioncable/app/javascript/action_cable/consumer.js b/actioncable/app/javascript/action_cable/consumer.js new file mode 100644 index 0000000000..3298be717f --- /dev/null +++ b/actioncable/app/javascript/action_cable/consumer.js @@ -0,0 +1,46 @@ +#= require ./connection +#= require ./subscriptions +#= require ./subscription + +# 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. +# The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription +# method. +# +# The following example shows how this can be setup: +# +# @App = {} +# App.cable = ActionCable.createConsumer "ws://example.com/accounts/1" +# App.appearance = App.cable.subscriptions.create "AppearanceChannel" +# +# For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. +# +# When a consumer is created, it automatically connects with the server. +# +# To disconnect from the server, call +# +# App.cable.disconnect() +# +# and to restart the connection: +# +# App.cable.connect() +# +# Any channel subscriptions which existed prior to disconnecting will +# automatically resubscribe. +class ActionCable.Consumer + constructor: (@url) -> + @subscriptions = new ActionCable.Subscriptions this + @connection = new ActionCable.Connection this + + send: (data) -> + @connection.send(data) + + connect: -> + @connection.open() + + disconnect: -> + @connection.close(allowReconnect: false) + + ensureActiveConnection: -> + unless @connection.isActive() + @connection.open() diff --git a/actioncable/app/javascript/action_cable/index.js.erb b/actioncable/app/javascript/action_cable/index.js.erb new file mode 100644 index 0000000000..e0758dae72 --- /dev/null +++ b/actioncable/app/javascript/action_cable/index.js.erb @@ -0,0 +1,38 @@ +#= export ActionCable +#= require_self +#= require ./action_cable/consumer + +@ActionCable = + INTERNAL: <%= ActionCable::INTERNAL.to_json %> + WebSocket: window.WebSocket + logger: window.console + + createConsumer: (url) -> + url ?= @getConfig("url") ? @INTERNAL.default_mount_path + new ActionCable.Consumer @createWebSocketURL(url) + + getConfig: (name) -> + element = document.head.querySelector("meta[name='action-cable-#{name}']") + element?.getAttribute("content") + + createWebSocketURL: (url) -> + if url and not /^wss?:/i.test(url) + 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") + a.href + else + url + + startDebugging: -> + @debugging = true + + stopDebugging: -> + @debugging = null + + log: (messages...) -> + if @debugging + messages.push(Date.now()) + @logger.log("[ActionCable]", messages...) diff --git a/actioncable/app/javascript/action_cable/subscription.js b/actioncable/app/javascript/action_cable/subscription.js new file mode 100644 index 0000000000..8e0805a174 --- /dev/null +++ b/actioncable/app/javascript/action_cable/subscription.js @@ -0,0 +1,72 @@ +# A new subscription is created through the ActionCable.Subscriptions instance available on the consumer. +# It provides a number of callbacks and a method for calling remote procedure calls on the corresponding +# Channel instance on the server side. +# +# An example demonstrates the basic functionality: +# +# App.appearance = App.cable.subscriptions.create "AppearanceChannel", +# connected: -> +# # Called once the subscription has been successfully completed +# +# disconnected: ({ willAttemptReconnect: boolean }) -> +# # Called when the client has disconnected with the server. +# # The object will have an `willAttemptReconnect` property which +# # says whether the client has the intention of attempting +# # to reconnect. +# +# appear: -> +# @perform 'appear', appearing_on: @appearingOn() +# +# away: -> +# @perform 'away' +# +# appearingOn: -> +# $('main').data 'appearing-on' +# +# The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server +# by calling the `@perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away). +# The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter. +# +# This is how the server component would look: +# +# class AppearanceChannel < ApplicationActionCable::Channel +# def subscribed +# current_user.appear +# end +# +# def unsubscribed +# current_user.disappear +# end +# +# def appear(data) +# current_user.appear on: data['appearing_on'] +# end +# +# def away +# current_user.away +# end +# end +# +# 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. +class ActionCable.Subscription + constructor: (@consumer, params = {}, mixin) -> + @identifier = JSON.stringify(params) + extend(this, mixin) + + # Perform a channel action with the optional data passed as an attribute + perform: (action, data = {}) -> + data.action = action + @send(data) + + send: (data) -> + @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) + + unsubscribe: -> + @consumer.subscriptions.remove(this) + + extend = (object, properties) -> + if properties? + for key, value of properties + object[key] = value + object diff --git a/actioncable/app/javascript/action_cable/subscriptions.js b/actioncable/app/javascript/action_cable/subscriptions.js new file mode 100644 index 0000000000..aa052bf5d8 --- /dev/null +++ b/actioncable/app/javascript/action_cable/subscriptions.js @@ -0,0 +1,66 @@ +# 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: +# +# @App = {} +# App.cable = ActionCable.createConsumer "ws://example.com/accounts/1" +# App.appearance = App.cable.subscriptions.create "AppearanceChannel" +# +# For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription. +class ActionCable.Subscriptions + constructor: (@consumer) -> + @subscriptions = [] + + create: (channelName, mixin) -> + channel = channelName + params = if typeof channel is "object" then channel else {channel} + subscription = new ActionCable.Subscription @consumer, params, mixin + @add(subscription) + + # Private + + add: (subscription) -> + @subscriptions.push(subscription) + @consumer.ensureActiveConnection() + @notify(subscription, "initialized") + @sendCommand(subscription, "subscribe") + subscription + + remove: (subscription) -> + @forget(subscription) + unless @findAll(subscription.identifier).length + @sendCommand(subscription, "unsubscribe") + subscription + + reject: (identifier) -> + for subscription in @findAll(identifier) + @forget(subscription) + @notify(subscription, "rejected") + subscription + + forget: (subscription) -> + @subscriptions = (s for s in @subscriptions when s isnt subscription) + subscription + + findAll: (identifier) -> + s for s in @subscriptions when s.identifier is identifier + + reload: -> + for subscription in @subscriptions + @sendCommand(subscription, "subscribe") + + notifyAll: (callbackName, args...) -> + for subscription in @subscriptions + @notify(subscription, callbackName, args...) + + notify: (subscription, callbackName, args...) -> + if typeof subscription is "string" + subscriptions = @findAll(subscription) + else + subscriptions = [subscription] + + for subscription in subscriptions + subscription[callbackName]?(args...) + + sendCommand: (subscription, command) -> + {identifier} = subscription + @consumer.send({command, identifier}) -- cgit v1.2.3