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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/MIT-LICENSE +20 -0
- data/README.md +439 -21
- data/lib/action_cable.rb +47 -2
- data/lib/action_cable/channel.rb +14 -0
- data/lib/action_cable/channel/base.rb +277 -0
- data/lib/action_cable/channel/broadcasting.rb +29 -0
- data/lib/action_cable/channel/callbacks.rb +35 -0
- data/lib/action_cable/channel/naming.rb +22 -0
- data/lib/action_cable/channel/periodic_timers.rb +41 -0
- data/lib/action_cable/channel/streams.rb +114 -0
- data/lib/action_cable/connection.rb +16 -0
- data/lib/action_cable/connection/authorization.rb +13 -0
- data/lib/action_cable/connection/base.rb +221 -0
- data/lib/action_cable/connection/identification.rb +46 -0
- data/lib/action_cable/connection/internal_channel.rb +45 -0
- data/lib/action_cable/connection/message_buffer.rb +54 -0
- data/lib/action_cable/connection/subscriptions.rb +76 -0
- data/lib/action_cable/connection/tagged_logger_proxy.rb +40 -0
- data/lib/action_cable/connection/web_socket.rb +29 -0
- data/lib/action_cable/engine.rb +38 -0
- data/lib/action_cable/gem_version.rb +15 -0
- data/lib/action_cable/helpers/action_cable_helper.rb +29 -0
- data/lib/action_cable/process/logging.rb +10 -0
- data/lib/action_cable/remote_connections.rb +64 -0
- data/lib/action_cable/server.rb +19 -0
- data/lib/action_cable/server/base.rb +77 -0
- data/lib/action_cable/server/broadcasting.rb +54 -0
- data/lib/action_cable/server/configuration.rb +35 -0
- data/lib/action_cable/server/connections.rb +37 -0
- data/lib/action_cable/server/worker.rb +42 -0
- data/lib/action_cable/server/worker/active_record_connection_management.rb +22 -0
- data/lib/action_cable/version.rb +6 -1
- data/lib/assets/javascripts/action_cable.coffee.erb +23 -0
- data/lib/assets/javascripts/action_cable/connection.coffee +84 -0
- data/lib/assets/javascripts/action_cable/connection_monitor.coffee +84 -0
- data/lib/assets/javascripts/action_cable/consumer.coffee +31 -0
- data/lib/assets/javascripts/action_cable/subscription.coffee +68 -0
- data/lib/assets/javascripts/action_cable/subscriptions.coffee +78 -0
- data/lib/rails/generators/channel/USAGE +14 -0
- data/lib/rails/generators/channel/channel_generator.rb +21 -0
- data/lib/rails/generators/channel/templates/assets/channel.coffee +14 -0
- data/lib/rails/generators/channel/templates/channel.rb +17 -0
- metadata +161 -26
- data/.gitignore +0 -9
- data/Gemfile +0 -4
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -2
- data/actioncable.gemspec +0 -22
- data/bin/console +0 -14
- 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
|
data/lib/action_cable/version.rb
CHANGED
@@ -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
|