actioncable 0.0.0 → 5.0.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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