omg-actioncable 8.0.0.alpha2
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/MIT-LICENSE +20 -0
- data/README.md +24 -0
- data/app/assets/javascripts/action_cable.js +511 -0
- data/app/assets/javascripts/actioncable.esm.js +512 -0
- data/app/assets/javascripts/actioncable.js +510 -0
- data/lib/action_cable/channel/base.rb +335 -0
- data/lib/action_cable/channel/broadcasting.rb +50 -0
- data/lib/action_cable/channel/callbacks.rb +76 -0
- data/lib/action_cable/channel/naming.rb +28 -0
- data/lib/action_cable/channel/periodic_timers.rb +78 -0
- data/lib/action_cable/channel/streams.rb +215 -0
- data/lib/action_cable/channel/test_case.rb +356 -0
- data/lib/action_cable/connection/authorization.rb +18 -0
- data/lib/action_cable/connection/base.rb +294 -0
- data/lib/action_cable/connection/callbacks.rb +57 -0
- data/lib/action_cable/connection/client_socket.rb +159 -0
- data/lib/action_cable/connection/identification.rb +51 -0
- data/lib/action_cable/connection/internal_channel.rb +50 -0
- data/lib/action_cable/connection/message_buffer.rb +57 -0
- data/lib/action_cable/connection/stream.rb +117 -0
- data/lib/action_cable/connection/stream_event_loop.rb +136 -0
- data/lib/action_cable/connection/subscriptions.rb +85 -0
- data/lib/action_cable/connection/tagged_logger_proxy.rb +47 -0
- data/lib/action_cable/connection/test_case.rb +246 -0
- data/lib/action_cable/connection/web_socket.rb +45 -0
- data/lib/action_cable/deprecator.rb +9 -0
- data/lib/action_cable/engine.rb +98 -0
- data/lib/action_cable/gem_version.rb +19 -0
- data/lib/action_cable/helpers/action_cable_helper.rb +45 -0
- data/lib/action_cable/remote_connections.rb +82 -0
- data/lib/action_cable/server/base.rb +109 -0
- data/lib/action_cable/server/broadcasting.rb +62 -0
- data/lib/action_cable/server/configuration.rb +70 -0
- data/lib/action_cable/server/connections.rb +44 -0
- data/lib/action_cable/server/worker/active_record_connection_management.rb +23 -0
- data/lib/action_cable/server/worker.rb +75 -0
- data/lib/action_cable/subscription_adapter/async.rb +29 -0
- data/lib/action_cable/subscription_adapter/base.rb +36 -0
- data/lib/action_cable/subscription_adapter/channel_prefix.rb +30 -0
- data/lib/action_cable/subscription_adapter/inline.rb +39 -0
- data/lib/action_cable/subscription_adapter/postgresql.rb +134 -0
- data/lib/action_cable/subscription_adapter/redis.rb +256 -0
- data/lib/action_cable/subscription_adapter/subscriber_map.rb +61 -0
- data/lib/action_cable/subscription_adapter/test.rb +41 -0
- data/lib/action_cable/test_case.rb +13 -0
- data/lib/action_cable/test_helper.rb +163 -0
- data/lib/action_cable/version.rb +12 -0
- data/lib/action_cable.rb +80 -0
- data/lib/rails/generators/channel/USAGE +19 -0
- data/lib/rails/generators/channel/channel_generator.rb +127 -0
- data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
- data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
- data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
- data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
- data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
- data/lib/rails/generators/channel/templates/javascript/index.js.tt +1 -0
- data/lib/rails/generators/test_unit/channel_generator.rb +22 -0
- data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
- metadata +181 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :markup: markdown
|
|
4
|
+
|
|
5
|
+
module ActionCable
|
|
6
|
+
module Connection
|
|
7
|
+
#--
|
|
8
|
+
# This class is heavily based on faye-websocket-ruby
|
|
9
|
+
#
|
|
10
|
+
# Copyright (c) 2010-2015 James Coglan
|
|
11
|
+
class Stream # :nodoc:
|
|
12
|
+
def initialize(event_loop, socket)
|
|
13
|
+
@event_loop = event_loop
|
|
14
|
+
@socket_object = socket
|
|
15
|
+
@stream_send = socket.env["stream.send"]
|
|
16
|
+
|
|
17
|
+
@rack_hijack_io = nil
|
|
18
|
+
@write_lock = Mutex.new
|
|
19
|
+
|
|
20
|
+
@write_head = nil
|
|
21
|
+
@write_buffer = Queue.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def each(&callback)
|
|
25
|
+
@stream_send ||= callback
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def close
|
|
29
|
+
shutdown
|
|
30
|
+
@socket_object.client_gone
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def shutdown
|
|
34
|
+
clean_rack_hijack
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write(data)
|
|
38
|
+
if @stream_send
|
|
39
|
+
return @stream_send.call(data)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if @write_lock.try_lock
|
|
43
|
+
begin
|
|
44
|
+
if @write_head.nil? && @write_buffer.empty?
|
|
45
|
+
written = @rack_hijack_io.write_nonblock(data, exception: false)
|
|
46
|
+
|
|
47
|
+
case written
|
|
48
|
+
when :wait_writable
|
|
49
|
+
# proceed below
|
|
50
|
+
when data.bytesize
|
|
51
|
+
return data.bytesize
|
|
52
|
+
else
|
|
53
|
+
@write_head = data.byteslice(written, data.bytesize)
|
|
54
|
+
@event_loop.writes_pending @rack_hijack_io
|
|
55
|
+
|
|
56
|
+
return data.bytesize
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
ensure
|
|
60
|
+
@write_lock.unlock
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@write_buffer << data
|
|
65
|
+
@event_loop.writes_pending @rack_hijack_io
|
|
66
|
+
|
|
67
|
+
data.bytesize
|
|
68
|
+
rescue EOFError, Errno::ECONNRESET
|
|
69
|
+
@socket_object.client_gone
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def flush_write_buffer
|
|
73
|
+
@write_lock.synchronize do
|
|
74
|
+
loop do
|
|
75
|
+
if @write_head.nil?
|
|
76
|
+
return true if @write_buffer.empty?
|
|
77
|
+
@write_head = @write_buffer.pop
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
written = @rack_hijack_io.write_nonblock(@write_head, exception: false)
|
|
81
|
+
case written
|
|
82
|
+
when :wait_writable
|
|
83
|
+
return false
|
|
84
|
+
when @write_head.bytesize
|
|
85
|
+
@write_head = nil
|
|
86
|
+
else
|
|
87
|
+
@write_head = @write_head.byteslice(written, @write_head.bytesize)
|
|
88
|
+
return false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def receive(data)
|
|
95
|
+
@socket_object.parse(data)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def hijack_rack_socket
|
|
99
|
+
return unless @socket_object.env["rack.hijack"]
|
|
100
|
+
|
|
101
|
+
# This should return the underlying io according to the SPEC:
|
|
102
|
+
@rack_hijack_io = @socket_object.env["rack.hijack"].call
|
|
103
|
+
# Retain existing behavior if required:
|
|
104
|
+
@rack_hijack_io ||= @socket_object.env["rack.hijack_io"]
|
|
105
|
+
|
|
106
|
+
@event_loop.attach(@rack_hijack_io, self)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
def clean_rack_hijack
|
|
111
|
+
return unless @rack_hijack_io
|
|
112
|
+
@event_loop.detach(@rack_hijack_io, self)
|
|
113
|
+
@rack_hijack_io = nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :markup: markdown
|
|
4
|
+
|
|
5
|
+
require "nio"
|
|
6
|
+
|
|
7
|
+
module ActionCable
|
|
8
|
+
module Connection
|
|
9
|
+
class StreamEventLoop
|
|
10
|
+
def initialize
|
|
11
|
+
@nio = @executor = @thread = nil
|
|
12
|
+
@map = {}
|
|
13
|
+
@stopping = false
|
|
14
|
+
@todo = Queue.new
|
|
15
|
+
|
|
16
|
+
@spawn_mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def timer(interval, &block)
|
|
20
|
+
Concurrent::TimerTask.new(execution_interval: interval, &block).tap(&:execute)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def post(task = nil, &block)
|
|
24
|
+
task ||= block
|
|
25
|
+
|
|
26
|
+
spawn
|
|
27
|
+
@executor << task
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def attach(io, stream)
|
|
31
|
+
@todo << lambda do
|
|
32
|
+
@map[io] = @nio.register(io, :r)
|
|
33
|
+
@map[io].value = stream
|
|
34
|
+
end
|
|
35
|
+
wakeup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def detach(io, stream)
|
|
39
|
+
@todo << lambda do
|
|
40
|
+
@nio.deregister io
|
|
41
|
+
@map.delete io
|
|
42
|
+
io.close
|
|
43
|
+
end
|
|
44
|
+
wakeup
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def writes_pending(io)
|
|
48
|
+
@todo << lambda do
|
|
49
|
+
if monitor = @map[io]
|
|
50
|
+
monitor.interests = :rw
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
wakeup
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stop
|
|
57
|
+
@stopping = true
|
|
58
|
+
wakeup if @nio
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
def spawn
|
|
63
|
+
return if @thread && @thread.status
|
|
64
|
+
|
|
65
|
+
@spawn_mutex.synchronize do
|
|
66
|
+
return if @thread && @thread.status
|
|
67
|
+
|
|
68
|
+
@nio ||= NIO::Selector.new
|
|
69
|
+
|
|
70
|
+
@executor ||= Concurrent::ThreadPoolExecutor.new(
|
|
71
|
+
min_threads: 1,
|
|
72
|
+
max_threads: 10,
|
|
73
|
+
max_queue: 0,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@thread = Thread.new { run }
|
|
77
|
+
|
|
78
|
+
return true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def wakeup
|
|
83
|
+
spawn || @nio.wakeup
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def run
|
|
87
|
+
loop do
|
|
88
|
+
if @stopping
|
|
89
|
+
@nio.close
|
|
90
|
+
break
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
until @todo.empty?
|
|
94
|
+
@todo.pop(true).call
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
next unless monitors = @nio.select
|
|
98
|
+
|
|
99
|
+
monitors.each do |monitor|
|
|
100
|
+
io = monitor.io
|
|
101
|
+
stream = monitor.value
|
|
102
|
+
|
|
103
|
+
begin
|
|
104
|
+
if monitor.writable?
|
|
105
|
+
if stream.flush_write_buffer
|
|
106
|
+
monitor.interests = :r
|
|
107
|
+
end
|
|
108
|
+
next unless monitor.readable?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
incoming = io.read_nonblock(4096, exception: false)
|
|
112
|
+
case incoming
|
|
113
|
+
when :wait_readable
|
|
114
|
+
next
|
|
115
|
+
when nil
|
|
116
|
+
stream.close
|
|
117
|
+
else
|
|
118
|
+
stream.receive incoming
|
|
119
|
+
end
|
|
120
|
+
rescue
|
|
121
|
+
# We expect one of EOFError or Errno::ECONNRESET in normal operation (when the
|
|
122
|
+
# client goes away). But if anything else goes wrong, this is still the best way
|
|
123
|
+
# to handle it.
|
|
124
|
+
begin
|
|
125
|
+
stream.close
|
|
126
|
+
rescue
|
|
127
|
+
@nio.deregister io
|
|
128
|
+
@map.delete io
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :markup: markdown
|
|
4
|
+
|
|
5
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
6
|
+
|
|
7
|
+
module ActionCable
|
|
8
|
+
module Connection
|
|
9
|
+
# # Action Cable Connection Subscriptions
|
|
10
|
+
#
|
|
11
|
+
# Collection class for all the channel subscriptions established on a given
|
|
12
|
+
# connection. Responsible for routing incoming commands that arrive on the
|
|
13
|
+
# connection to the proper channel.
|
|
14
|
+
class Subscriptions # :nodoc:
|
|
15
|
+
def initialize(connection)
|
|
16
|
+
@connection = connection
|
|
17
|
+
@subscriptions = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def execute_command(data)
|
|
21
|
+
case data["command"]
|
|
22
|
+
when "subscribe" then add data
|
|
23
|
+
when "unsubscribe" then remove data
|
|
24
|
+
when "message" then perform_action data
|
|
25
|
+
else
|
|
26
|
+
logger.error "Received unrecognized command in #{data.inspect}"
|
|
27
|
+
end
|
|
28
|
+
rescue Exception => e
|
|
29
|
+
@connection.rescue_with_handler(e)
|
|
30
|
+
logger.error "Could not execute command from (#{data.inspect}) [#{e.class} - #{e.message}]: #{e.backtrace.first(5).join(" | ")}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add(data)
|
|
34
|
+
id_key = data["identifier"]
|
|
35
|
+
id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access
|
|
36
|
+
|
|
37
|
+
return if subscriptions.key?(id_key)
|
|
38
|
+
|
|
39
|
+
subscription_klass = id_options[:channel].safe_constantize
|
|
40
|
+
|
|
41
|
+
if subscription_klass && ActionCable::Channel::Base > subscription_klass
|
|
42
|
+
subscription = subscription_klass.new(connection, id_key, id_options)
|
|
43
|
+
subscriptions[id_key] = subscription
|
|
44
|
+
subscription.subscribe_to_channel
|
|
45
|
+
else
|
|
46
|
+
logger.error "Subscription class not found: #{id_options[:channel].inspect}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def remove(data)
|
|
51
|
+
logger.info "Unsubscribing from channel: #{data['identifier']}"
|
|
52
|
+
remove_subscription find(data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def remove_subscription(subscription)
|
|
56
|
+
subscription.unsubscribe_from_channel
|
|
57
|
+
subscriptions.delete(subscription.identifier)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def perform_action(data)
|
|
61
|
+
find(data).perform_action ActiveSupport::JSON.decode(data["data"])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def identifiers
|
|
65
|
+
subscriptions.keys
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unsubscribe_from_all
|
|
69
|
+
subscriptions.each { |id, channel| remove_subscription(channel) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
attr_reader :connection, :subscriptions
|
|
74
|
+
delegate :logger, to: :connection
|
|
75
|
+
|
|
76
|
+
def find(data)
|
|
77
|
+
if subscription = subscriptions[data["identifier"]]
|
|
78
|
+
subscription
|
|
79
|
+
else
|
|
80
|
+
raise "Unable to find subscription with identifier: #{data['identifier']}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :markup: markdown
|
|
4
|
+
|
|
5
|
+
module ActionCable
|
|
6
|
+
module Connection
|
|
7
|
+
# # Action Cable Connection TaggedLoggerProxy
|
|
8
|
+
#
|
|
9
|
+
# Allows the use of per-connection tags against the server logger. This wouldn't
|
|
10
|
+
# work using the traditional ActiveSupport::TaggedLogging enhanced Rails.logger,
|
|
11
|
+
# as that logger will reset the tags between requests. The connection is
|
|
12
|
+
# long-lived, so it needs its own set of tags for its independent duration.
|
|
13
|
+
class TaggedLoggerProxy
|
|
14
|
+
attr_reader :tags
|
|
15
|
+
|
|
16
|
+
def initialize(logger, tags:)
|
|
17
|
+
@logger = logger
|
|
18
|
+
@tags = tags.flatten
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add_tags(*tags)
|
|
22
|
+
@tags += tags.flatten
|
|
23
|
+
@tags = @tags.uniq
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tag(logger, &block)
|
|
27
|
+
if logger.respond_to?(:tagged)
|
|
28
|
+
current_tags = tags - logger.formatter.current_tags
|
|
29
|
+
logger.tagged(*current_tags, &block)
|
|
30
|
+
else
|
|
31
|
+
yield
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
%i( debug info warn error fatal unknown ).each do |severity|
|
|
36
|
+
define_method(severity) do |message = nil, &block|
|
|
37
|
+
log severity, message, &block
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
def log(type, message, &block) # :doc:
|
|
43
|
+
tag(@logger) { @logger.send type, message, &block }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :markup: markdown
|
|
4
|
+
|
|
5
|
+
require "active_support"
|
|
6
|
+
require "active_support/test_case"
|
|
7
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
8
|
+
require "action_dispatch"
|
|
9
|
+
require "action_dispatch/http/headers"
|
|
10
|
+
require "action_dispatch/testing/test_request"
|
|
11
|
+
|
|
12
|
+
module ActionCable
|
|
13
|
+
module Connection
|
|
14
|
+
class NonInferrableConnectionError < ::StandardError
|
|
15
|
+
def initialize(name)
|
|
16
|
+
super "Unable to determine the connection to test from #{name}. " +
|
|
17
|
+
"You'll need to specify it using `tests YourConnection` in your " +
|
|
18
|
+
"test case definition."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Assertions
|
|
23
|
+
# Asserts that the connection is rejected (via
|
|
24
|
+
# `reject_unauthorized_connection`).
|
|
25
|
+
#
|
|
26
|
+
# # Asserts that connection without user_id fails
|
|
27
|
+
# assert_reject_connection { connect params: { user_id: '' } }
|
|
28
|
+
def assert_reject_connection(&block)
|
|
29
|
+
assert_raises(Authorization::UnauthorizedError, "Expected to reject connection but no rejection was made", &block)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class TestCookies < ActiveSupport::HashWithIndifferentAccess # :nodoc:
|
|
34
|
+
def []=(name, options)
|
|
35
|
+
value = options.is_a?(Hash) ? options.symbolize_keys[:value] : options
|
|
36
|
+
super(name, value)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# We don't want to use the whole "encryption stack" for connection unit-tests,
|
|
41
|
+
# but we want to make sure that users test against the correct types of cookies
|
|
42
|
+
# (i.e. signed or encrypted or plain)
|
|
43
|
+
class TestCookieJar < TestCookies
|
|
44
|
+
def signed
|
|
45
|
+
@signed ||= TestCookies.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def encrypted
|
|
49
|
+
@encrypted ||= TestCookies.new
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class TestRequest < ActionDispatch::TestRequest
|
|
54
|
+
attr_accessor :session, :cookie_jar
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
module TestConnection
|
|
58
|
+
attr_reader :logger, :request
|
|
59
|
+
|
|
60
|
+
def initialize(request)
|
|
61
|
+
inner_logger = ActiveSupport::Logger.new(StringIO.new)
|
|
62
|
+
tagged_logging = ActiveSupport::TaggedLogging.new(inner_logger)
|
|
63
|
+
@logger = ActionCable::Connection::TaggedLoggerProxy.new(tagged_logging, tags: [])
|
|
64
|
+
@request = request
|
|
65
|
+
@env = request.env
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# # Action Cable Connection TestCase
|
|
70
|
+
#
|
|
71
|
+
# Unit test Action Cable connections.
|
|
72
|
+
#
|
|
73
|
+
# Useful to check whether a connection's `identified_by` gets assigned properly
|
|
74
|
+
# and that any improper connection requests are rejected.
|
|
75
|
+
#
|
|
76
|
+
# ## Basic example
|
|
77
|
+
#
|
|
78
|
+
# Unit tests are written as follows:
|
|
79
|
+
#
|
|
80
|
+
# 1. Simulate a connection attempt by calling `connect`.
|
|
81
|
+
# 2. Assert state, e.g. identifiers, has been assigned.
|
|
82
|
+
#
|
|
83
|
+
#
|
|
84
|
+
# class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
|
|
85
|
+
# def test_connects_with_proper_cookie
|
|
86
|
+
# # Simulate the connection request with a cookie.
|
|
87
|
+
# cookies["user_id"] = users(:john).id
|
|
88
|
+
#
|
|
89
|
+
# connect
|
|
90
|
+
#
|
|
91
|
+
# # Assert the connection identifier matches the fixture.
|
|
92
|
+
# assert_equal users(:john).id, connection.user.id
|
|
93
|
+
# end
|
|
94
|
+
#
|
|
95
|
+
# def test_rejects_connection_without_proper_cookie
|
|
96
|
+
# assert_reject_connection { connect }
|
|
97
|
+
# end
|
|
98
|
+
# end
|
|
99
|
+
#
|
|
100
|
+
# `connect` accepts additional information about the HTTP request with the
|
|
101
|
+
# `params`, `headers`, `session`, and Rack `env` options.
|
|
102
|
+
#
|
|
103
|
+
# def test_connect_with_headers_and_query_string
|
|
104
|
+
# connect params: { user_id: 1 }, headers: { "X-API-TOKEN" => "secret-my" }
|
|
105
|
+
#
|
|
106
|
+
# assert_equal "1", connection.user.id
|
|
107
|
+
# assert_equal "secret-my", connection.token
|
|
108
|
+
# end
|
|
109
|
+
#
|
|
110
|
+
# def test_connect_with_params
|
|
111
|
+
# connect params: { user_id: 1 }
|
|
112
|
+
#
|
|
113
|
+
# assert_equal "1", connection.user.id
|
|
114
|
+
# end
|
|
115
|
+
#
|
|
116
|
+
# You can also set up the correct cookies before the connection request:
|
|
117
|
+
#
|
|
118
|
+
# def test_connect_with_cookies
|
|
119
|
+
# # Plain cookies:
|
|
120
|
+
# cookies["user_id"] = 1
|
|
121
|
+
#
|
|
122
|
+
# # Or signed/encrypted:
|
|
123
|
+
# # cookies.signed["user_id"] = 1
|
|
124
|
+
# # cookies.encrypted["user_id"] = 1
|
|
125
|
+
#
|
|
126
|
+
# connect
|
|
127
|
+
#
|
|
128
|
+
# assert_equal "1", connection.user_id
|
|
129
|
+
# end
|
|
130
|
+
#
|
|
131
|
+
# ## Connection is automatically inferred
|
|
132
|
+
#
|
|
133
|
+
# ActionCable::Connection::TestCase will automatically infer the connection
|
|
134
|
+
# under test from the test class name. If the channel cannot be inferred from
|
|
135
|
+
# the test class name, you can explicitly set it with `tests`.
|
|
136
|
+
#
|
|
137
|
+
# class ConnectionTest < ActionCable::Connection::TestCase
|
|
138
|
+
# tests ApplicationCable::Connection
|
|
139
|
+
# end
|
|
140
|
+
#
|
|
141
|
+
class TestCase < ActiveSupport::TestCase
|
|
142
|
+
module Behavior
|
|
143
|
+
extend ActiveSupport::Concern
|
|
144
|
+
|
|
145
|
+
DEFAULT_PATH = "/cable"
|
|
146
|
+
|
|
147
|
+
include ActiveSupport::Testing::ConstantLookup
|
|
148
|
+
include Assertions
|
|
149
|
+
|
|
150
|
+
included do
|
|
151
|
+
class_attribute :_connection_class
|
|
152
|
+
|
|
153
|
+
attr_reader :connection
|
|
154
|
+
|
|
155
|
+
ActiveSupport.run_load_hooks(:action_cable_connection_test_case, self)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
module ClassMethods
|
|
159
|
+
def tests(connection)
|
|
160
|
+
case connection
|
|
161
|
+
when String, Symbol
|
|
162
|
+
self._connection_class = connection.to_s.camelize.constantize
|
|
163
|
+
when Module
|
|
164
|
+
self._connection_class = connection
|
|
165
|
+
else
|
|
166
|
+
raise NonInferrableConnectionError.new(connection)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def connection_class
|
|
171
|
+
if connection = self._connection_class
|
|
172
|
+
connection
|
|
173
|
+
else
|
|
174
|
+
tests determine_default_connection(name)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def determine_default_connection(name)
|
|
179
|
+
connection = determine_constant_from_test_name(name) do |constant|
|
|
180
|
+
Class === constant && constant < ActionCable::Connection::Base
|
|
181
|
+
end
|
|
182
|
+
raise NonInferrableConnectionError.new(name) if connection.nil?
|
|
183
|
+
connection
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Performs connection attempt to exert #connect on the connection under test.
|
|
188
|
+
#
|
|
189
|
+
# Accepts request path as the first argument and the following request options:
|
|
190
|
+
#
|
|
191
|
+
# * params – URL parameters (Hash)
|
|
192
|
+
# * headers – request headers (Hash)
|
|
193
|
+
# * session – session data (Hash)
|
|
194
|
+
# * env – additional Rack env configuration (Hash)
|
|
195
|
+
def connect(path = ActionCable.server.config.mount_path, **request_params)
|
|
196
|
+
path ||= DEFAULT_PATH
|
|
197
|
+
|
|
198
|
+
connection = self.class.connection_class.allocate
|
|
199
|
+
connection.singleton_class.include(TestConnection)
|
|
200
|
+
connection.send(:initialize, build_test_request(path, **request_params))
|
|
201
|
+
connection.connect if connection.respond_to?(:connect)
|
|
202
|
+
|
|
203
|
+
# Only set instance variable if connected successfully
|
|
204
|
+
@connection = connection
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Exert #disconnect on the connection under test.
|
|
208
|
+
def disconnect
|
|
209
|
+
raise "Must be connected!" if connection.nil?
|
|
210
|
+
|
|
211
|
+
connection.disconnect if connection.respond_to?(:disconnect)
|
|
212
|
+
@connection = nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def cookies
|
|
216
|
+
@cookie_jar ||= TestCookieJar.new
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private
|
|
220
|
+
def build_test_request(path, params: nil, headers: {}, session: {}, env: {})
|
|
221
|
+
wrapped_headers = ActionDispatch::Http::Headers.from_hash(headers)
|
|
222
|
+
|
|
223
|
+
uri = URI.parse(path)
|
|
224
|
+
|
|
225
|
+
query_string = params.nil? ? uri.query : params.to_query
|
|
226
|
+
|
|
227
|
+
request_env = {
|
|
228
|
+
"QUERY_STRING" => query_string,
|
|
229
|
+
"PATH_INFO" => uri.path
|
|
230
|
+
}.merge(env)
|
|
231
|
+
|
|
232
|
+
if wrapped_headers.present?
|
|
233
|
+
ActionDispatch::Http::Headers.from_hash(request_env).merge!(wrapped_headers)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
TestRequest.create(request_env).tap do |request|
|
|
237
|
+
request.session = session.with_indifferent_access
|
|
238
|
+
request.cookie_jar = cookies
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
include Behavior
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# :markup: markdown
|
|
4
|
+
|
|
5
|
+
require "websocket/driver"
|
|
6
|
+
|
|
7
|
+
module ActionCable
|
|
8
|
+
module Connection
|
|
9
|
+
# # Action Cable Connection WebSocket
|
|
10
|
+
#
|
|
11
|
+
# Wrap the real socket to minimize the externally-presented API
|
|
12
|
+
class WebSocket # :nodoc:
|
|
13
|
+
def initialize(env, event_target, event_loop, protocols: ActionCable::INTERNAL[:protocols])
|
|
14
|
+
@websocket = ::WebSocket::Driver.websocket?(env) ? ClientSocket.new(env, event_target, event_loop, protocols) : nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def possible?
|
|
18
|
+
websocket
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def alive?
|
|
22
|
+
websocket&.alive?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def transmit(...)
|
|
26
|
+
websocket&.transmit(...)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def close(...)
|
|
30
|
+
websocket&.close(...)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def protocol
|
|
34
|
+
websocket&.protocol
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rack_response
|
|
38
|
+
websocket&.rack_response
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
attr_reader :websocket
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|