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.erb23
-rw-r--r--actioncable/app/assets/javascripts/action_cable/connection.coffee81
-rw-r--r--actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee79
-rw-r--r--actioncable/app/assets/javascripts/action_cable/consumer.coffee25
-rw-r--r--actioncable/app/assets/javascripts/action_cable/subscription.coffee68
-rw-r--r--actioncable/app/assets/javascripts/action_cable/subscriptions.coffee64
6 files changed, 340 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..18a48c0610
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable.coffee.erb
@@ -0,0 +1,23 @@
+#= require_self
+#= require ./action_cable/consumer
+
+@ActionCable =
+ INTERNAL: <%= ActionCable::INTERNAL.to_json %>
+
+ createConsumer: (url = @getConfig("url")) ->
+ 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
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..fbd7dbd35b
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/connection.coffee
@@ -0,0 +1,81 @@
+# Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation.
+
+{message_types} = ActionCable.INTERNAL
+
+class ActionCable.Connection
+ @reopenDelay: 500
+
+ constructor: (@consumer) ->
+ @open()
+
+ send: (data) ->
+ if @isOpen()
+ @webSocket.send(JSON.stringify(data))
+ true
+ else
+ false
+
+ open: =>
+ if @webSocket and not @isState("closed")
+ throw new Error("Existing connection must be closed before opening")
+ else
+ @webSocket = new WebSocket(@consumer.url)
+ @installEventHandlers()
+ true
+
+ close: ->
+ @webSocket?.close()
+
+ reopen: ->
+ if @isState("closed")
+ @open()
+ else
+ try
+ @close()
+ finally
+ setTimeout(@open, @constructor.reopenDelay)
+
+ isOpen: ->
+ @isState("open")
+
+ # Private
+
+ 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
+
+ events:
+ message: (event) ->
+ {identifier, message, type} = JSON.parse(event.data)
+
+ switch type
+ when message_types.confirmation
+ @consumer.subscriptions.notify(identifier, "connected")
+ when message_types.rejection
+ @consumer.subscriptions.reject(identifier)
+ else
+ @consumer.subscriptions.notify(identifier, "received", message)
+
+ open: ->
+ @disconnected = false
+ @consumer.subscriptions.reload()
+
+ close: ->
+ @disconnect()
+
+ error: ->
+ @disconnect()
+
+ disconnect: ->
+ return if @disconnected
+ @disconnected = true
+ @consumer.subscriptions.notifyAll("disconnected")
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..99b9a1c6d5
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/connection_monitor.coffee
@@ -0,0 +1,79 @@
+# 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)
+
+ identifier: ActionCable.INTERNAL.identifiers.ping
+
+ constructor: (@consumer) ->
+ @consumer.subscriptions.add(this)
+ @start()
+
+ connected: ->
+ @reset()
+ @pingedAt = now()
+ delete @disconnectedAt
+
+ disconnected: ->
+ @disconnectedAt = now()
+
+ received: ->
+ @pingedAt = now()
+
+ reset: ->
+ @reconnectAttempts = 0
+
+ start: ->
+ @reset()
+ delete @stoppedAt
+ @startedAt = now()
+ @poll()
+ document.addEventListener("visibilitychange", @visibilityDidChange)
+
+ stop: ->
+ @stoppedAt = now()
+ document.removeEventListener("visibilitychange", @visibilityDidChange)
+
+ poll: ->
+ setTimeout =>
+ unless @stoppedAt
+ @reconnectIfStale()
+ @poll()
+ , @getInterval()
+
+ getInterval: ->
+ {min, max} = @constructor.pollInterval
+ interval = 5 * Math.log(@reconnectAttempts + 1)
+ clamp(interval, min, max) * 1000
+
+ reconnectIfStale: ->
+ if @connectionIsStale()
+ @reconnectAttempts++
+ unless @disconnectedRecently()
+ @consumer.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 @consumer.connection.isOpen()
+ @consumer.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..717c0641a9
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/consumer.coffee
@@ -0,0 +1,25 @@
+#= require ./connection
+#= require ./connection_monitor
+#= 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.
+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)
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..339d676933
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/subscription.coffee
@@ -0,0 +1,68 @@
+# 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
+#
+# 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: (@subscriptions, 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 = {}) ->
+ data.action = action
+ @send(data)
+
+ send: (data) ->
+ @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data))
+
+ unsubscribe: ->
+ @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..ae041ffa2b
--- /dev/null
+++ b/actioncable/app/assets/javascripts/action_cable/subscriptions.coffee
@@ -0,0 +1,64 @@
+# 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}
+ new ActionCable.Subscription this, params, mixin
+
+ # Private
+
+ add: (subscription) ->
+ @subscriptions.push(subscription)
+ @notify(subscription, "initialized")
+ @sendCommand(subscription, "subscribe")
+
+ remove: (subscription) ->
+ @forget(subscription)
+
+ unless @findAll(subscription.identifier).length
+ @sendCommand(subscription, "unsubscribe")
+
+ reject: (identifier) ->
+ for subscription in @findAll(identifier)
+ @forget(subscription)
+ @notify(subscription, "rejected")
+
+ forget: (subscription) ->
+ @subscriptions = (s for s in @subscriptions when s isnt 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
+ if identifier is ActionCable.INTERNAL.identifiers.ping
+ @consumer.connection.isOpen()
+ else
+ @consumer.send({command, identifier})