diff options
Diffstat (limited to 'actioncable/app/assets')
6 files changed, 170 insertions, 74 deletions
diff --git a/actioncable/app/assets/javascripts/action_cable.coffee.erb b/actioncable/app/assets/javascripts/action_cable.coffee.erb index 18a48c0610..32f9f517f4 100644 --- a/actioncable/app/assets/javascripts/action_cable.coffee.erb +++ b/actioncable/app/assets/javascripts/action_cable.coffee.erb @@ -4,7 +4,8 @@ @ActionCable = INTERNAL: <%= ActionCable::INTERNAL.to_json %> - createConsumer: (url = @getConfig("url")) -> + createConsumer: (url) -> + url ?= @getConfig("url") ? @INTERNAL.default_mount_path new ActionCable.Consumer @createWebSocketURL(url) getConfig: (name) -> @@ -21,3 +22,20 @@ a.href else url + + startDebugging: -> + @debugging = true + + stopDebugging: -> + @debugging = null + + log: (messages...) -> + if @debugging + messages.push(Date.now()) + console.log("[ActionCable]", messages...) + +# NOTE: We expose ActionCable as a browser global so we can reference it +# internally without concern for how the module is loaded. +window?.ActionCable = @ActionCable + +module?.exports = @ActionCable diff --git a/actioncable/app/assets/javascripts/action_cable/connection.coffee b/actioncable/app/assets/javascripts/action_cable/connection.coffee index fbd7dbd35b..d6a6397804 100644 --- a/actioncable/app/assets/javascripts/action_cable/connection.coffee +++ b/actioncable/app/assets/javascripts/action_cable/connection.coffee @@ -1,12 +1,17 @@ +#= require ./connection_monitor + # Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation. -{message_types} = ActionCable.INTERNAL +{message_types, protocols} = ActionCable.INTERNAL +[supportedProtocols..., unsupportedProtocol] = protocols class ActionCable.Connection @reopenDelay: 500 constructor: (@consumer) -> - @open() + {@subscriptions} = @consumer + @monitor = new ActionCable.ConnectionMonitor this + @disconnected = true send: (data) -> if @isOpen() @@ -16,30 +21,48 @@ class ActionCable.Connection false open: => - if @webSocket and not @isState("closed") + if @isActive() + ActionCable.log("Attempted to open WebSocket, but existing socket is #{@getState()}") throw new Error("Existing connection must be closed before opening") else - @webSocket = new WebSocket(@consumer.url) + ActionCable.log("Opening WebSocket, current state is #{@getState()}, subprotocols: #{protocols}") + @uninstallEventHandlers() if @webSocket? + @webSocket = new WebSocket(@consumer.url, protocols) @installEventHandlers() + @monitor.start() true - close: -> - @webSocket?.close() + close: ({allowReconnect} = {allowReconnect: true}) -> + @monitor.stop() unless allowReconnect + @webSocket?.close() if @isActive() reopen: -> - if @isState("closed") - @open() - else + 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 @@ -53,29 +76,41 @@ class ActionCable.Connection @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 - @consumer.subscriptions.notify(identifier, "connected") + @subscriptions.notify(identifier, "connected") when message_types.rejection - @consumer.subscriptions.reject(identifier) + @subscriptions.reject(identifier) else - @consumer.subscriptions.notify(identifier, "received", message) + @subscriptions.notify(identifier, "received", message) open: -> + ActionCable.log("WebSocket onopen event, using '#{@getProtocol()}' subprotocol") @disconnected = false - @consumer.subscriptions.reload() + if not @isProtocolSupported() + ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting.") + @close(allowReconnect: false) - close: -> - @disconnect() + close: (event) -> + ActionCable.log("WebSocket onclose event") + return if @disconnected + @disconnected = true + @monitor.recordDisconnect() + @subscriptions.notifyAll("disconnected", {willAttemptReconnect: @monitor.isRunning()}) error: -> - @disconnect() - - disconnect: -> - return if @disconnected - @disconnected = true - @consumer.subscriptions.notifyAll("disconnected") + 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 index 99b9a1c6d5..0cc675fa94 100644 --- a/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee +++ b/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee @@ -7,54 +7,69 @@ class ActionCable.ConnectionMonitor @staleThreshold: 6 # Server::Connections::BEAT_INTERVAL * 2 (missed two pings) - identifier: ActionCable.INTERNAL.identifiers.ping + constructor: (@connection) -> + @reconnectAttempts = 0 - constructor: (@consumer) -> - @consumer.subscriptions.add(this) - @start() + start: -> + unless @isRunning() + @startedAt = now() + delete @stoppedAt + @startPolling() + document.addEventListener("visibilitychange", @visibilityDidChange) + ActionCable.log("ConnectionMonitor started. pollInterval = #{@getPollInterval()} ms") - connected: -> - @reset() - @pingedAt = now() - delete @disconnectedAt + stop: -> + if @isRunning() + @stoppedAt = now() + @stopPolling() + document.removeEventListener("visibilitychange", @visibilityDidChange) + ActionCable.log("ConnectionMonitor stopped") - disconnected: -> - @disconnectedAt = now() + isRunning: -> + @startedAt? and not @stoppedAt? - received: -> + recordPing: -> @pingedAt = now() - reset: -> + recordConnect: -> @reconnectAttempts = 0 + @recordPing() + delete @disconnectedAt + ActionCable.log("ConnectionMonitor recorded connect") - start: -> - @reset() - delete @stoppedAt - @startedAt = now() + recordDisconnect: -> + @disconnectedAt = now() + ActionCable.log("ConnectionMonitor recorded disconnect") + + # Private + + startPolling: -> + @stopPolling() @poll() - document.addEventListener("visibilitychange", @visibilityDidChange) - stop: -> - @stoppedAt = now() - document.removeEventListener("visibilitychange", @visibilityDidChange) + stopPolling: -> + clearTimeout(@pollTimeout) poll: -> - setTimeout => - unless @stoppedAt - @reconnectIfStale() - @poll() - , @getInterval() + @pollTimeout = setTimeout => + @reconnectIfStale() + @poll() + , @getPollInterval() - getInterval: -> + getPollInterval: -> {min, max} = @constructor.pollInterval interval = 5 * Math.log(@reconnectAttempts + 1) - clamp(interval, min, max) * 1000 + 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++ - unless @disconnectedRecently() - @consumer.connection.reopen() + if @disconnectedRecently() + ActionCable.log("ConnectionMonitor skipping reopening recent disconnect") + else + ActionCable.log("ConnectionMonitor reopening") + @connection.reopen() connectionIsStale: -> secondsSince(@pingedAt ? @startedAt) > @constructor.staleThreshold @@ -65,8 +80,9 @@ class ActionCable.ConnectionMonitor visibilityDidChange: => if document.visibilityState is "visible" setTimeout => - if @connectionIsStale() or not @consumer.connection.isOpen() - @consumer.connection.reopen() + if @connectionIsStale() or not @connection.isOpen() + ActionCable.log("ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = #{document.visibilityState}") + @connection.reopen() , 200 now = -> diff --git a/actioncable/app/assets/javascripts/action_cable/consumer.coffee b/actioncable/app/assets/javascripts/action_cable/consumer.coffee index 717c0641a9..3298be717f 100644 --- a/actioncable/app/assets/javascripts/action_cable/consumer.coffee +++ b/actioncable/app/assets/javascripts/action_cable/consumer.coffee @@ -1,5 +1,4 @@ #= require ./connection -#= require ./connection_monitor #= require ./subscriptions #= require ./subscription @@ -15,11 +14,33 @@ # 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 - @connectionMonitor = new ActionCable.ConnectionMonitor 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 index 339d676933..8e0805a174 100644 --- a/actioncable/app/assets/javascripts/action_cable/subscription.coffee +++ b/actioncable/app/assets/javascripts/action_cable/subscription.coffee @@ -1,5 +1,5 @@ -# 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 +# 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: @@ -7,13 +7,19 @@ # 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' # @@ -27,15 +33,15 @@ # 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 @@ -44,11 +50,9 @@ # 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: (@subscriptions, params = {}, mixin) -> + constructor: (@consumer, params = {}, mixin) -> @identifier = JSON.stringify(params) extend(this, mixin) - @subscriptions.add(this) - @consumer = @subscriptions.consumer # Perform a channel action with the optional data passed as an attribute perform: (action, data = {}) -> @@ -59,7 +63,7 @@ class ActionCable.Subscription @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data)) unsubscribe: -> - @subscriptions.remove(this) + @consumer.subscriptions.remove(this) extend = (object, properties) -> if properties? diff --git a/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee b/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee index ae041ffa2b..aa052bf5d8 100644 --- a/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee +++ b/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee @@ -13,28 +13,33 @@ class ActionCable.Subscriptions create: (channelName, mixin) -> channel = channelName params = if typeof channel is "object" then channel else {channel} - new ActionCable.Subscription this, params, mixin + 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 @@ -58,7 +63,4 @@ class ActionCable.Subscriptions sendCommand: (subscription, command) -> {identifier} = subscription - if identifier is ActionCable.INTERNAL.identifiers.ping - @consumer.connection.isOpen() - else - @consumer.send({command, identifier}) + @consumer.send({command, identifier}) |