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.
@@ -6,17 +6,20 @@ require "gemstack/schema"
6
6
  require "gemstack/http"
7
7
 
8
8
  module GemStack
9
- # Realtime updates to browsers (ARCHITECTURE §10).
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
- # Transport: Server-Sent Events on `<api_path>/realtime`, one multiplexed
17
- # stream per browser tab, served off the server's request threads. Fan-out
18
- # between processes goes through a broker (PostgreSQL LISTEN/NOTIFY, or
19
- # Redis). Channels are deny-by-default: declare them in config/channels.rb.
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 keep-alive comments (keeps proxies from closing idle streams).
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
- def sse = "id: #{id}\ndata: #{json}\n\n"
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
- # Channel authorization (config/channels.rb):
187
+ # config/channels.rb — what browsers may do, deny-by-default:
139
188
  #
140
189
  # GemStack.channels do
141
- # channel "announcements" # anyone may subscribe
142
- # channel "orders:*" do |order_id, request| # * = one segment, passed to the block
143
- # Order.find_by(id: order_id)&.visible_to?(current_user(request))
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
- # end
199
+ # channel "rooms:*", presence: true do |room_id, request| … end # + who's here
146
200
  #
147
- # Channels that match no rule are refused (403).
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 channel(pattern, &block)
160
- pattern = pattern.to_s
161
- unless pattern.split(":").all? { |part| part == "*" || CHANNEL_NAME.match?(part) }
162
- raise ArgumentError, "invalid channel pattern #{pattern.inspect}"
163
- end
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
- regex = Regexp.new("\\A#{pattern.split(":").map do |part|
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 authorized?(name, request)
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 true unless rule.block
253
+ return rule if rule.block.nil? || rule.block.call(*match.captures, request)
175
254
 
176
- return rule.block.call(*match.captures, request) ? true : false
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
- false
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.3.6
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.3.6
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.3.6
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: GemStack.broadcast to browsers over Server-Sent Events'
89
+ summary: 'GemStack realtime (WebSockets or Server-Sent Events): broadcasts, channels,
90
+ presence, messages'
86
91
  test_files: []