anycable-rack-server 0.0.1
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/LICENSE +21 -0
- data/README.md +37 -0
- data/lib/anycable-rack-server.rb +3 -0
- data/lib/anycable/rack-server.rb +107 -0
- data/lib/anycable/rack-server/broadcast_subscribers/redis_subscriber.rb +40 -0
- data/lib/anycable/rack-server/coders/json.rb +19 -0
- data/lib/anycable/rack-server/connection.rb +189 -0
- data/lib/anycable/rack-server/errors.rb +11 -0
- data/lib/anycable/rack-server/hub.rb +83 -0
- data/lib/anycable/rack-server/logging.rb +15 -0
- data/lib/anycable/rack-server/middleware.rb +82 -0
- data/lib/anycable/rack-server/pinger.rb +53 -0
- data/lib/anycable/rack-server/rpc/client.rb +42 -0
- data/lib/anycable/rack-server/rpc/rpc.proto +55 -0
- data/lib/anycable/rack-server/socket.rb +159 -0
- data/lib/anycable/rack-server/version.rb +7 -0
- metadata +143 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 416a34b5853c2b0f81f4693e59fe4c9b10021b41fc8eb2e710b8fd82b5a67c04
|
4
|
+
data.tar.gz: fcb0f8b569adc737b704025393237b573b7de616ea4e5deedaa77e442a51bc09
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 7cd8bcd7b5afda208b3df2e476b0729faf9fbfbfd9d6fb36735f26cba48b9589e3a4704a9dbc69c6743bdf4f209c2ee9e6d44f8521a5fc89036a42859f0f9090
|
7
|
+
data.tar.gz: 68f4b98948bb3ec8fb330947faed0f933ee7dd12dab2f1ca6abbb35af41ab3f7b7f3ad720ea74be82af486f309b250c340603e8e10716478a03975704c3ba3f5
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2018 Yulia Oletskaya
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# anycable-rack-server
|
2
|
+
|
3
|
+
AnyCable-compatible Rack hijack based Ruby Web Socket server middleware designed for development and testing purposes.
|
4
|
+
|
5
|
+
## Usage
|
6
|
+
|
7
|
+
Mount the rack middleware
|
8
|
+
```ruby
|
9
|
+
# config/routes.rb
|
10
|
+
Rails.application.routes.draw do
|
11
|
+
mount AnyCable::Rack.new => '/cable'
|
12
|
+
end
|
13
|
+
```
|
14
|
+
|
15
|
+
## Settings
|
16
|
+
|
17
|
+
Customizable options: headers being sent with each gRPC request. The gem uses AnyCable config for redis and gRPC host settings.
|
18
|
+
|
19
|
+
Default headers: `'cookie', 'x-api-token'`.
|
20
|
+
|
21
|
+
Can be specified via env variable
|
22
|
+
```
|
23
|
+
ANYCABLE_HEADERS=cookie,x-api-token,origin
|
24
|
+
```
|
25
|
+
|
26
|
+
Or
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
options = { headers: ['cookie', 'origin'] }
|
30
|
+
AnyCable::Rack.new(nil, options)
|
31
|
+
```
|
32
|
+
|
33
|
+
## Testing
|
34
|
+
|
35
|
+
Run units with `rake`.
|
36
|
+
|
37
|
+
Instructions for testing with [anyt](https://github.com/anycable/anyt) (anycable conformance testing) can be found [here](https://github.com/tuwukee/anycable-rack-server/tree/master/test/support).
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
require 'json'
|
5
|
+
require 'anycable'
|
6
|
+
require 'websocket'
|
7
|
+
require 'securerandom'
|
8
|
+
require 'anycable/rack-server/hub'
|
9
|
+
require 'anycable/rack-server/pinger'
|
10
|
+
require 'anycable/rack-server/errors'
|
11
|
+
require 'anycable/rack-server/middleware'
|
12
|
+
require 'anycable/rack-server/broadcast_subscribers/redis_subscriber'
|
13
|
+
require 'anycable/rack-server/coders/json'
|
14
|
+
|
15
|
+
module AnyCable
|
16
|
+
module RackServer
|
17
|
+
DEFAULT_OPTIONS = {
|
18
|
+
headers: ['cookie', 'x-api-token']
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
class << self
|
22
|
+
attr_reader :broadcast_subscriber,
|
23
|
+
:coder,
|
24
|
+
:hub,
|
25
|
+
:middleware,
|
26
|
+
:pinger,
|
27
|
+
:server_id
|
28
|
+
|
29
|
+
def start(options = {})
|
30
|
+
options = DEFAULT_OPTIONS.merge(options)
|
31
|
+
@hub = Hub.new
|
32
|
+
@pinger = Pinger.new
|
33
|
+
@coder = Coders::JSON
|
34
|
+
|
35
|
+
rpc_host = unpack_host(AnyCable.config.rpc_host)
|
36
|
+
headers = parse_env_headers || options[:headers]
|
37
|
+
|
38
|
+
@server_id = "anycable-rack-server-#{SecureRandom.hex}"
|
39
|
+
@middleware = Middleware.new(
|
40
|
+
nil,
|
41
|
+
pinger: pinger,
|
42
|
+
hub: hub,
|
43
|
+
coder: coder,
|
44
|
+
rpc_host: rpc_host,
|
45
|
+
headers: headers,
|
46
|
+
server_id: server_id
|
47
|
+
)
|
48
|
+
|
49
|
+
broadcast_subscribe
|
50
|
+
|
51
|
+
@_started = true
|
52
|
+
end
|
53
|
+
|
54
|
+
def started?
|
55
|
+
@_started == true
|
56
|
+
end
|
57
|
+
|
58
|
+
def stop
|
59
|
+
return unless started?
|
60
|
+
|
61
|
+
@_started = false
|
62
|
+
broadcast_subscriber.unsubscribe(@_redis_channel)
|
63
|
+
pinger.stop
|
64
|
+
|
65
|
+
hub.sockets.each do |socket|
|
66
|
+
hub.remove_socket(socket)
|
67
|
+
socket.close
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def broadcast_subscribe
|
74
|
+
@_redis_params = AnyCable.config.to_redis_params
|
75
|
+
@_redis_channel = AnyCable.config.redis_channel
|
76
|
+
|
77
|
+
@broadcast_subscriber = BroadcastSubscribers::RedisSubscriber.new(
|
78
|
+
hub: @hub,
|
79
|
+
coder: @coder,
|
80
|
+
options: @_redis_params
|
81
|
+
)
|
82
|
+
|
83
|
+
@broadcast_subscriber.subscribe(@_redis_channel)
|
84
|
+
end
|
85
|
+
|
86
|
+
def parse_env_headers
|
87
|
+
headers = ENV['ANYCABLE_HEADERS'].to_s.split(',')
|
88
|
+
return nil if headers.empty?
|
89
|
+
headers
|
90
|
+
end
|
91
|
+
|
92
|
+
def unpack_host(str)
|
93
|
+
str.gsub('[::]', '0.0.0.0')
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
class Rack
|
99
|
+
def initialize(_app = nil, options = {})
|
100
|
+
AnyCable::RackServer.start(options)
|
101
|
+
end
|
102
|
+
|
103
|
+
def call(env)
|
104
|
+
AnyCable::RackServer.middleware.call(env)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'redis'
|
4
|
+
|
5
|
+
module AnyCable
|
6
|
+
module RackServer
|
7
|
+
module BroadcastSubscribers
|
8
|
+
class RedisSubscriber
|
9
|
+
attr_reader :hub, :coder, :redis_conn, :threads
|
10
|
+
|
11
|
+
def initialize(hub:, coder:, options:)
|
12
|
+
@hub = hub
|
13
|
+
@coder = coder
|
14
|
+
@redis_conn = ::Redis.new(options)
|
15
|
+
@threads = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
def subscribe(channel)
|
19
|
+
@threads[channel] = Thread.new do
|
20
|
+
redis_conn.subscribe(channel) do |on|
|
21
|
+
on.message { |_channel, msg| handle_message(msg) }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def unsubscribe(channel)
|
27
|
+
@threads[channel].terminate unless @threads[channel].nil?
|
28
|
+
@threads.delete(channel)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def handle_message(msg)
|
34
|
+
data = JSON.parse(msg)
|
35
|
+
hub.broadcast(data['stream'], data['data'], coder)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AnyCable
|
4
|
+
module RackServer
|
5
|
+
module Coders
|
6
|
+
module JSON
|
7
|
+
class << self
|
8
|
+
def decode(json_str)
|
9
|
+
::JSON.parse(json_str)
|
10
|
+
end
|
11
|
+
|
12
|
+
def encode(ruby_obj)
|
13
|
+
ruby_obj.to_json
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'anycable/rack-server/rpc/client'
|
4
|
+
require 'anycable/rack-server/logging'
|
5
|
+
require 'anycable/rack-server/errors'
|
6
|
+
|
7
|
+
module AnyCable
|
8
|
+
# rubocop:disable Metrics/LineLength
|
9
|
+
module RackServer
|
10
|
+
class Connection
|
11
|
+
# rubocop:enable Metrics/LineLength
|
12
|
+
include Logging
|
13
|
+
|
14
|
+
attr_reader :coder,
|
15
|
+
:header_names,
|
16
|
+
:hub,
|
17
|
+
:socket,
|
18
|
+
:rpc_client,
|
19
|
+
:server_id
|
20
|
+
|
21
|
+
def initialize(socket, hub, coder, host, header_names, server_id)
|
22
|
+
@socket = socket
|
23
|
+
@coder = coder
|
24
|
+
@hub = hub
|
25
|
+
@header_names = header_names
|
26
|
+
@server_id = server_id
|
27
|
+
|
28
|
+
@rpc_client = RPC::Client.new(host)
|
29
|
+
|
30
|
+
@_identifiers = '{}'
|
31
|
+
@_subscriptions = Set.new
|
32
|
+
end
|
33
|
+
|
34
|
+
def handle_open
|
35
|
+
response = rpc_connect
|
36
|
+
process_open(response)
|
37
|
+
end
|
38
|
+
|
39
|
+
def handle_close
|
40
|
+
response = rpc_disconnect
|
41
|
+
process_close(response)
|
42
|
+
reset_connection
|
43
|
+
end
|
44
|
+
|
45
|
+
def handle_command(websocket_message)
|
46
|
+
decoded = decode(websocket_message)
|
47
|
+
command = decoded.delete('command')
|
48
|
+
|
49
|
+
channel_identifier = decoded['identifier']
|
50
|
+
|
51
|
+
case command
|
52
|
+
when 'subscribe' then subscribe(channel_identifier)
|
53
|
+
when 'unsubscribe' then unsubscribe(channel_identifier)
|
54
|
+
when 'message' then send_message(channel_identifier, decoded['data'])
|
55
|
+
else
|
56
|
+
raise Errors::UnknownCommand, "Command not found #{command}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def transmit(cable_message)
|
63
|
+
socket.transmit(encode(cable_message))
|
64
|
+
end
|
65
|
+
|
66
|
+
def close
|
67
|
+
socket.close
|
68
|
+
end
|
69
|
+
|
70
|
+
def request
|
71
|
+
socket.request
|
72
|
+
end
|
73
|
+
|
74
|
+
def request_path
|
75
|
+
request.fullpath
|
76
|
+
end
|
77
|
+
|
78
|
+
def rpc_connect
|
79
|
+
rpc_client.connect(headers: headers, path: request_path)
|
80
|
+
end
|
81
|
+
|
82
|
+
def rpc_disconnect
|
83
|
+
rpc_client.disconnect(
|
84
|
+
identifiers: @_identifiers,
|
85
|
+
subscriptions: @_subscriptions.to_a,
|
86
|
+
headers: headers,
|
87
|
+
path: request_path
|
88
|
+
)
|
89
|
+
end
|
90
|
+
|
91
|
+
def rpc_command(command, identifier, data = '')
|
92
|
+
rpc_client.command(
|
93
|
+
command: command,
|
94
|
+
identifier: identifier,
|
95
|
+
connection_identifiers: @_identifiers,
|
96
|
+
data: data
|
97
|
+
)
|
98
|
+
end
|
99
|
+
|
100
|
+
def subscribe(identifier)
|
101
|
+
response = rpc_command('subscribe', identifier)
|
102
|
+
if response.status == :SUCCESS
|
103
|
+
@_subscriptions.add(identifier)
|
104
|
+
else
|
105
|
+
log(:debug, log_fmt("RPC subscribe command failed: #{response.inspect}"))
|
106
|
+
end
|
107
|
+
process_command(response, identifier)
|
108
|
+
end
|
109
|
+
|
110
|
+
def unsubscribe(identifier)
|
111
|
+
response = rpc_command('unsubscribe', identifier)
|
112
|
+
if response.status == :SUCCESS
|
113
|
+
@_subscriptions.delete(identifier)
|
114
|
+
else
|
115
|
+
log(:debug, log_fmt("RPC unsubscribe command failed: #{response.inspect}"))
|
116
|
+
end
|
117
|
+
process_command(response, identifier)
|
118
|
+
end
|
119
|
+
|
120
|
+
def send_message(identifier, data)
|
121
|
+
response = rpc_command('message', identifier, data)
|
122
|
+
unless response.status == :SUCCESS
|
123
|
+
log(:debug, log_fmt("RPC message command failed: #{response.inspect}"))
|
124
|
+
end
|
125
|
+
process_command(response, identifier)
|
126
|
+
end
|
127
|
+
|
128
|
+
def headers
|
129
|
+
@headers ||= begin
|
130
|
+
header_names.inject({}) do |acc, name|
|
131
|
+
header_val = request.env["HTTP_#{name.gsub(/-/,'_').upcase}"]
|
132
|
+
acc[name] = header_val unless header_val.nil? || header_val.empty?
|
133
|
+
acc
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def process_command(response, identifier)
|
139
|
+
response.transmissions.each { |transmission| transmit(decode(transmission)) }
|
140
|
+
hub.remove_channel(socket, identifier) if response.stop_streams
|
141
|
+
response.streams.each { |stream| hub.add_subscriber(stream, socket, identifier) }
|
142
|
+
close_connection if response.disconnect
|
143
|
+
end
|
144
|
+
|
145
|
+
def process_open(response)
|
146
|
+
if response.status == :SUCCESS
|
147
|
+
@_identifiers = response.identifiers
|
148
|
+
response.transmissions.each { |transmission| transmit(decode(transmission)) }
|
149
|
+
log(:debug) { log_fmt('Opened') }
|
150
|
+
else
|
151
|
+
log(:error, log_fmt("RPC connection command failed: #{response.inspect}"))
|
152
|
+
close_connection
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def process_close(response)
|
157
|
+
if response.status == :SUCCESS
|
158
|
+
log(:debug) { log_fmt('Closed') }
|
159
|
+
else
|
160
|
+
log(:error, log_fmt("RPC disconnection command failed: #{response.inspect}"))
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def reset_connection
|
165
|
+
@_identifiers = '{}'
|
166
|
+
@_subscriptions = []
|
167
|
+
|
168
|
+
hub.remove_socket(socket)
|
169
|
+
end
|
170
|
+
|
171
|
+
def close_connection
|
172
|
+
reset_connection
|
173
|
+
close
|
174
|
+
end
|
175
|
+
|
176
|
+
def encode(cable_message)
|
177
|
+
coder.encode(cable_message)
|
178
|
+
end
|
179
|
+
|
180
|
+
def decode(websocket_message)
|
181
|
+
coder.decode(websocket_message)
|
182
|
+
end
|
183
|
+
|
184
|
+
def log_fmt(msg)
|
185
|
+
"[connection:#{server_id}] #{msg}"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AnyCable
|
4
|
+
module RackServer
|
5
|
+
# From https://github.com/rails/rails/blob/v5.0.1/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb
|
6
|
+
class Hub
|
7
|
+
attr_reader :streams, :sockets
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@streams = Hash.new do |streams, stream_id|
|
11
|
+
streams[stream_id] = Hash.new { |channels, channel_id| channels[channel_id] = Set.new }
|
12
|
+
end
|
13
|
+
@sockets = Hash.new { |h, k| h[k] = Set.new }
|
14
|
+
@sync = Mutex.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def add_subscriber(stream, socket, channel)
|
18
|
+
@sync.synchronize do
|
19
|
+
@streams[stream][channel] << socket
|
20
|
+
@sockets[socket] << [channel, stream]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def remove_subscriber(stream, socket, channel)
|
25
|
+
@sync.synchronize do
|
26
|
+
@streams[stream][channel].delete(socket)
|
27
|
+
@sockets[socket].delete([channel, stream])
|
28
|
+
cleanup stream, socket, channel
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def remove_channel(socket, channel)
|
33
|
+
list = @sync.synchronize do
|
34
|
+
return unless @sockets.key?(socket)
|
35
|
+
|
36
|
+
@sockets[socket].dup
|
37
|
+
end
|
38
|
+
|
39
|
+
list.each do |(channel_id, stream)|
|
40
|
+
remove_subscriber(stream, socket, channel) if channel == channel_id
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def remove_socket(socket)
|
45
|
+
list = @sync.synchronize do
|
46
|
+
return unless @sockets.key?(socket)
|
47
|
+
|
48
|
+
@sockets[socket].dup
|
49
|
+
end
|
50
|
+
|
51
|
+
list.each do |(channel_id, stream)|
|
52
|
+
remove_subscriber(stream, socket, channel_id)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def broadcast(stream, message, coder)
|
57
|
+
list = @sync.synchronize do
|
58
|
+
return unless @streams.key?(stream)
|
59
|
+
|
60
|
+
@streams[stream].to_a
|
61
|
+
end
|
62
|
+
|
63
|
+
list.each do |(channel_id, sockets)|
|
64
|
+
decoded = coder.decode(message)
|
65
|
+
cmessage = channel_message(channel_id, decoded, coder)
|
66
|
+
sockets.each { |socket| socket.transmit(cmessage) }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def cleanup(stream, socket, channel)
|
73
|
+
@streams[stream].delete(channel) if @streams[stream][channel].empty?
|
74
|
+
@streams.delete(stream) if @streams[stream].empty?
|
75
|
+
@sockets.delete(socket) if @sockets[socket].empty?
|
76
|
+
end
|
77
|
+
|
78
|
+
def channel_message(channel_id, message, coder)
|
79
|
+
coder.encode(identifier: channel_id, message: message)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AnyCable
|
4
|
+
module RackServer
|
5
|
+
module Logging # :nodoc:
|
6
|
+
PREFIX = 'AnycableRackServer'
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def log(level, message = nil, logger = AnyCable.logger)
|
11
|
+
logger.send(level, PREFIX) { message || yield }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'anycable/rack-server/connection'
|
4
|
+
require 'anycable/rack-server/errors'
|
5
|
+
require 'anycable/rack-server/socket'
|
6
|
+
|
7
|
+
module AnyCable
|
8
|
+
module RackServer
|
9
|
+
class Middleware
|
10
|
+
PROTOCOLS = ['actioncable-v1-json', 'actioncable-unsupported'].freeze
|
11
|
+
attr_reader :pinger,
|
12
|
+
:hub,
|
13
|
+
:coder,
|
14
|
+
:rpc_host,
|
15
|
+
:headers,
|
16
|
+
:server_id
|
17
|
+
|
18
|
+
def initialize(_app, pinger:, hub:, coder:, rpc_host:, headers:, server_id:)
|
19
|
+
@pinger = pinger
|
20
|
+
@hub = hub
|
21
|
+
@coder = coder
|
22
|
+
@rpc_host = rpc_host
|
23
|
+
@headers = headers
|
24
|
+
@server_id = server_id
|
25
|
+
end
|
26
|
+
|
27
|
+
def call(env)
|
28
|
+
return not_found unless websocket?(env)
|
29
|
+
|
30
|
+
rack_hijack(env)
|
31
|
+
listen_socket(env)
|
32
|
+
|
33
|
+
[-1, {}, []]
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def handshake
|
39
|
+
@handshake ||= WebSocket::Handshake::Server.new(protocols: PROTOCOLS)
|
40
|
+
end
|
41
|
+
|
42
|
+
def rack_hijack(env)
|
43
|
+
raise Errors::HijackNotAvailable unless env['rack.hijack']
|
44
|
+
|
45
|
+
env['rack.hijack'].call
|
46
|
+
send_handshake(env)
|
47
|
+
end
|
48
|
+
|
49
|
+
def send_handshake(env)
|
50
|
+
handshake.from_rack(env)
|
51
|
+
env['rack.hijack_io'].write(handshake.to_s)
|
52
|
+
end
|
53
|
+
|
54
|
+
def listen_socket(env)
|
55
|
+
socket = Socket.new(env, env['rack.hijack_io'], handshake.version)
|
56
|
+
init_connection(socket)
|
57
|
+
init_pinger(socket)
|
58
|
+
socket.listen
|
59
|
+
end
|
60
|
+
|
61
|
+
def not_found
|
62
|
+
[404, { 'Content-Type' => 'text/plain' }, ['Not Found']]
|
63
|
+
end
|
64
|
+
|
65
|
+
def websocket?(env)
|
66
|
+
env['HTTP_UPGRADE'] == 'websocket'
|
67
|
+
end
|
68
|
+
|
69
|
+
def init_connection(socket)
|
70
|
+
connection = Connection.new(socket, hub, coder, rpc_host, headers, server_id)
|
71
|
+
socket.onopen { connection.handle_open }
|
72
|
+
socket.onclose { connection.handle_close }
|
73
|
+
socket.onmessage { |data| connection.handle_command(data) }
|
74
|
+
end
|
75
|
+
|
76
|
+
def init_pinger(socket)
|
77
|
+
pinger.add(socket)
|
78
|
+
socket.onclose { pinger.remove(socket) }
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AnyCable
|
4
|
+
module RackServer
|
5
|
+
# Sends pings to sockets
|
6
|
+
class Pinger
|
7
|
+
INTERVAL = 3
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@_sockets = []
|
11
|
+
@_stopped = false
|
12
|
+
run
|
13
|
+
end
|
14
|
+
|
15
|
+
def add(socket)
|
16
|
+
@_sockets << socket
|
17
|
+
end
|
18
|
+
|
19
|
+
def remove(socket)
|
20
|
+
@_sockets.delete(socket)
|
21
|
+
end
|
22
|
+
|
23
|
+
def stop
|
24
|
+
@_stopped = true
|
25
|
+
end
|
26
|
+
|
27
|
+
# rubocop: disable Metrics/MethodLength
|
28
|
+
def run
|
29
|
+
Thread.new do
|
30
|
+
loop do
|
31
|
+
break if @_stopped
|
32
|
+
|
33
|
+
unless @_sockets.empty?
|
34
|
+
msg = ping_message(Time.now.to_i)
|
35
|
+
@_sockets.each do |socket|
|
36
|
+
socket.transmit(msg)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
sleep(INTERVAL)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
# rubocop: enable Metrics/MethodLength
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def ping_message(time)
|
49
|
+
{ type: :ping, message: time }.to_json
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'grpc'
|
4
|
+
|
5
|
+
module AnyCable
|
6
|
+
module RackServer
|
7
|
+
module RPC
|
8
|
+
class Client
|
9
|
+
attr_reader :stub
|
10
|
+
|
11
|
+
def initialize(host)
|
12
|
+
@stub = AnyCable::RPC::Service.rpc_stub_class.new(host, :this_channel_is_insecure)
|
13
|
+
end
|
14
|
+
|
15
|
+
def connect(headers:, path:)
|
16
|
+
request = ConnectionRequest.new(headers: headers, path: path)
|
17
|
+
stub.connect(request)
|
18
|
+
end
|
19
|
+
|
20
|
+
def command(command:, identifier:, connection_identifiers:, data:)
|
21
|
+
message = CommandMessage.new(
|
22
|
+
command: command,
|
23
|
+
identifier: identifier,
|
24
|
+
connection_identifiers: connection_identifiers,
|
25
|
+
data: data
|
26
|
+
)
|
27
|
+
stub.command(message)
|
28
|
+
end
|
29
|
+
|
30
|
+
def disconnect(identifiers:, subscriptions:, headers:, path:)
|
31
|
+
request = DisconnectRequest.new(
|
32
|
+
identifiers: identifiers,
|
33
|
+
subscriptions: subscriptions,
|
34
|
+
headers: headers,
|
35
|
+
path: path
|
36
|
+
)
|
37
|
+
stub.disconnect(request)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
syntax = "proto3";
|
2
|
+
|
3
|
+
package anycable;
|
4
|
+
|
5
|
+
service RPC {
|
6
|
+
rpc Connect (ConnectionRequest) returns (ConnectionResponse) {}
|
7
|
+
rpc Command (CommandMessage) returns (CommandResponse) {}
|
8
|
+
rpc Disconnect (DisconnectRequest) returns (DisconnectResponse) {}
|
9
|
+
}
|
10
|
+
|
11
|
+
enum Status {
|
12
|
+
ERROR = 0;
|
13
|
+
SUCCESS = 1;
|
14
|
+
FAILURE = 2;
|
15
|
+
}
|
16
|
+
|
17
|
+
message ConnectionRequest {
|
18
|
+
string path = 1;
|
19
|
+
map<string,string> headers = 2;
|
20
|
+
}
|
21
|
+
|
22
|
+
message ConnectionResponse {
|
23
|
+
Status status = 1;
|
24
|
+
string identifiers = 2;
|
25
|
+
repeated string transmissions = 3;
|
26
|
+
string error_msg = 4;
|
27
|
+
}
|
28
|
+
|
29
|
+
message CommandMessage {
|
30
|
+
string command = 1;
|
31
|
+
string identifier = 2;
|
32
|
+
string connection_identifiers = 3;
|
33
|
+
string data = 4;
|
34
|
+
}
|
35
|
+
|
36
|
+
message CommandResponse {
|
37
|
+
Status status = 1;
|
38
|
+
bool disconnect = 2;
|
39
|
+
bool stop_streams = 3;
|
40
|
+
repeated string streams = 4;
|
41
|
+
repeated string transmissions = 5;
|
42
|
+
string error_msg = 6;
|
43
|
+
}
|
44
|
+
|
45
|
+
message DisconnectRequest {
|
46
|
+
string identifiers = 1;
|
47
|
+
repeated string subscriptions = 2;
|
48
|
+
string path = 3;
|
49
|
+
map<string,string> headers = 4;
|
50
|
+
}
|
51
|
+
|
52
|
+
message DisconnectResponse {
|
53
|
+
Status status = 1;
|
54
|
+
string error_msg = 2;
|
55
|
+
}
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'anycable/rack-server/logging'
|
4
|
+
|
5
|
+
module AnyCable
|
6
|
+
module RackServer
|
7
|
+
class Socket
|
8
|
+
include Logging
|
9
|
+
attr_reader :version, :socket
|
10
|
+
|
11
|
+
def initialize(env, socket, version)
|
12
|
+
log(:debug, "WebSocket version #{version}")
|
13
|
+
@env = env
|
14
|
+
@socket = socket
|
15
|
+
@version = version
|
16
|
+
|
17
|
+
@_open_handlers = []
|
18
|
+
@_message_handlers = []
|
19
|
+
@_close_handlers = []
|
20
|
+
@_error_handlers = []
|
21
|
+
@_active = true
|
22
|
+
end
|
23
|
+
|
24
|
+
def transmit(data, type: :text)
|
25
|
+
frame = WebSocket::Frame::Outgoing::Server.new(
|
26
|
+
version: version,
|
27
|
+
data: data,
|
28
|
+
type: type
|
29
|
+
)
|
30
|
+
socket.write(frame.to_s)
|
31
|
+
rescue IOError, Errno::EPIPE, Errno::ETIMEDOUT => e
|
32
|
+
log(:error, "Socket send failed: #{e}")
|
33
|
+
close
|
34
|
+
end
|
35
|
+
|
36
|
+
def request
|
37
|
+
@_request ||= ::Rack::Request.new(@env)
|
38
|
+
end
|
39
|
+
|
40
|
+
def onopen(&block)
|
41
|
+
@_open_handlers << block
|
42
|
+
end
|
43
|
+
|
44
|
+
def onmessage(&block)
|
45
|
+
@_message_handlers << block
|
46
|
+
end
|
47
|
+
|
48
|
+
def onclose(&block)
|
49
|
+
@_close_handlers << block
|
50
|
+
end
|
51
|
+
|
52
|
+
def onerror(&block)
|
53
|
+
@_error_handlers << block
|
54
|
+
end
|
55
|
+
|
56
|
+
# rubocop: disable Metrics/MethodLength
|
57
|
+
def listen
|
58
|
+
keepalive
|
59
|
+
Thread.new do
|
60
|
+
Thread.current.abort_on_exception = true
|
61
|
+
begin
|
62
|
+
@_open_handlers.each(&:call)
|
63
|
+
each_frame do |data|
|
64
|
+
@_message_handlers.each do |handler|
|
65
|
+
begin
|
66
|
+
handler.call(data)
|
67
|
+
rescue => e # rubocop: disable Style/RescueStandardError
|
68
|
+
log(:error, "Socket receive failed: #{e}")
|
69
|
+
@_error_handlers.each { |eh| eh.call(e, data) }
|
70
|
+
close
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
ensure
|
75
|
+
close
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
# rubocop: enable Metrics/MethodLength
|
80
|
+
|
81
|
+
def close
|
82
|
+
return unless @_active
|
83
|
+
|
84
|
+
@_close_handlers.each(&:call)
|
85
|
+
close!
|
86
|
+
|
87
|
+
@_active = false
|
88
|
+
end
|
89
|
+
|
90
|
+
def closed?
|
91
|
+
socket.closed?
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def close!
|
97
|
+
if socket.respond_to?(:closed?)
|
98
|
+
close_socket unless @socket.closed?
|
99
|
+
else
|
100
|
+
close_socket
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def close_socket
|
105
|
+
frame = WebSocket::Frame::Outgoing::Server.new(version: version, type: :close, code: 1000)
|
106
|
+
socket.write(frame.to_s) if frame.supported?
|
107
|
+
socket.close
|
108
|
+
rescue IOError, Errno::EPIPE, Errno::ETIMEDOUT # rubocop:disable Lint/HandleExceptions
|
109
|
+
# already closed
|
110
|
+
end
|
111
|
+
|
112
|
+
def keepalive
|
113
|
+
thread = Thread.new do
|
114
|
+
Thread.current.abort_on_exception = true
|
115
|
+
loop do
|
116
|
+
sleep 5
|
117
|
+
time = Time.now.to_i
|
118
|
+
transmit({ message: time, type: :ping }.to_json)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
onclose do
|
123
|
+
thread.kill
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# rubocop:disable Metrics/AbcSize
|
128
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
129
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
130
|
+
# rubocop:disable Metrics/MethodLength
|
131
|
+
def each_frame
|
132
|
+
framebuffer = WebSocket::Frame::Incoming::Server.new(version: version)
|
133
|
+
while IO.select([socket])
|
134
|
+
if socket.respond_to?(:recvfrom)
|
135
|
+
data, _addrinfo = socket.recvfrom(2000)
|
136
|
+
else
|
137
|
+
data, _addrinfo = socket.readpartial(2000), socket.peeraddr
|
138
|
+
end
|
139
|
+
|
140
|
+
break if data.empty?
|
141
|
+
|
142
|
+
framebuffer << data
|
143
|
+
|
144
|
+
while frame = framebuffer.next
|
145
|
+
case frame.type
|
146
|
+
when :close
|
147
|
+
return
|
148
|
+
when :text, :binary
|
149
|
+
yield frame.data
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
rescue Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::ECONNRESET, IOError, Errno::EBADF => e
|
154
|
+
log(:error, "Socket frame error: #{e}")
|
155
|
+
nil # client disconnected or timed out
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
metadata
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: anycable-rack-server
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Yulia Oletskaya
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-01-06 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: anycable
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.6'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.6'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: websocket
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.2'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.2'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: redis
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '4'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '4'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: anyt
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.8'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0.8'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: minitest
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '5.11'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '5.11'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '12.3'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '12.3'
|
97
|
+
description: AnyCable-compatible Ruby Rack middleware
|
98
|
+
email: yulia.oletskaya@gmail.com
|
99
|
+
executables: []
|
100
|
+
extensions: []
|
101
|
+
extra_rdoc_files: []
|
102
|
+
files:
|
103
|
+
- LICENSE
|
104
|
+
- README.md
|
105
|
+
- lib/anycable-rack-server.rb
|
106
|
+
- lib/anycable/rack-server.rb
|
107
|
+
- lib/anycable/rack-server/broadcast_subscribers/redis_subscriber.rb
|
108
|
+
- lib/anycable/rack-server/coders/json.rb
|
109
|
+
- lib/anycable/rack-server/connection.rb
|
110
|
+
- lib/anycable/rack-server/errors.rb
|
111
|
+
- lib/anycable/rack-server/hub.rb
|
112
|
+
- lib/anycable/rack-server/logging.rb
|
113
|
+
- lib/anycable/rack-server/middleware.rb
|
114
|
+
- lib/anycable/rack-server/pinger.rb
|
115
|
+
- lib/anycable/rack-server/rpc/client.rb
|
116
|
+
- lib/anycable/rack-server/rpc/rpc.proto
|
117
|
+
- lib/anycable/rack-server/socket.rb
|
118
|
+
- lib/anycable/rack-server/version.rb
|
119
|
+
homepage:
|
120
|
+
licenses:
|
121
|
+
- MIT
|
122
|
+
metadata: {}
|
123
|
+
post_install_message:
|
124
|
+
rdoc_options: []
|
125
|
+
require_paths:
|
126
|
+
- lib
|
127
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
133
|
+
requirements:
|
134
|
+
- - ">="
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: '0'
|
137
|
+
requirements: []
|
138
|
+
rubyforge_project:
|
139
|
+
rubygems_version: 2.7.6
|
140
|
+
signing_key:
|
141
|
+
specification_version: 4
|
142
|
+
summary: Anycable Rack Server
|
143
|
+
test_files: []
|