anycable-rack-server 0.0.1 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 416a34b5853c2b0f81f4693e59fe4c9b10021b41fc8eb2e710b8fd82b5a67c04
4
- data.tar.gz: fcb0f8b569adc737b704025393237b573b7de616ea4e5deedaa77e442a51bc09
3
+ metadata.gz: 61ac3aaa885caabfc4e6761dc77f3c8f792d966c13dc598b6c100c58c160c334
4
+ data.tar.gz: f1317d8d0b6d38266bd8c9f6866c2d4e1e7e24225b758d2e8c06ef6176251cc4
5
5
  SHA512:
6
- metadata.gz: 7cd8bcd7b5afda208b3df2e476b0729faf9fbfbfd9d6fb36735f26cba48b9589e3a4704a9dbc69c6743bdf4f209c2ee9e6d44f8521a5fc89036a42859f0f9090
7
- data.tar.gz: 68f4b98948bb3ec8fb330947faed0f933ee7dd12dab2f1ca6abbb35af41ab3f7b7f3ad720ea74be82af486f309b250c340603e8e10716478a03975704c3ba3f5
6
+ metadata.gz: 577a63b90c45172860302d22893db7828f748a80dc3fe8443a819c78b4c91eed10d61fecf70e6d8aeb00f50daa667cb31d9dcfe661d73e20ae65027ebbdb6a87
7
+ data.tar.gz: 4d1c8fa0810a6ea93ebebcbbc868e0713e648cebfe1af0d6761205b396b0a44cc2c79b716f50d740314c8d8faa382f873124e60275430563faa8460e019754b6
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2018 Yulia Oletskaya
3
+ Copyright (c) 2019-2020 Yulia Oletskaya, Vladimir Dementyev
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,37 +1,123 @@
1
+ [![Cult Of Martians](http://cultofmartians.com/assets/badges/badge.svg)](https://cultofmartians.com/tasks/anycable-ruby-server.html)
2
+ [![Gem Version](https://badge.fury.io/rb/anycable-rack-server.svg)](https://rubygems.org/gems/anycable-rack-server)
3
+ [![Build](https://github.com/anycable/anycable-rack-server/workflows/Build/badge.svg)](https://github.com/anycable/anycable-rack-server/actions)
4
+
1
5
  # anycable-rack-server
2
6
 
3
- AnyCable-compatible Rack hijack based Ruby Web Socket server middleware designed for development and testing purposes.
7
+ [AnyCable](https://anycable.io)-compatible Rack hijack based Ruby Web Socket server designed for development and testing purposes.
4
8
 
5
- ## Usage
9
+ ## Using with Rack
6
10
 
7
- Mount the rack middleware
8
11
  ```ruby
9
- # config/routes.rb
10
- Rails.application.routes.draw do
11
- mount AnyCable::Rack.new => '/cable'
12
+ # Initialize server instance first.
13
+ ws_server = AnyCable::Rack::Server.new
14
+
15
+ app = Rack::Builder.new do
16
+ map "/cable" do
17
+ run ws_server
18
+ end
12
19
  end
20
+
21
+ # NOTE: don't forget to call `start!` method
22
+ ws_server.start!
23
+
24
+ run app
13
25
  ```
14
26
 
15
- ## Settings
27
+ ## Usage with Rails
28
+
29
+ Add `gem "anycable-rack-server"` to you `Gemfile` and make sure your Action Cable adapter is set to `:any_cable`. That's it! We automatically start AnyCable Rack server for your at `/cable` path.
30
+
31
+ ## Configuration
16
32
 
17
- Customizable options: headers being sent with each gRPC request. The gem uses AnyCable config for redis and gRPC host settings.
33
+ AnyCable Rack Server uses [`anyway_config`](https://github.com/palkan/anyway_config) gem for configuration; thus it is possible to set configuration parameters through environment vars (prefixed with `ANYCABLE_`), `config/anycable.yml` file or `secrets.yml` when using Rails.
34
+
35
+ **NOTE:** AnyCable Rack Server uses the same config name (i.e., env prefix, YML file name, etc.) as AnyCable itself.
36
+
37
+ You can pass a config object as the option to `AnyCable::Rack::Server.new`:
38
+
39
+ ```ruby
40
+ server = AnyCable::Server::Rack.new(config: AnyCable::Rack::Config.new(**params))
41
+ ```
42
+
43
+ If no config is passed, a default, global, configuration would be used (`AnyCable::Rack.config`).
44
+
45
+ When using Rails, `config.anycable_rack` points to `AnyCable::Rack.config`.
46
+
47
+ ### Headers
48
+
49
+ You can customize the headers being sent with each gRPC request.
18
50
 
19
51
  Default headers: `'cookie', 'x-api-token'`.
20
52
 
21
- Can be specified via env variable
53
+ Can be specified via configuration:
54
+
55
+ ```ruby
56
+ AnyCable::Rack.config.headers = ["cookie", "x-my-header"]
57
+ ```
58
+
59
+ Or in Rails:
60
+
61
+ ```ruby
62
+ # <environment>.rb
63
+ config.any_cable_rack.headers = %w[cookie]
64
+ ```
65
+
66
+ ### Rails-specific options
67
+
68
+ ```ruby
69
+ # Mount WebSocket server at the specified path
70
+ config.any_cable_rack.mount_path = "/cable"
71
+ # NOTE: here we specify only the port (we assume that a server is running locally)
72
+ config.any_cable_rack.rpc_port = 50051
22
73
  ```
23
- ANYCABLE_HEADERS=cookie,x-api-token,origin
74
+
75
+ ## Broadcast adapters
76
+
77
+ AnyCable Rack supports Redis (default) and HTTP broadcast adapters (see [the documentation](https://docs.anycable.io/#/ruby/broadcast_adapters)).
78
+
79
+ Broadcast adapter is inherited from AnyCable configuration (so, you don't need to configure it twice).
80
+
81
+ ### Using HTTP broadcast adapter
82
+
83
+ ### With Rack
84
+
85
+ ```ruby
86
+ AnyCable::Rack.config.broadast_adapter = :http
87
+
88
+ ws_server = AnyCable::Rack::Server
89
+
90
+ app = Rack::Builder.new do
91
+ map "/cable" do
92
+ run ws_server
93
+ end
94
+
95
+ map "/_anycable_rack_broadcast" do
96
+ run ws_server.broadcast
97
+ end
98
+ end
24
99
  ```
25
100
 
26
- Or
101
+ ### With Rails
102
+
103
+ By default, we mount broadcasts endpoint at `/_anycable_rack_broadcast`.
104
+
105
+ You can change this setting:
27
106
 
28
107
  ```ruby
29
- options = { headers: ['cookie', 'origin'] }
30
- AnyCable::Rack.new(nil, options)
108
+ config.any_cable_rack.http_broadcast_path = "/_my_broadcast"
31
109
  ```
32
110
 
111
+ **NOTE:** Don't forget to configure `http_broadcast_url` for AnyCable pointing to your web server and the specified broadcast path.
112
+
33
113
  ## Testing
34
114
 
35
- Run units with `rake`.
115
+ Run units with `bundle exec rake`.
116
+
117
+ ## Contributing
118
+
119
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/anycable/anycable-rack-server](https://github.com/anycable/anycable-rack-server).
120
+
121
+ ## License
36
122
 
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).
123
+ The gem is available as open source under the terms of the [MIT License](./LICENSE).
@@ -1,3 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'anycable/rack-server'
3
+ require "anycable/rack/version"
4
+ require "anycable/rack/config"
5
+ require "anycable/rack/server"
6
+
7
+ module AnyCable
8
+ module Rack
9
+ class << self
10
+ def config
11
+ @config ||= Config.new
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ require "anycable/rack/railtie" if defined?(Rails)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AnyCable
6
+ module Rack
7
+ module BroadcastSubscribers
8
+ class BaseSubscriber
9
+ include Logging
10
+
11
+ attr_reader :hub, :coder
12
+
13
+ def initialize(hub:, coder:, **options)
14
+ @hub = hub
15
+ @coder = coder
16
+ end
17
+
18
+ def start
19
+ # no-op
20
+ end
21
+
22
+ def stop
23
+ # no-op
24
+ end
25
+
26
+ private
27
+
28
+ def handle_message(msg)
29
+ log(:debug) { "Received pub/sub message: #{msg}" }
30
+
31
+ data = JSON.parse(msg)
32
+ if data["stream"]
33
+ hub.broadcast(data["stream"], data["data"], coder)
34
+ elsif data["command"] == "disconnect"
35
+ hub.disconnect(data["payload"]["identifier"], data["payload"]["reconnect"])
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AnyCable
6
+ module Rack
7
+ module BroadcastSubscribers
8
+ # HTTP Pub/Sub subscriber
9
+ class HTTPSubscriber < BaseSubscriber
10
+ attr_reader :token, :path
11
+
12
+ def initialize(**options)
13
+ super
14
+ @token = options[:token]
15
+ @path = options[:path]
16
+ end
17
+
18
+ def start
19
+ log(:info) { "Accepting pub/sub request at #{path}" }
20
+ end
21
+
22
+ def call(env)
23
+ req = ::Rack::Request.new(env)
24
+
25
+ return invalid_request unless req.post?
26
+
27
+ if token && req.get_header("HTTP_AUTHORIZATION") != "Bearer #{token}"
28
+ return invalid_request(401)
29
+ end
30
+
31
+ handle_message req.body.read
32
+
33
+ [201, {"Content-Type" => "text/plain"}, ["OK"]]
34
+ end
35
+
36
+ private
37
+
38
+ def invalid_request(code = 422)
39
+ [code, {"Content-Type" => "text/plain"}, ["Invalid request"]]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ gem "redis", "~> 4"
4
+
5
+ require "redis"
6
+ require "json"
7
+
8
+ module AnyCable
9
+ module Rack
10
+ module BroadcastSubscribers
11
+ # Redis Pub/Sub subscriber
12
+ class RedisSubscriber < BaseSubscriber
13
+ attr_reader :redis_conn, :thread, :channel
14
+
15
+ def initialize(hub:, coder:, channel:, **options)
16
+ super
17
+ @redis_conn = ::Redis.new(options)
18
+ @channel = channel
19
+ end
20
+
21
+ def start
22
+ subscribe(channel)
23
+
24
+ log(:info) { "Subscribed to #{channel}" }
25
+ end
26
+
27
+ def stop
28
+ thread&.terminate
29
+ end
30
+
31
+ def subscribe(channel)
32
+ @thread ||= Thread.new do
33
+ Thread.current.abort_on_exception = true
34
+
35
+ redis_conn.without_reconnect do
36
+ redis_conn.subscribe(channel) do |on|
37
+ on.subscribe do |chan, count|
38
+ log(:debug) { "Redis subscriber connected to #{chan} (#{count})" }
39
+ end
40
+
41
+ on.unsubscribe do |chan, count|
42
+ log(:debug) { "Redis subscribed disconnected from #{chan} (#{count})" }
43
+ end
44
+
45
+ on.message do |_channel, msg|
46
+ handle_message(msg)
47
+ rescue
48
+ log(:error) { "Failed to broadcast message: #{msg}" }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module AnyCable
4
- module RackServer
6
+ module Rack
5
7
  module Coders
6
- module JSON
8
+ module JSON # :nodoc:
7
9
  class << self
8
10
  def decode(json_str)
9
11
  ::JSON.parse(json_str)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway_config"
4
+
5
+ module AnyCable
6
+ module Rack
7
+ class Config < Anyway::Config
8
+ DEFAULT_HEADERS = %w[cookie x-api-token].freeze
9
+
10
+ config_name :anycable
11
+ env_prefix "ANYCABLE"
12
+
13
+ attr_config mount_path: "/cable",
14
+ headers: DEFAULT_HEADERS,
15
+ rpc_addr: "localhost:50051",
16
+ rpc_client_pool_size: 5,
17
+ rpc_client_timeout: 5,
18
+ http_broadcast_path: "/_anycable_rack_broadcast"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "set"
5
+ require "json"
6
+
7
+ require "anycable/rack/rpc/client"
8
+ require "anycable/rack/logging"
9
+ require "anycable/rack/errors"
10
+
11
+ module AnyCable
12
+ module Rack
13
+ class Connection # :nodoc:
14
+ include Logging
15
+
16
+ attr_reader :coder,
17
+ :headers,
18
+ :hub,
19
+ :socket,
20
+ :rpc_client,
21
+ :sid
22
+
23
+ def initialize(socket, hub:, coder:, rpc_client:, headers:)
24
+ @socket = socket
25
+ @coder = coder
26
+ @headers = headers
27
+ @hub = hub
28
+ @sid = SecureRandom.hex(6)
29
+
30
+ @rpc_client = rpc_client
31
+
32
+ @_identifiers = "{}"
33
+ @_subscriptions = Set.new
34
+ @_istate = {}
35
+ end
36
+
37
+ def handle_open
38
+ response = rpc_connect
39
+ process_open(response)
40
+ end
41
+
42
+ def handle_close
43
+ response = rpc_disconnect
44
+ process_close(response)
45
+ reset_connection
46
+ end
47
+
48
+ def handle_command(websocket_message)
49
+ decoded = decode(websocket_message)
50
+ command = decoded.delete("command")
51
+
52
+ channel_identifier = decoded["identifier"]
53
+
54
+ log(:debug) { "Command: #{decoded}" }
55
+
56
+ case command
57
+ when "subscribe" then subscribe(channel_identifier)
58
+ when "unsubscribe" then unsubscribe(channel_identifier)
59
+ when "message" then send_message(channel_identifier, decoded["data"])
60
+ else
61
+ log(:error, "Command not found #{command}")
62
+ end
63
+ rescue Exception => e # rubocop:disable Lint/RescueException
64
+ log(:error, "Failed to execute command #{command}: #{e.message}")
65
+ end
66
+
67
+ private
68
+
69
+ def transmit(cable_message)
70
+ socket.transmit(encode(cable_message))
71
+ end
72
+
73
+ def close
74
+ socket.close
75
+ end
76
+
77
+ def request
78
+ socket.request
79
+ end
80
+
81
+ def rpc_connect
82
+ rpc_client.connect(headers: headers, url: request.url)
83
+ end
84
+
85
+ def rpc_disconnect
86
+ rpc_client.disconnect(
87
+ identifiers: @_identifiers,
88
+ subscriptions: @_subscriptions.to_a,
89
+ headers: headers,
90
+ url: request.url,
91
+ state: @_cstate,
92
+ channels_state: @_istate
93
+ )
94
+ end
95
+
96
+ def rpc_command(command, identifier, data = "")
97
+ rpc_client.command(
98
+ command: command,
99
+ identifier: identifier,
100
+ connection_identifiers: @_identifiers,
101
+ data: data,
102
+ headers: headers,
103
+ url: request.url,
104
+ connection_state: @_cstate,
105
+ state: @_istate[identifier]
106
+ )
107
+ end
108
+
109
+ def subscribe(identifier)
110
+ response = rpc_command("subscribe", identifier)
111
+ if response.status == :SUCCESS
112
+ @_subscriptions.add(identifier)
113
+ elsif response.status == :ERROR
114
+ log(:error, "RPC subscribe command failed: #{response.inspect}")
115
+ end
116
+ process_command(response, identifier)
117
+ end
118
+
119
+ def unsubscribe(identifier)
120
+ response = rpc_command("unsubscribe", identifier)
121
+ if response.status == :SUCCESS
122
+ @_subscriptions.delete(identifier)
123
+ elsif response.status == :ERROR
124
+ log(:error, "RPC unsubscribe command failed: #{response.inspect}")
125
+ end
126
+ process_command(response, identifier)
127
+ end
128
+
129
+ def send_message(identifier, data)
130
+ response = rpc_command("message", identifier, data)
131
+ log(:error, "RPC message command failed: #{response.inspect}") if response.status == :ERROR
132
+ process_command(response, identifier)
133
+ end
134
+
135
+ def process_command(response, identifier)
136
+ response.transmissions.each { |transmission| transmit(decode(transmission)) }
137
+ hub.remove_channel(socket, identifier) if response.stop_streams
138
+ response.streams.each { |stream| hub.add_subscriber(stream, socket, identifier) }
139
+ response.stopped_streams.each { |stream| hub.remove_subscriber(stream, socket, identifier) }
140
+
141
+ @_istate[identifier] ||= {}
142
+ @_istate[identifier].merge!(response.env.istate&.to_h || {})
143
+
144
+ close_connection if response.disconnect
145
+ end
146
+
147
+ def process_open(response)
148
+ response.transmissions&.each { |transmission| transmit(decode(transmission)) }
149
+ if response.status == :SUCCESS
150
+ @_identifiers = response.identifiers
151
+ @_cstate = response.env.cstate&.to_h || {}
152
+ hub.add_socket(socket, @_identifiers)
153
+ log(:debug) { "Opened" }
154
+ else
155
+ log(:error, "RPC connection command failed: #{response.inspect}")
156
+ close_connection
157
+ end
158
+ end
159
+
160
+ def process_close(response)
161
+ if response.status == :SUCCESS
162
+ log(:debug) { "Closed" }
163
+ else
164
+ log(:error, "RPC disconnection command failed: #{response.inspect}")
165
+ end
166
+ end
167
+
168
+ def reset_connection
169
+ @_identifiers = "{}"
170
+ @_subscriptions = []
171
+
172
+ hub.remove_socket(socket)
173
+ end
174
+
175
+ def close_connection
176
+ reset_connection
177
+ close
178
+ end
179
+
180
+ def encode(cable_message)
181
+ coder.encode(cable_message)
182
+ end
183
+
184
+ def decode(websocket_message)
185
+ coder.decode(websocket_message)
186
+ end
187
+
188
+ def log(level, msg = nil)
189
+ super(level, msg ? log_fmt(msg) : nil) { log_fmt(yield) }
190
+ end
191
+
192
+ def log_fmt(msg)
193
+ "[sid=#{sid}] #{msg}"
194
+ end
195
+ end
196
+ end
197
+ end