actioncable 0.0.0 → 5.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +439 -21
  5. data/lib/action_cable.rb +47 -2
  6. data/lib/action_cable/channel.rb +14 -0
  7. data/lib/action_cable/channel/base.rb +277 -0
  8. data/lib/action_cable/channel/broadcasting.rb +29 -0
  9. data/lib/action_cable/channel/callbacks.rb +35 -0
  10. data/lib/action_cable/channel/naming.rb +22 -0
  11. data/lib/action_cable/channel/periodic_timers.rb +41 -0
  12. data/lib/action_cable/channel/streams.rb +114 -0
  13. data/lib/action_cable/connection.rb +16 -0
  14. data/lib/action_cable/connection/authorization.rb +13 -0
  15. data/lib/action_cable/connection/base.rb +221 -0
  16. data/lib/action_cable/connection/identification.rb +46 -0
  17. data/lib/action_cable/connection/internal_channel.rb +45 -0
  18. data/lib/action_cable/connection/message_buffer.rb +54 -0
  19. data/lib/action_cable/connection/subscriptions.rb +76 -0
  20. data/lib/action_cable/connection/tagged_logger_proxy.rb +40 -0
  21. data/lib/action_cable/connection/web_socket.rb +29 -0
  22. data/lib/action_cable/engine.rb +38 -0
  23. data/lib/action_cable/gem_version.rb +15 -0
  24. data/lib/action_cable/helpers/action_cable_helper.rb +29 -0
  25. data/lib/action_cable/process/logging.rb +10 -0
  26. data/lib/action_cable/remote_connections.rb +64 -0
  27. data/lib/action_cable/server.rb +19 -0
  28. data/lib/action_cable/server/base.rb +77 -0
  29. data/lib/action_cable/server/broadcasting.rb +54 -0
  30. data/lib/action_cable/server/configuration.rb +35 -0
  31. data/lib/action_cable/server/connections.rb +37 -0
  32. data/lib/action_cable/server/worker.rb +42 -0
  33. data/lib/action_cable/server/worker/active_record_connection_management.rb +22 -0
  34. data/lib/action_cable/version.rb +6 -1
  35. data/lib/assets/javascripts/action_cable.coffee.erb +23 -0
  36. data/lib/assets/javascripts/action_cable/connection.coffee +84 -0
  37. data/lib/assets/javascripts/action_cable/connection_monitor.coffee +84 -0
  38. data/lib/assets/javascripts/action_cable/consumer.coffee +31 -0
  39. data/lib/assets/javascripts/action_cable/subscription.coffee +68 -0
  40. data/lib/assets/javascripts/action_cable/subscriptions.coffee +78 -0
  41. data/lib/rails/generators/channel/USAGE +14 -0
  42. data/lib/rails/generators/channel/channel_generator.rb +21 -0
  43. data/lib/rails/generators/channel/templates/assets/channel.coffee +14 -0
  44. data/lib/rails/generators/channel/templates/channel.rb +17 -0
  45. metadata +161 -26
  46. data/.gitignore +0 -9
  47. data/Gemfile +0 -4
  48. data/LICENSE.txt +0 -21
  49. data/Rakefile +0 -2
  50. data/actioncable.gemspec +0 -22
  51. data/bin/console +0 -14
  52. data/bin/setup +0 -7
