pakyow-realtime 0.11.3 → 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +5 -5
  2. data/{pakyow-realtime/CHANGELOG.md → CHANGELOG.md} +5 -0
  3. data/LICENSE +4 -0
  4. data/{pakyow-realtime/README.md → README.md} +1 -2
  5. data/lib/pakyow/environment/realtime/config.rb +29 -0
  6. data/lib/pakyow/realtime/actions/upgrader.rb +29 -0
  7. data/lib/pakyow/realtime/behavior/config.rb +42 -0
  8. data/lib/pakyow/realtime/behavior/rendering/install_websocket.rb +57 -0
  9. data/lib/pakyow/realtime/behavior/serialization.rb +42 -0
  10. data/lib/pakyow/realtime/behavior/server.rb +42 -0
  11. data/lib/pakyow/realtime/behavior/silencing.rb +25 -0
  12. data/lib/pakyow/realtime/channel.rb +23 -0
  13. data/lib/pakyow/realtime/context.rb +38 -0
  14. data/lib/pakyow/realtime/framework.rb +49 -0
  15. data/lib/pakyow/realtime/helpers/broadcasting.rb +13 -0
  16. data/lib/pakyow/realtime/helpers/socket.rb +13 -0
  17. data/lib/pakyow/realtime/helpers/subscriptions.rb +35 -0
  18. data/lib/pakyow/realtime/server/adapters/memory.rb +127 -0
  19. data/lib/pakyow/realtime/server/adapters/redis.rb +277 -0
  20. data/lib/pakyow/realtime/server.rb +152 -0
  21. data/lib/pakyow/realtime/websocket.rb +157 -0
  22. data/lib/pakyow/realtime.rb +13 -0
  23. metadata +73 -44
  24. data/pakyow-realtime/LICENSE +0 -20
  25. data/pakyow-realtime/lib/pakyow/realtime/config.rb +0 -20
  26. data/pakyow-realtime/lib/pakyow/realtime/connection.rb +0 -18
  27. data/pakyow-realtime/lib/pakyow/realtime/context.rb +0 -68
  28. data/pakyow-realtime/lib/pakyow/realtime/delegate.rb +0 -112
  29. data/pakyow-realtime/lib/pakyow/realtime/exceptions.rb +0 -6
  30. data/pakyow-realtime/lib/pakyow/realtime/ext/request.rb +0 -10
  31. data/pakyow-realtime/lib/pakyow/realtime/helpers.rb +0 -40
  32. data/pakyow-realtime/lib/pakyow/realtime/hooks.rb +0 -41
  33. data/pakyow-realtime/lib/pakyow/realtime/message_handler.rb +0 -57
  34. data/pakyow-realtime/lib/pakyow/realtime/message_handlers/call_route.rb +0 -34
  35. data/pakyow-realtime/lib/pakyow/realtime/message_handlers/ping.rb +0 -8
  36. data/pakyow-realtime/lib/pakyow/realtime/redis_subscription.rb +0 -61
  37. data/pakyow-realtime/lib/pakyow/realtime/registries/redis_registry.rb +0 -107
  38. data/pakyow-realtime/lib/pakyow/realtime/registries/simple_registry.rb +0 -40
  39. data/pakyow-realtime/lib/pakyow/realtime/websocket.rb +0 -209
  40. data/pakyow-realtime/lib/pakyow/realtime.rb +0 -19
  41. data/pakyow-realtime/lib/pakyow-realtime.rb +0 -1
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+ require "concurrent/array"
5
+ require "concurrent/timer_task"
6
+ require "connection_pool"
7
+
8
+ module Pakyow
9
+ module Realtime
10
+ class Server
11
+ module Adapters
12
+ # Manages websocket channels in redis.
13
+ #
14
+ # Use this in production.
15
+ #
16
+ # @api private
17
+ class Redis
18
+ KEY_PART_SEPARATOR = "/"
19
+ KEY_PREFIX = "realtime"
20
+ INFINITY = "+inf"
21
+
22
+ PUBSUB_PREFIX = "pubsub"
23
+
24
+ def initialize(server, config)
25
+ @server, @config = server, config
26
+ @prefix = [@config[:key_prefix], KEY_PREFIX].join(KEY_PART_SEPARATOR)
27
+
28
+ connect
29
+ cleanup
30
+ end
31
+
32
+ def connect
33
+ @redis = ConnectionPool.new(**@config[:pool]) {
34
+ ::Redis.new(@config[:connection])
35
+ }
36
+
37
+ @buffer = Buffer.new(@redis, pubsub_channel)
38
+ @subscriber = Subscriber.new(::Redis.new(@config[:connection]), pubsub_channel) do |payload|
39
+ channel, message = Marshal.restore(payload).values_at(:channel, :message)
40
+ @server.transmit_message_to_connection_ids(message, socket_ids_for_channel(channel), raw: true)
41
+ end
42
+ end
43
+
44
+ def disconnect
45
+ @subscriber.disconnect
46
+ end
47
+
48
+ def socket_subscribe(socket_id, *channels)
49
+ @redis.with do |redis|
50
+ redis.multi do |transaction|
51
+ channels.each do |channel|
52
+ channel = channel.to_s
53
+ transaction.zadd(key_socket_ids_by_channel(channel), INFINITY, socket_id)
54
+ transaction.zadd(key_channels_by_socket_id(socket_id), INFINITY, channel)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ def socket_unsubscribe(*channels)
61
+ @redis.with do |redis|
62
+ channels.each do |channel|
63
+ channel = channel.to_s
64
+
65
+ # Channel could contain a wildcard, so this takes some work...
66
+ redis.scan_each(match: key_socket_ids_by_channel(channel)) do |key|
67
+ channel = key.split("channel:", 2)[1]
68
+
69
+ socket_ids = redis.zrangebyscore(
70
+ key, Time.now.to_i, INFINITY
71
+ )
72
+
73
+ redis.multi do |transaction|
74
+ transaction.del(key)
75
+
76
+ socket_ids.each do |socket_id|
77
+ transaction.zrem(key_channels_by_socket_id(socket_id), channel)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def subscription_broadcast(channel, message)
86
+ @buffer << Marshal.dump(channel: channel, message: { payload: message }.to_json)
87
+ end
88
+
89
+ def expire(socket_id, seconds)
90
+ time_expire = Time.now.to_i + seconds
91
+ channels = channels_for_socket_id(socket_id)
92
+
93
+ @redis.with do |redis|
94
+ redis.multi do |transaction|
95
+ channels.each do |channel|
96
+ transaction.zadd(key_socket_ids_by_channel(channel), time_expire, socket_id)
97
+ end
98
+
99
+ transaction.expireat(key_channels_by_socket_id(socket_id), time_expire + 1)
100
+ transaction.expireat(key_socket_instance_id_by_socket_id(socket_id), time_expire + 1)
101
+ end
102
+ end
103
+ end
104
+
105
+ def persist(socket_id)
106
+ channels = channels_for_socket_id(socket_id)
107
+
108
+ @redis.with do |redis|
109
+ redis.multi do |transaction|
110
+ channels.each do |channel|
111
+ transaction.zadd(key_socket_ids_by_channel(channel), INFINITY, socket_id)
112
+ end
113
+
114
+ transaction.persist(key_channels_by_socket_id(socket_id))
115
+ transaction.persist(key_socket_instance_id_by_socket_id(socket_id))
116
+ end
117
+ end
118
+ end
119
+
120
+ def current!(socket_id, socket_instance_id)
121
+ @redis.with do |redis|
122
+ redis.set(key_socket_instance_id_by_socket_id(socket_id), socket_instance_id)
123
+ end
124
+ end
125
+
126
+ def current?(socket_id, socket_instance_id)
127
+ @redis.with do |redis|
128
+ redis.get(key_socket_instance_id_by_socket_id(socket_id)) == socket_instance_id.to_s
129
+ end
130
+ end
131
+
132
+ protected
133
+
134
+ def socket_ids_for_channel(channel)
135
+ @redis.with do |redis|
136
+ redis.zrangebyscore(
137
+ key_socket_ids_by_channel(channel), INFINITY, INFINITY
138
+ )
139
+ end
140
+ end
141
+
142
+ def channels_for_socket_id(socket_id)
143
+ @redis.with do |redis|
144
+ redis.zrangebyscore(
145
+ key_channels_by_socket_id(socket_id), INFINITY, INFINITY
146
+ )
147
+ end
148
+ end
149
+
150
+ def build_key(*parts)
151
+ [@prefix].concat(parts).join(KEY_PART_SEPARATOR)
152
+ end
153
+
154
+ def key_socket_ids_by_channel(channel)
155
+ build_key("channel:#{channel}")
156
+ end
157
+
158
+ def key_channels_by_socket_id(socket_id)
159
+ build_key("socket_id:#{socket_id}")
160
+ end
161
+
162
+ def key_socket_instance_id_by_socket_id(socket_id)
163
+ build_key("socket_instance_id:#{socket_id}")
164
+ end
165
+
166
+ def pubsub_channel
167
+ [@prefix, PUBSUB_PREFIX].join(KEY_PART_SEPARATOR)
168
+ end
169
+
170
+ def cleanup
171
+ Concurrent::TimerTask.new(execution_interval: 300, timeout_interval: 300) {
172
+ Pakyow.logger.debug "[Pakyow::Realtime::Server::Adapters::Redis] Cleaning up channel keys"
173
+
174
+ removed_count = 0
175
+ @redis.with do |redis|
176
+ redis.scan_each(match: key_socket_ids_by_channel("*")) do |key|
177
+ socket_ids = redis.zrangebyscore(
178
+ key, Time.now.to_i, INFINITY
179
+ )
180
+
181
+ if socket_ids.empty?
182
+ removed_count += 1
183
+ redis.del(key)
184
+ end
185
+ end
186
+ end
187
+
188
+ Pakyow.logger.debug "[Pakyow::Realtime::Server::Adapters::Redis] Removed #{removed_count} keys"
189
+ }.execute
190
+ end
191
+
192
+ class Buffer
193
+ # The number of publish commands to pipeline to redis.
194
+ #
195
+ PUBLISH_BUFFER_SIZE = 1_000
196
+
197
+ # How often the publish buffer should be flushed.
198
+ #
199
+ PUBLISH_BUFFER_FLUSH_MS = 150
200
+
201
+ def initialize(redis, channel)
202
+ @redis, @channel = redis, channel
203
+ @buffer = Concurrent::Array.new
204
+ end
205
+
206
+ def <<(payload)
207
+ @buffer << payload
208
+ maybe_flush
209
+ end
210
+
211
+ protected
212
+
213
+ def maybe_flush
214
+ if @buffer.count > PUBLISH_BUFFER_SIZE
215
+ flush
216
+ end
217
+
218
+ unless @task&.pending?
219
+ @task = Concurrent::ScheduledTask.execute(PUBLISH_BUFFER_FLUSH_MS / 1_000) {
220
+ flush
221
+ }
222
+ end
223
+ end
224
+
225
+ def flush
226
+ @redis.with do |redis|
227
+ redis.pipelined do |pipeline|
228
+ until @buffer.empty?
229
+ pipeline.publish(@channel, @buffer.shift)
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ class Subscriber
237
+ def initialize(redis, channel, &callback)
238
+ @redis, @channel, @callback = redis, channel, callback
239
+
240
+ @thread = Thread.new do
241
+ subscribe
242
+ end
243
+ end
244
+
245
+ def disconnect
246
+ @thread.exit
247
+ @redis.disconnect!
248
+ end
249
+
250
+ def subscribe
251
+ @redis.subscribe(@channel) do |on|
252
+ on.message do |_, payload|
253
+ begin
254
+ @callback.call(payload)
255
+ rescue => error
256
+ Pakyow.logger.error "[Pakyow::Realtime::Server::Adapters::Redis] Subscriber callback failed: #{error}"
257
+ end
258
+ end
259
+ end
260
+ rescue ::Redis::CannotConnectError
261
+ Pakyow.logger.error "[Pakyow::Realtime::Server::Adapters::Redis] Subscriber disconnected"
262
+ resubscribe
263
+ rescue => error
264
+ Pakyow.logger.error "[Pakyow::Realtime::Server::Adapters::Redis] Subscriber crashed: #{error}"
265
+ resubscribe
266
+ end
267
+
268
+ def resubscribe
269
+ sleep 0.25
270
+ subscribe
271
+ end
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/array"
4
+ require "concurrent/timer_task"
5
+ require "concurrent/executor/thread_pool_executor"
6
+
7
+ require "pakyow/support/message_verifier"
8
+
9
+ require "pakyow/realtime/websocket"
10
+
11
+ module Pakyow
12
+ module Realtime
13
+ class Server
14
+ attr_reader :adapter
15
+
16
+ HEARTBEAT_INTERVAL = 3
17
+
18
+ def initialize(adapter = :memory, adapter_config, timeout_config)
19
+ require "pakyow/realtime/server/adapters/#{adapter}"
20
+ @adapter = Adapters.const_get(adapter.to_s.capitalize).new(self, adapter_config)
21
+ @sockets = Concurrent::Array.new
22
+ @timeout_config = timeout_config
23
+ @executor = Concurrent::ThreadPoolExecutor.new(
24
+ auto_terminate: false,
25
+ min_threads: 1,
26
+ max_threads: 10,
27
+ max_queue: 0
28
+ )
29
+
30
+ connect
31
+ rescue LoadError => e
32
+ Pakyow.logger.error "Failed to load data subscriber store adapter named `#{adapter}'"
33
+ Pakyow.logger.error e.message
34
+ end
35
+
36
+ def shutdown
37
+ disconnect
38
+ @executor.shutdown
39
+ @executor.wait_for_termination(30)
40
+ end
41
+
42
+ def connect
43
+ @executor << -> {
44
+ start_heartbeat; @adapter.connect
45
+ }
46
+ end
47
+
48
+ def disconnect
49
+ @executor << -> {
50
+ stop_heartbeat; @adapter.disconnect
51
+ }
52
+ end
53
+
54
+ def socket_connect(id_or_socket)
55
+ @executor << -> {
56
+ find_socket(id_or_socket) do |socket|
57
+ @sockets << socket
58
+ @adapter.persist(socket.id)
59
+ @adapter.current!(socket.id, socket.object_id)
60
+ end
61
+ }
62
+ end
63
+
64
+ def socket_disconnect(id_or_socket)
65
+ @executor << -> {
66
+ find_socket(id_or_socket) do |socket|
67
+ @sockets.delete(socket)
68
+
69
+ # If this isn't the current instance for the socket id, it means that a
70
+ # reconnect probably happened and the new socket connected before we
71
+ # knew that the old one disconnected. Since there's a newer socket,
72
+ # don't trigger leave events or expirations for the old one.
73
+ #
74
+ if @adapter.current?(socket.id, socket.object_id)
75
+ socket.leave
76
+ @adapter.expire(socket.id, @timeout_config.disconnect)
77
+ end
78
+ end
79
+ }
80
+ end
81
+
82
+ def socket_subscribe(id_or_socket, *channels)
83
+ @executor << -> {
84
+ find_socket_id(id_or_socket) do |socket_id|
85
+ @adapter.socket_subscribe(socket_id, *channels)
86
+ @adapter.expire(socket_id, @timeout_config.initial)
87
+ end
88
+ }
89
+ end
90
+
91
+ def socket_unsubscribe(*channels)
92
+ @executor << -> {
93
+ @adapter.socket_unsubscribe(*channels)
94
+ }
95
+ end
96
+
97
+ def subscription_broadcast(channel, message)
98
+ @executor << -> {
99
+ @adapter.subscription_broadcast(channel.to_s, channel: channel.name, message: message)
100
+ }
101
+ end
102
+
103
+ # Called by the adapter, which guarantees that this server has connections for these ids.
104
+ #
105
+ def transmit_message_to_connection_ids(message, socket_ids, raw: false)
106
+ socket_ids.each do |socket_id|
107
+ find_socket_by_id(socket_id)&.transmit(message, raw: raw)
108
+ end
109
+ end
110
+
111
+ def find_socket_by_id(socket_id)
112
+ @sockets.find { |socket| socket.id == socket_id }
113
+ end
114
+
115
+ def find_socket(id_or_socket)
116
+ socket = if id_or_socket.is_a?(WebSocket)
117
+ id_or_socket
118
+ else
119
+ find_socket_by_id(id_or_socket)
120
+ end
121
+
122
+ yield socket if socket
123
+ end
124
+
125
+ def find_socket_id(id_or_socket)
126
+ socket_id = if id_or_socket.is_a?(WebSocket)
127
+ id_or_socket.id
128
+ else
129
+ id_or_socket
130
+ end
131
+
132
+ yield socket_id if socket_id
133
+ end
134
+
135
+ private
136
+
137
+ def start_heartbeat
138
+ @heartbeat = Concurrent::TimerTask.new(execution_interval: HEARTBEAT_INTERVAL) do
139
+ @executor << -> {
140
+ @sockets.each(&:beat)
141
+ }
142
+ end
143
+
144
+ @heartbeat.execute
145
+ end
146
+
147
+ def stop_heartbeat
148
+ @heartbeat.shutdown
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ require "pakyow/helpers/app"
7
+ require "pakyow/helpers/connection"
8
+
9
+ require "async/websocket"
10
+
11
+ require "protocol/websocket/connection"
12
+ require "protocol/websocket/headers"
13
+
14
+ module Pakyow
15
+ module Realtime
16
+ class WebSocket
17
+ Frame = ::Protocol::WebSocket::Frame
18
+
19
+ class Connection < ::Protocol::WebSocket::Connection
20
+ include ::Protocol::WebSocket::Headers
21
+
22
+ def self.call(framer, protocol = [], **options)
23
+ return self.new(framer, Array(protocol).first, **options)
24
+ end
25
+
26
+ def initialize(framer, protocol = nil, **options)
27
+ super(framer, **options)
28
+ @protocol = protocol
29
+ end
30
+
31
+ attr :protocol
32
+
33
+ def call
34
+ self.close
35
+ end
36
+ end
37
+
38
+ include Pakyow::Helpers::App
39
+ include Pakyow::Helpers::Connection
40
+
41
+ attr_reader :id
42
+
43
+ def initialize(id, connection)
44
+ @id, @connection, @open = id, connection, false
45
+ @logger = Logger.new(:sock, id: @id[0..7], output: Pakyow.global_logger, level: Pakyow.config.logger.level)
46
+ @server = @connection.app.websocket_server
47
+
48
+ response = Async::WebSocket::Adapters::Native.open(@connection.request, handler: Connection) do |socket|
49
+ @socket = socket
50
+
51
+ handle_open
52
+ while message = socket.read
53
+ handle_message(message)
54
+ end
55
+ rescue EOFError, Protocol::WebSocket::ClosedError
56
+ ensure
57
+ @socket&.close; shutdown
58
+ end
59
+
60
+ @connection.__getobj__.instance_variable_set(:@response, response)
61
+ end
62
+
63
+ def open?
64
+ @open == true
65
+ end
66
+
67
+ def transmit(message, raw: false)
68
+ if open?
69
+ if raw
70
+ @socket.write(message)
71
+ else
72
+ @socket.write(JSON.dump(payload: message))
73
+ end
74
+
75
+ @socket.flush
76
+ end
77
+ end
78
+
79
+ def beat
80
+ transmit("beat")
81
+ end
82
+
83
+ # @api private
84
+ def leave
85
+ trigger_presence(:leave)
86
+ end
87
+
88
+ private
89
+
90
+ def shutdown
91
+ if open?
92
+ @server.socket_disconnect(self)
93
+ @open = false
94
+ @logger.info "shutdown"
95
+ end
96
+ end
97
+
98
+ def handle_open
99
+ @server.socket_connect(self)
100
+ @open = true
101
+ trigger_presence(:join)
102
+ @logger.info "opened"
103
+ transmit_system_info
104
+ end
105
+
106
+ def handle_message(message)
107
+ @logger.internal("< " + message)
108
+ end
109
+
110
+ def trigger_presence(event)
111
+ @connection.app.hooks(:before, event).each do |hook, _|
112
+ instance_exec(&hook[:block])
113
+ end
114
+ end
115
+
116
+ def transmit_system_info
117
+ transmit(
118
+ channel: "system",
119
+ message: {
120
+ version: @connection.app.config.version
121
+ }
122
+ )
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ module Async
129
+ module WebSocket
130
+ module Adapters
131
+ module Native
132
+ include ::Protocol::WebSocket::Headers
133
+
134
+ def self.websocket?(request)
135
+ request.headers.include?("upgrade")
136
+ end
137
+
138
+ def self.open(request, headers: [], protocols: [], handler: Connection, **options)
139
+ if websocket?(request) && Array(request.protocol).include?(PROTOCOL)
140
+ # Select websocket sub-protocol:
141
+ if requested_protocol = request.headers[SEC_WEBSOCKET_PROTOCOL]
142
+ protocol = (requested_protocol & protocols).first
143
+ end
144
+
145
+ Response.for(request, headers, protocol: protocol, **options) do |stream|
146
+ framer = Protocol::WebSocket::Framer.new(stream)
147
+
148
+ yield handler.call(framer, protocol)
149
+ end
150
+ else
151
+ nil
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support"
4
+ require "pakyow/routing"
5
+ require "pakyow/presenter"
6
+
7
+ require "pakyow/realtime/framework"
8
+
9
+ require "pakyow/environment/realtime/config"
10
+
11
+ module Pakyow
12
+ include Environment::Realtime::Config
13
+ end