aboutsummaryrefslogtreecommitdiffstats
path: root/actioncable/app/assets
diff options
context:
space:
mode:
Diffstat (limited to 'actioncable/app/assets')
-rw-r--r--actioncable/app/assets/javascripts/action_cable.coffee.erb38
-rw-r--r--actioncable/app/assets/javascripts/action_cable/connection.coffee116
-rw-r--r--actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee95
-rw-r--r--actioncable/app/assets/javascripts/action_cable/consumer.coffee46
-rw-r--r--actioncable/app/assets/javascripts/action_cable/subscription.coffee72
-rw-r--r--actioncable/app/assets/javascripts/action_cable/subscriptions.coffee66
6 files changed, 433 insertions, 0 deletions
diff --git a/actioncable/app/assets/javascripts/action_cable.coffee.erb b/actioncable/app/assets/javascripts/action_cable.coffee.erb
new file mode 100644
index 0000000000..e0758dae72
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable.coffee.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/assets/javascripts/action_cable/connection.coffee b/actioncable/app/assets/javascripts/action_cable/connection.coffee
new file mode 100644
index 0000000000..7fd68cad2f
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/connection.coffee
@@ -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/assets/javascripts/action_cable/connection_monitor.coffee b/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee
new file mode 100644
index 0000000000..0cc675fa94
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee
@@ -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/assets/javascripts/action_cable/consumer.coffee b/actioncable/app/assets/javascripts/action_cable/consumer.coffee
new file mode 100644
index 0000000000..3298be717f
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/consumer.coffee
@@ -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/assets/javascripts/action_cable/subscription.coffee b/actioncable/app/assets/javascripts/action_cable/subscription.coffee
new file mode 100644
index 0000000000..8e0805a174
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/subscription.coffee
@@ -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/assets/javascripts/action_cable/subscriptions.coffee b/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee
new file mode 100644
index 0000000000..aa052bf5d8
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee
@@ -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})