@@ -0,0 +1,54 @@
1
+ require 'redis'
2
+
3
+ module ActionCable
4
+ module Server
5
+ # Broadcasting is how other parts of your application can send messages to the channel subscribers. As explained in Channel, most of the time, these
6
+ # broadcastings are streamed directly to the clients subscribed to the named broadcasting. Let's explain with a full-stack example:
7
+ #
8
+ # class WebNotificationsChannel < ApplicationCable::Channel
9
+ # def subscribed
10
+ # stream_from "web_notifications_#{current_user.id}"
11
+ # end
12
+ # end
13
+ #
14
+ # # Somewhere in your app this is called, perhaps from a NewCommentJob
15
+ # ActionCable.server.broadcast \
16
+ # "web_notifications_1", { title: 'New things!', body: 'All shit fit for print' }
17
+ #
18
+ # # Client-side coffescript, which assumes you've already requested the right to send web notifications
19
+ # App.cable.subscriptions.create "WebNotificationsChannel",
20
+ # received: (data) ->
21
+ # new Notification data['title'], body: data['body']
22
+ module Broadcasting
23
+ # Broadcast a hash directly to a named <tt>broadcasting</tt>. It'll automatically be JSON encoded.
24
+ def broadcast(broadcasting, message)
25
+ broadcaster_for(broadcasting).broadcast(message)
26
+ end
27
+
28
+ # Returns a broadcaster for a named <tt>broadcasting</tt> that can be reused. Useful when you have a object that
29
+ # may need multiple spots to transmit to a specific broadcasting over and over.
30
+ def broadcaster_for(broadcasting)
31
+ Broadcaster.new(self, broadcasting)
32
+ end
33
+
34
+ # The redis instance used for broadcasting. Not intended for direct user use.
35
+ def broadcasting_redis
36
+ @broadcasting_redis ||= Redis.new(config.redis)
37
+ end
38
+
39
+ private
40
+ class Broadcaster
41
+ attr_reader :server, :broadcasting
42
+
43
+ def initialize(server, broadcasting)
44
+ @server, @broadcasting = server, broadcasting
45
+ end
46
+
47
+ def broadcast(message)
48
+ server.logger.info "[ActionCable] Broadcasting to #{broadcasting}: #{message}"
49
+ server.broadcasting_redis.publish broadcasting, ActiveSupport::JSON.encode(message)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,35 @@
1
+ module ActionCable
2
+ module Server
3
+ # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak the configuration points
4
+ # in a Rails config initializer.
5
+ class Configuration
6
+ attr_accessor :logger, :log_tags
7
+ attr_accessor :connection_class, :worker_pool_size
8
+ attr_accessor :redis, :channels_path
9
+ attr_accessor :disable_request_forgery_protection, :allowed_request_origins
10
+ attr_accessor :url
11
+
12
+ def initialize
13
+ @log_tags = []
14
+
15
+ @connection_class = ApplicationCable::Connection
16
+ @worker_pool_size = 100
17
+
18
+ @channels_path = Rails.root.join('app/channels')
19
+
20
+ @disable_request_forgery_protection = false
21
+ end
22
+
23
+ def channel_paths
24
+ @channels ||= Dir["#{channels_path}/**/*_channel.rb"]
25
+ end
26
+
27
+ def channel_class_names
28
+ @channel_class_names ||= channel_paths.collect do |channel_path|
29
+ Pathname.new(channel_path).basename.to_s.split('.').first.camelize
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
@@ -0,0 +1,37 @@
1
+ module ActionCable
2
+ module Server
3
+ # Collection class for all the connections that's been established on this specific server. Remember, usually you'll run many cable servers, so
4
+ # you can't use this collection as an full list of all the connections established against your application. Use RemoteConnections for that.
5
+ # As such, this is primarily for internal use.
6
+ module Connections
7
+ BEAT_INTERVAL = 3
8
+
9
+ def connections
10
+ @connections ||= []
11
+ end
12
+
13
+ def add_connection(connection)
14
+ connections << connection
15
+ end
16
+
17
+ def remove_connection(connection)
18
+ connections.delete connection
19
+ end
20
+
21
+ # WebSocket connection implementations differ on when they'll mark a connection as stale. We basically never want a connection to go stale, as you
22
+ # then can't rely on being able to receive and send to it. So there's a 3 second heartbeat running on all connections. If the beat fails, we automatically
23
+ # disconnect.
24
+ def setup_heartbeat_timer
25
+ EM.next_tick do
26
+ @heartbeat_timer ||= EventMachine.add_periodic_timer(BEAT_INTERVAL) do
27
+ EM.next_tick { connections.map(&:beat) }
28
+ end
29
+ end
30
+ end
31
+
32
+ def open_connections_statistics
33
+ connections.map(&:statistics)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ require 'celluloid'
2
+ require 'active_support/callbacks'
3
+
4
+ module ActionCable
5
+ module Server
6
+ # Worker used by Server.send_async to do connection work in threads. Only for internal use.
7
+ class Worker
8
+ include ActiveSupport::Callbacks
9
+ include Celluloid
10
+
11
+ attr_reader :connection
12
+ define_callbacks :work
13
+ include ActiveRecordConnectionManagement
14
+
15
+ def invoke(receiver, method, *args)
16
+ @connection = receiver
17
+
18
+ run_callbacks :work do
19
+ receiver.send method, *args
20
+ end
21
+ rescue Exception => e
22
+ logger.error "There was an exception - #{e.class}(#{e.message})"
23
+ logger.error e.backtrace.join("\n")
24
+
25
+ receiver.handle_exception if receiver.respond_to?(:handle_exception)
26
+ end
27
+
28
+ def run_periodic_timer(channel, callback)
29
+ @connection = channel.connection
30
+
31
+ run_callbacks :work do
32
+ callback.respond_to?(:call) ? channel.instance_exec(&callback) : channel.send(callback)
33
+ end
34
+ end
35
+
36
+ private
37
+ def logger
38
+ ActionCable.server.logger
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,22 @@
1
+ module ActionCable
2
+ module Server
3
+ class Worker
4
+ # Clear active connections between units of work so the long-running channel or connection processes do not hoard connections.
5
+ module ActiveRecordConnectionManagement
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ if defined?(ActiveRecord::Base)
10
+ set_callback :work, :around, :with_database_connections
11
+ end
12
+ end
13
+
14
+ def with_database_connections
15
+ connection.logger.tag(ActiveRecord::Base.logger) { yield }
16
+ ensure
17
+ ActiveRecord::Base.clear_active_connections!
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,3 +1,8 @@
1
+ require_relative 'gem_version'
2
+
1
3
  module ActionCable
