pakyow-realtime 0.11.3 → 1.0.0.rc1

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.
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