gemstack-realtime 0.3.6 → 0.4.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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +4 -4
- data/lib/gemstack/realtime/connection.rb +51 -6
- data/lib/gemstack/realtime/dispatcher.rb +54 -0
- data/lib/gemstack/realtime/hub.rb +32 -4
- data/lib/gemstack/realtime/middleware.rb +206 -44
- data/lib/gemstack/realtime/presence.rb +186 -0
- data/lib/gemstack/realtime/streamer.rb +12 -9
- data/lib/gemstack/realtime/testing.rb +1 -1
- data/lib/gemstack/realtime/websocket/codec.rb +192 -0
- data/lib/gemstack/realtime/websocket/connection.rb +253 -0
- data/lib/gemstack/realtime.rb +136 -31
- metadata +9 -4
data/lib/gemstack/realtime.rb
CHANGED
|
@@ -6,17 +6,20 @@ require "gemstack/schema"
|
|
|
6
6
|
require "gemstack/http"
|
|
7
7
|
|
|
8
8
|
module GemStack
|
|
9
|
-
# Realtime
|
|
9
|
+
# Realtime between server and browsers (ARCHITECTURE §10, docs/realtime.md).
|
|
10
10
|
#
|
|
11
11
|
# GemStack.broadcast("orders:#{order.id}", "order.updated", order) # anywhere: controllers, jobs, console
|
|
12
12
|
#
|
|
13
|
-
# // browser
|
|
13
|
+
# // browser (frontend/lib/gemstack/realtime.ts)
|
|
14
14
|
# realtime.subscribe(`orders:${id}`, (event) => { ... })
|
|
15
|
+
# await realtime.send("rooms:1", "message.create", { body }) // handled by `receive`
|
|
15
16
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
17
|
+
# Transports: one connection per browser tab on `<api_path>/realtime` (the
|
|
18
|
+
# same origin as the API) — a WebSocket, or a Server-Sent Events stream plus
|
|
19
|
+
# POSTs — held by an event loop off the server's request threads. Both carry
|
|
20
|
+
# subscriptions, client messages, presence and replay after reconnects.
|
|
21
|
+
# Fan-out between processes goes through a broker (PostgreSQL LISTEN/NOTIFY,
|
|
22
|
+
# or Redis). Channels are deny-by-default: declare them in config/channels.rb.
|
|
20
23
|
module Realtime
|
|
21
24
|
class Config < Settings
|
|
22
25
|
setting :path, default: -> { "#{GemStack.config.http.api_path}/realtime" }
|
|
@@ -31,12 +34,32 @@ module GemStack
|
|
|
31
34
|
else :memory
|
|
32
35
|
end
|
|
33
36
|
}
|
|
34
|
-
# Seconds between
|
|
37
|
+
# Seconds between WebSocket pings (keeps proxies from closing idle
|
|
38
|
+
# connections); a client silent for three heartbeats is disconnected.
|
|
35
39
|
setting :heartbeat, default: 15
|
|
36
40
|
# Recent events kept per process for Last-Event-ID replay after reconnects.
|
|
37
41
|
setting :replay_size, default: 1_000
|
|
38
42
|
setting :replay_ttl, default: 300
|
|
39
43
|
setting :max_channels, default: 50
|
|
44
|
+
# Largest WebSocket message a browser may send (bytes); larger closes with 1009.
|
|
45
|
+
setting :max_message_size, default: 64 * 1024
|
|
46
|
+
# Subscribe/unsubscribe/message requests per connection per second.
|
|
47
|
+
setting :max_messages_per_second, default: 20
|
|
48
|
+
# Threads that run channel authorization and `receive` handlers.
|
|
49
|
+
setting :workers, default: 4
|
|
50
|
+
# Origins allowed to open a WebSocket. nil: the API's own origin plus
|
|
51
|
+
# config.http.cors.origins (browsers always send Origin; this blocks
|
|
52
|
+
# cross-site WebSocket hijacking).
|
|
53
|
+
setting :allowed_origins, default: nil
|
|
54
|
+
# Seconds between presence refreshes; an entry lapses after three.
|
|
55
|
+
setting :presence_interval, default: 15
|
|
56
|
+
# Seconds a dropped connection stays present (covers reconnects).
|
|
57
|
+
setting :presence_grace, default: 3
|
|
58
|
+
# Which transports the endpoint serves: :websocket (one connection,
|
|
59
|
+
# both directions) and :sse (Server-Sent Events down, POST up — works
|
|
60
|
+
# wherever plain HTTP does). The browser client's NEXT_PUBLIC_GEMSTACK_REALTIME
|
|
61
|
+
# picks one; "auto" tries WebSocket first and falls back to SSE.
|
|
62
|
+
setting :transports, default: %i[websocket sse]
|
|
40
63
|
# A client that falls this far behind (bytes buffered) is disconnected.
|
|
41
64
|
setting :max_buffer, default: 1024 * 1024
|
|
42
65
|
# Reconnect delay the browser is told to use (ms).
|
|
@@ -54,7 +77,11 @@ module GemStack
|
|
|
54
77
|
Message = Struct.new(:id, :channel, :event, :data) do
|
|
55
78
|
def to_h = { id: id, channel: channel, event: event, data: data }
|
|
56
79
|
def json = @json ||= HTTP::JSONCodec.default.dump(to_h)
|
|
57
|
-
|
|
80
|
+
# Without an id (presence changes, system events) there's no id line: an
|
|
81
|
+
# empty one would reset the browser's Last-Event-ID.
|
|
82
|
+
def sse = "#{"id: #{id}\n" if id}data: #{json}\n\n"
|
|
83
|
+
# Encoded once per broadcast, however many connections receive it.
|
|
84
|
+
def ws_frame = @ws_frame ||= WebSocket::Codec.text(%({"type":"event",#{json.delete_prefix("{")}))
|
|
58
85
|
|
|
59
86
|
def self.from_json(string)
|
|
60
87
|
hash = JSON.parse(string)
|
|
@@ -81,6 +108,28 @@ module GemStack
|
|
|
81
108
|
@channels ||= Channels.new
|
|
82
109
|
end
|
|
83
110
|
|
|
111
|
+
def presence
|
|
112
|
+
@presence || @mutex.synchronize { @presence ||= Presence.new }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Who is on a presence channel right now, across processes:
|
|
116
|
+
# GemStack::Realtime.present_on("rooms:1") # => [{ "id" => "7", "meta" => { "name" => "Ada" } }]
|
|
117
|
+
def present_on(channel) = presence.list(channel.to_s)
|
|
118
|
+
|
|
119
|
+
# identify's result → [key, meta] for presence. A Hash needs an :id; a
|
|
120
|
+
# model is keyed by #id; anything else by its string form.
|
|
121
|
+
def identity_key(identity)
|
|
122
|
+
case identity
|
|
123
|
+
when nil then nil
|
|
124
|
+
when Hash
|
|
125
|
+
meta = identity.transform_keys(&:to_s)
|
|
126
|
+
meta["id"].nil? ? nil : [meta["id"].to_s, meta]
|
|
127
|
+
else
|
|
128
|
+
id = identity.respond_to?(:id) ? identity.id : identity
|
|
129
|
+
[id.to_s, { "id" => id.to_s }]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
84
133
|
def build_broker(setting)
|
|
85
134
|
case setting
|
|
86
135
|
when :postgres, "postgres" then Brokers::Postgres.new
|
|
@@ -127,55 +176,107 @@ module GemStack
|
|
|
127
176
|
@broker = nil
|
|
128
177
|
@hub&.shutdown
|
|
129
178
|
@hub = nil
|
|
179
|
+
@presence&.stop
|
|
180
|
+
@presence = nil
|
|
130
181
|
@listening = nil
|
|
131
182
|
end
|
|
132
183
|
|
|
133
|
-
private
|
|
134
|
-
|
|
135
184
|
def next_id = "#{Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)}-#{SecureRandom.hex(4)}"
|
|
136
185
|
end
|
|
137
186
|
|
|
138
|
-
#
|
|
187
|
+
# config/channels.rb — what browsers may do, deny-by-default:
|
|
139
188
|
#
|
|
140
189
|
# GemStack.channels do
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
190
|
+
# # Who is connecting (once per connection, from the handshake's cookies
|
|
191
|
+
# # or headers); nil for anonymous. A Hash ({ id:, name: }) is also the
|
|
192
|
+
# # presence metadata others see.
|
|
193
|
+
# identify { |request| current_user(request)&.then { |user| { id: user.id, name: user.name } } }
|
|
194
|
+
#
|
|
195
|
+
# channel "announcements" # anyone may subscribe
|
|
196
|
+
# channel "orders:*" do |order_id, request| # * = one segment, passed to the block
|
|
197
|
+
# Order.find_by(id: order_id)&.user_id == identity(request)&.fetch(:id)
|
|
144
198
|
# end
|
|
145
|
-
#
|
|
199
|
+
# channel "rooms:*", presence: true do |room_id, request| … end # + who's here
|
|
146
200
|
#
|
|
147
|
-
#
|
|
201
|
+
# # Messages browsers send with realtime.send(channel, event, data);
|
|
202
|
+
# # only to channels they're subscribed to. The return value is the reply.
|
|
203
|
+
# receive "rooms:*" do |message|
|
|
204
|
+
# post = Post.create!(room_id: message.params.first, body: message.data["body"],
|
|
205
|
+
# user_id: message.identity[:id])
|
|
206
|
+
# GemStack.broadcast(message.channel, "post.created", post)
|
|
207
|
+
# end
|
|
208
|
+
# end
|
|
148
209
|
class Channels
|
|
149
|
-
Rule = Struct.new(:pattern, :regex, :block)
|
|
210
|
+
Rule = Struct.new(:pattern, :regex, :block, :presence)
|
|
211
|
+
IDENTITY = "gemstack.realtime.identity"
|
|
150
212
|
|
|
151
213
|
def initialize
|
|
152
214
|
@rules = []
|
|
215
|
+
@receivers = []
|
|
216
|
+
@identify = nil
|
|
153
217
|
end
|
|
154
218
|
|
|
155
219
|
def draw(&) = instance_exec(&)
|
|
156
|
-
def clear = @rules.clear
|
|
157
220
|
def rules = @rules.dup
|
|
158
221
|
|
|
159
|
-
def
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
222
|
+
def clear
|
|
223
|
+
@rules.clear
|
|
224
|
+
@receivers.clear
|
|
225
|
+
@identify = nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def channel(pattern, presence: false, &block)
|
|
229
|
+
@rules << Rule.new(pattern.to_s, compile(pattern), block, presence)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def receive(pattern, &block)
|
|
233
|
+
raise ArgumentError, "receive needs a block" unless block
|
|
164
234
|
|
|
165
|
-
|
|
166
|
-
part == "*" ? "([^:]+)" : Regexp.escape(part)
|
|
167
|
-
end.join(":")}\\z")
|
|
168
|
-
@rules << Rule.new(pattern, regex, block)
|
|
235
|
+
@receivers << Rule.new(pattern.to_s, compile(pattern), block, false)
|
|
169
236
|
end
|
|
170
237
|
|
|
171
|
-
def
|
|
238
|
+
def identify(&block)
|
|
239
|
+
@identify = block
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The identity of the connection a handshake request belongs to.
|
|
243
|
+
def identity(request) = request.env[IDENTITY]
|
|
244
|
+
|
|
245
|
+
def identify_request(request)
|
|
246
|
+
request.env[IDENTITY] = @identify&.call(request)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The rule allowing `name`, or nil (the first matching rule decides).
|
|
250
|
+
def authorize(name, request)
|
|
172
251
|
@rules.each do |rule|
|
|
173
252
|
match = rule.regex.match(name) or next
|
|
174
|
-
return
|
|
253
|
+
return rule if rule.block.nil? || rule.block.call(*match.captures, request)
|
|
175
254
|
|
|
176
|
-
return
|
|
255
|
+
return nil
|
|
256
|
+
end
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def authorized?(name, request) = !authorize(name, request).nil?
|
|
261
|
+
|
|
262
|
+
# [handler, params] for a message to `name`, or nil.
|
|
263
|
+
def receiver(name)
|
|
264
|
+
@receivers.each do |rule|
|
|
265
|
+
match = rule.regex.match(name) or next
|
|
266
|
+
return [rule.block, match.captures]
|
|
177
267
|
end
|
|
178
|
-
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private
|
|
272
|
+
|
|
273
|
+
def compile(pattern)
|
|
274
|
+
pattern = pattern.to_s
|
|
275
|
+
unless pattern.split(":").all? { |part| part == "*" || CHANNEL_NAME.match?(part) }
|
|
276
|
+
raise ArgumentError, "invalid channel pattern #{pattern.inspect}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
Regexp.new("\\A#{pattern.split(":").map { |part| part == "*" ? "([^:]+)" : Regexp.escape(part) }.join(":")}\\z")
|
|
179
280
|
end
|
|
180
281
|
end
|
|
181
282
|
end
|
|
@@ -192,9 +293,13 @@ module GemStack
|
|
|
192
293
|
end
|
|
193
294
|
end
|
|
194
295
|
|
|
296
|
+
require_relative "realtime/websocket/codec"
|
|
195
297
|
require_relative "realtime/hub"
|
|
298
|
+
require_relative "realtime/presence"
|
|
299
|
+
require_relative "realtime/dispatcher"
|
|
196
300
|
require_relative "realtime/connection"
|
|
197
301
|
require_relative "realtime/streamer"
|
|
302
|
+
require_relative "realtime/websocket/connection"
|
|
198
303
|
require_relative "realtime/middleware"
|
|
199
304
|
require_relative "realtime/brokers/memory"
|
|
200
305
|
require_relative "realtime/brokers/test"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gemstack-realtime
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Adware Technologies
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - '='
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.
|
|
19
|
+
version: 0.4.0
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - '='
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0.
|
|
26
|
+
version: 0.4.0
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: nio4r
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -53,10 +53,14 @@ files:
|
|
|
53
53
|
- lib/gemstack/realtime/brokers/redis.rb
|
|
54
54
|
- lib/gemstack/realtime/brokers/test.rb
|
|
55
55
|
- lib/gemstack/realtime/connection.rb
|
|
56
|
+
- lib/gemstack/realtime/dispatcher.rb
|
|
56
57
|
- lib/gemstack/realtime/hub.rb
|
|
57
58
|
- lib/gemstack/realtime/middleware.rb
|
|
59
|
+
- lib/gemstack/realtime/presence.rb
|
|
58
60
|
- lib/gemstack/realtime/streamer.rb
|
|
59
61
|
- lib/gemstack/realtime/testing.rb
|
|
62
|
+
- lib/gemstack/realtime/websocket/codec.rb
|
|
63
|
+
- lib/gemstack/realtime/websocket/connection.rb
|
|
60
64
|
homepage: https://github.com/gemstack-rb/gemstack
|
|
61
65
|
licenses:
|
|
62
66
|
- MIT
|
|
@@ -82,5 +86,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
82
86
|
requirements: []
|
|
83
87
|
rubygems_version: 4.0.20
|
|
84
88
|
specification_version: 4
|
|
85
|
-
summary: 'GemStack realtime
|
|
89
|
+
summary: 'GemStack realtime (WebSockets or Server-Sent Events): broadcasts, channels,
|
|
90
|
+
presence, messages'
|
|
86
91
|
test_files: []
|