2
- VERSION = "0.0.0"
4
+ # Returns the version of the currently loaded Action Cable as a <tt>Gem::Version</tt>
5
+ def self.version
6
+ gem_version
7
+ end
3
8
  end
@@ -0,0 +1,23 @@
1
+ #= require_self
2
+ #= require action_cable/consumer
3
+
4
+ @ActionCable =
5
+ INTERNAL: <%= ActionCable::INTERNAL.to_json %>
6
+
7
+ createConsumer: (url = @getConfig("url")) ->
8
+ new ActionCable.Consumer @createWebSocketURL(url)
9
+
10
+ getConfig: (name) ->
11
+ element = document.head.querySelector("meta[name='action-cable-#{name}']")
12
+ element?.getAttribute("content")
13
+
14
+ createWebSocketURL: (url) ->
15
+ if url and not /^wss?:/i.test(url)
16
+ a = document.createElement("a")
17
+ a.href = url
18
+ # Fix populating Location properties in IE. Otherwise, protocol will be blank.
19
+ a.href = a.href
20
+ a.protocol = a.protocol.replace("http", "ws")
21
+ a.href
22
+ else
23
+ url
@@ -0,0 +1,84 @@
1
+ # Encapsulate the cable connection held by the consumer. This is an internal class not intended for direct user manipulation.
2
+
3
+ {message_types} = ActionCable.INTERNAL
4
+
5
+ class ActionCable.Connection
6
+ @reopenDelay: 500
7
+
8
+ constructor: (@consumer) ->
9
+ @open()
10
+
11
+ send: (data) ->
12
+ if @isOpen()
13
+ @webSocket.send(JSON.stringify(data))
14
+ true
15
+ else
16
+ false
17
+
18
+ open: =>
19
+ if @webSocket and not @isState("closed")
20
+ throw new Error("Existing connection must be closed before opening")
21
+ else
22
+ @webSocket = new WebSocket(@consumer.url)
23
+ @installEventHandlers()
24
+ true
25
+
26
+ close: ->
27
+ @webSocket?.close()
28
+
29
+ reopen: ->
30
+ if @isState("closed")
31
+ @open()
32
+ else
33
+ try
34
+ @close()
35
+ finally
36
+ setTimeout(@open, @constructor.reopenDelay)
37
+
38
+ isOpen: ->
39
+ @isState("open")
40
+
41
+ # Private
42
+
43
+ isState: (states...) ->
44
+ @getState() in states
45
+
46
+ getState: ->
47
+ return state.toLowerCase() for state, value of WebSocket when value is @webSocket?.readyState
48
+ null
49
+
50
+ installEventHandlers: ->
51
+ for eventName of @events
52
+ handler = @events[eventName].bind(this)
53
+ @webSocket["on#{eventName}"] = handler
54
+ return
55
+
56
+ events:
57
+ message: (event) ->
58
+ {identifier, message, type} = JSON.parse(event.data)
59
+
60
+ switch type
61
+ when message_types.confirmation
62
+ @consumer.subscriptions.notify(identifier, "connected")
63
+ when message_types.rejection
64
+ @consumer.subscriptions.reject(identifier)
65
+ else
66
+ @consumer.subscriptions.notify(identifier, "received", message)
67
+
68
+ open: ->
69
+ @disconnected = false
70
+ @consumer.subscriptions.reload()
71
+
72
+ close: ->
73
+ @disconnect()
74
+
75
+ error: ->
76
+ @disconnect()
77
+
78
+ disconnect: ->
79
+ return if @disconnected
80
+ @disconnected = true
81
+ @consumer.subscriptions.notifyAll("disconnected")
82
+
83
+ toJSON: ->
84
+ state: @getState()
@@ -0,0 +1,84 @@
1
+ # Responsible for ensuring the cable connection is in good health by validating the heartbeat pings sent from the server, and attempting
2
+ # revival reconnections if things go astray. Internal class, not intended for direct user manipulation.
3
+ class ActionCable.ConnectionMonitor
4
+ @pollInterval:
5
+ min: 3
6
+ max: 30
7
+
8
+ @staleThreshold: 6 # Server::Connections::BEAT_INTERVAL * 2 (missed two pings)
9
+
10
+ identifier: ActionCable.INTERNAL.identifiers.ping
11
+
12
+ constructor: (@consumer) ->
13
+ @consumer.subscriptions.add(this)
14
+ @start()
15
+
16
+ connected: ->
17
+ @reset()
18
+ @pingedAt = now()
19
+ delete @disconnectedAt
20
+
21
+ disconnected: ->
22
+ @disconnectedAt = now()
23
+
24
+ received: ->
25
+ @pingedAt = now()
26
+
27
+ reset: ->
28
+ @reconnectAttempts = 0
29
+
30
+ start: ->
31
+ @reset()
32
+ delete @stoppedAt
33
+ @startedAt = now()
34
+ @poll()
35
+ document.addEventListener("visibilitychange", @visibilityDidChange)
36
+
37
+ stop: ->
38
+ @stoppedAt = now()
39
+ document.removeEventListener("visibilitychange", @visibilityDidChange)
40
+
41
+ poll: ->
42
+ setTimeout =>
43
+ unless @stoppedAt
44
+ @reconnectIfStale()
45
+ @poll()
46
+ , @getInterval()
47
+
48
+ getInterval: ->
49
+ {min, max} = @constructor.pollInterval
50
+ interval = 5 * Math.log(@reconnectAttempts + 1)
51
+ clamp(interval, min, max) * 1000
52
+
53
+ reconnectIfStale: ->
54
+ if @connectionIsStale()
55
+ @reconnectAttempts++
56
+ unless @disconnectedRecently()
57
+ @consumer.connection.reopen()
58
+
59
+ connectionIsStale: ->
60
+ secondsSince(@pingedAt ? @startedAt) > @constructor.staleThreshold
61
+
62
+ disconnectedRecently: ->
63
+ @disconnectedAt and secondsSince(@disconnectedAt) < @constructor.staleThreshold
64
+
65
+ visibilityDidChange: =>
66
+ if document.visibilityState is "visible"
67
+ setTimeout =>
68
+ if @connectionIsStale() or not @consumer.connection.isOpen()
69
+ @consumer.connection.reopen()
70
+ , 200
71
+
72
+ toJSON: ->
73
+ interval = @getInterval()
74
+ connectionIsStale = @connectionIsStale()
75
+ {@startedAt, @stoppedAt, @pingedAt, @reconnectAttempts, connectionIsStale, interval}
76
+
77
+ now = ->
78
+ new Date().getTime()
79
+
80
+ secondsSince = (time) ->
81
+ (now() - time) / 1000
82
+
83
+ clamp = (number, min, max) ->
84
+ Math.max(min, Math.min(max, number))
@@ -0,0 +1,31 @@
1
+ #= require action_cable/connection
2
+ #= require action_cable/connection_monitor
3
+ #= require action_cable/subscriptions
4
+ #= require action_cable/subscription
5
+
6
+ # The ActionCable.Consumer establishes the connection to a server-side Ruby Connection object. Once established,
7
+ # the ActionCable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates.
8
+ # The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription
9
+ # method.
10
+ #
11
+ # The following example shows how this can be setup:
12
+ #
13
+ # @App = {}
14
+ # App.cable = ActionCable.createConsumer "ws://example.com/accounts/1"
15
+ # App.appearance = App.cable.subscriptions.create "AppearanceChannel"
16
+ #
17
+ # For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription.
18
+ class ActionCable.Consumer
19
+ constructor: (@url) ->
20
+ @subscriptions = new ActionCable.Subscriptions this
21
+ @connection = new ActionCable.Connection this
22
+ @connectionMonitor = new ActionCable.ConnectionMonitor this
23
+
24
+ send: (data) ->
25
+ @connection.send(data)
26
+
27
+ inspect: ->
28
+ JSON.stringify(this, null, 2)
29
+
30
+ toJSON: ->
31
+ {@url, @subscriptions, @connection, @connectionMonitor}
@@ -0,0 +1,68 @@
1
+ # A new subscription is created through the ActionCable.Subscriptions instance available on the consumer.
2
+ # It provides a number of callbacks and a method for calling remote procedure calls on the corresponding
3
+ # Channel instance on the server side.
4
+ #
5
+ # An example demonstrates the basic functionality:
6
+ #
7
+ # App.appearance = App.cable.subscriptions.create "AppearanceChannel",
8
+ # connected: ->
9
+ # # Called once the subscription has been successfully completed
10
+ #
11
+ # appear: ->
12
+ # @perform 'appear', appearing_on: @appearingOn()
13
+ #
14
+ # away: ->
15
+ # @perform 'away'
16
+ #
17
+ # appearingOn: ->
18
+ # $('main').data 'appearing-on'
19
+ #
20
+ # The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server
21
+ # by calling the `@perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away).
22
+ # The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter.
23
+ #
24
+ # This is how the server component would look:
25
+ #
26
+ # class AppearanceChannel < ApplicationActionCable::Channel
27
+ # def subscribed
28
+ # current_user.appear
29
+ # end
30
+ #
31
+ # def unsubscribed
32
+ # current_user.disappear
33
+ # end
34
+ #
35
+ # def appear(data)
36
+ # current_user.appear on: data['appearing_on']
37
+ # end
38
+ #
39
+ # def away
40
+ # current_user.away
41
+ # end
42
+ # end
43
+ #
44
+ # The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name.
45
+ # The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the @perform method.
46
+ class ActionCable.Subscription
47
+ constructor: (@subscriptions, params = {}, mixin) ->
48
+ @identifier = JSON.stringify(params)
49
+ extend(this, mixin)
50
+ @subscriptions.add(this)
51
+ @consumer = @subscriptions.consumer
52
+
53
+ # Perform a channel action with the optional data passed as an attribute
54
+ perform: (action, data = {}) ->
55
+ data.action = action
56
+ @send(data)
57
+
58
+ send: (data) ->
59
+ @consumer.send(command: "message", identifier: @identifier, data: JSON.stringify(data))
60
+
61
+ unsubscribe: ->
62
+ @subscriptions.remove(this)
63
+
64
+ extend = (object, properties) ->
65
+ if properties?
66
+ for key, value of properties
67
+ object[key] = value
68
+ object