gemstack-realtime 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 383b5db1904ae2687941a4af6aac241b9d4a4d37e3187ae92f751fdbbe762b18
4
+ data.tar.gz: ac673f15bb730c70e0cce0da8c7fc6390905bf2bd18dd887fdd4222f866c8430
5
+ SHA512:
6
+ metadata.gz: 76449b084ad86256f962c403afba444ee180a3b9add95f26f4fcb4ee25b7e093f7e01945931e0e5483ad543016e12e04647a0e6f805409facb75aadbde7b1ba1
7
+ data.tar.gz: 1b8310889e833012342f8b890217c496d639c7323b8f7b5339157d8ea3ae2cd3e05237c0f4cb05255f7daaea416f588d43d1fcb2ef75437c1f74d6a74f8425c7
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release. See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shoaib Malik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # gemstack-realtime
2
+
3
+ GemStack realtime: GemStack.broadcast to browsers over Server-Sent Events.
4
+
5
+ Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby API framework for Next.js
6
+ applications. All GemStack gems are developed together in that repository and released with the same
7
+ version.
8
+
9
+ ## Installation
10
+
11
+ Optional module — `gemstack add realtime`:
12
+
13
+ ```ruby
14
+ gem "gemstack-realtime", "~> 0.1"
15
+ ```
16
+
17
+ ## Documentation
18
+
19
+ - [Guide](https://github.com/gemstack-rb/gemstack/blob/main/docs/realtime.md)
20
+ - [All guides](https://github.com/gemstack-rb/gemstack/tree/main/docs) ·
21
+ [Architecture](https://github.com/gemstack-rb/gemstack/blob/main/ARCHITECTURE.md)
22
+
23
+ Source, issues and pull requests: [gemstack-rb/gemstack](https://github.com/gemstack-rb/gemstack)
24
+ (this gem lives in `gems/gemstack-realtime`).
25
+
26
+ ## License
27
+
28
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Realtime
5
+ module Brokers
6
+ # Delivers within the current process only (the default without a
7
+ # database). Broadcasts made inside a database transaction are sent
8
+ # after it commits, like with the PostgreSQL broker.
9
+ class Memory
10
+ def start(&on_message)
11
+ @on_message = on_message
12
+ self
13
+ end
14
+
15
+ def publish(message)
16
+ after_commit { @on_message&.call(message) }
17
+ end
18
+
19
+ def stop = @on_message = nil
20
+
21
+ private
22
+
23
+ def after_commit(&)
24
+ db = defined?(GemStack::DB) && GemStack::DB.connected? ? GemStack::DB.connection : nil
25
+ db&.in_transaction? ? db.after_commit(&) : yield
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Realtime
5
+ module Brokers
6
+ # Fan-out between processes with PostgreSQL LISTEN/NOTIFY (the default
7
+ # with gemstack-db): a broadcast from a job worker reaches browsers
8
+ # connected to any API process, with no extra infrastructure.
9
+ #
10
+ # NOTIFY is transactional, so a broadcast inside a transaction is
11
+ # delivered only if — and when — it commits. Payloads are limited to
12
+ # ~8 KB by PostgreSQL: broadcast what changed (ids, small records) and
13
+ # let clients refetch larger data.
14
+ class Postgres
15
+ CHANNEL = "gemstack_realtime"
16
+ MAX_PAYLOAD = 7_900
17
+
18
+ def initialize(db: nil)
19
+ @db = db
20
+ end
21
+
22
+ def db
23
+ @db || begin
24
+ require "gemstack/db"
25
+ GemStack::DB.connection
26
+ end
27
+ end
28
+
29
+ def publish(message)
30
+ payload = message.json
31
+ if payload.bytesize > MAX_PAYLOAD
32
+ raise PayloadTooLarge, "realtime payload is #{payload.bytesize} bytes; the PostgreSQL broker allows " \
33
+ "#{MAX_PAYLOAD}. Broadcast ids or a smaller serializer, or use the Redis broker."
34
+ end
35
+ db.notify(CHANNEL, payload: payload)
36
+ end
37
+
38
+ # Listens on a dedicated connection in a background thread.
39
+ def start(&on_message)
40
+ @running = true
41
+ ready = Queue.new
42
+ @thread = Thread.new { listen(on_message, ready) }
43
+ ready.pop(timeout: 5)
44
+ self
45
+ end
46
+
47
+ def stop
48
+ @running = false
49
+ @thread&.join(2)
50
+ end
51
+
52
+ private
53
+
54
+ def listen(on_message, ready)
55
+ while @running
56
+ begin
57
+ db.listen(CHANNEL, loop: ->(_conn) { throw :stop unless @running }, timeout: 1,
58
+ after_listen: ->(_conn) { ready << true }) do |_channel, _pid, payload|
59
+ deliver(on_message, payload)
60
+ end
61
+ rescue Sequel::Error => e
62
+ GemStack.logger.warn("realtime: LISTEN failed, retrying", error: e.message)
63
+ sleep 1
64
+ end
65
+ end
66
+ end
67
+
68
+ def deliver(on_message, payload)
69
+ on_message.call(Message.from_json(payload))
70
+ rescue StandardError => e
71
+ GemStack.logger.error("realtime: delivery failed", error: e)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Realtime
5
+ module Brokers
6
+ # Fan-out through Redis pub/sub (add `gem "redis-client"`). Use it for
7
+ # large payloads or very high broadcast rates. Broadcasts inside a
8
+ # database transaction are published after commit.
9
+ class Redis
10
+ def initialize(url: Realtime.config.redis_url, channel: Realtime.config.redis_channel)
11
+ require "redis-client"
12
+ @config = RedisClient.config(url: url)
13
+ @channel = channel
14
+ @publisher = @config.new_pool(size: 5, timeout: 1.0)
15
+ rescue LoadError
16
+ raise ConfigurationError, 'the Redis realtime broker needs `gem "redis-client"` in the Gemfile'
17
+ end
18
+
19
+ def publish(message)
20
+ after_commit { @publisher.call("PUBLISH", @channel, message.json) }
21
+ end
22
+
23
+ def start(&on_message)
24
+ @running = true
25
+ ready = Queue.new
26
+ @thread = Thread.new { listen(on_message, ready) }
27
+ ready.pop(timeout: 5)
28
+ self
29
+ end
30
+
31
+ def stop
32
+ @running = false
33
+ @thread&.join(2)
34
+ end
35
+
36
+ private
37
+
38
+ def listen(on_message, ready)
39
+ while @running
40
+ begin
41
+ pubsub = @config.new_client.pubsub
42
+ pubsub.call("SUBSCRIBE", @channel)
43
+ ready << true
44
+ while @running
45
+ event = pubsub.next_event(1) or next
46
+ deliver(on_message, event[2]) if event[0] == "message"
47
+ end
48
+ rescue RedisClient::Error => e
49
+ GemStack.logger.warn("realtime: Redis subscription failed, retrying", error: e.message)
50
+ sleep 1
51
+ ensure
52
+ pubsub&.close
53
+ end
54
+ end
55
+ end
56
+
57
+ def deliver(on_message, payload)
58
+ on_message.call(Message.from_json(payload))
59
+ rescue StandardError => e
60
+ GemStack.logger.error("realtime: delivery failed", error: e)
61
+ end
62
+
63
+ def after_commit(&)
64
+ db = defined?(GemStack::DB) && GemStack::DB.connected? ? GemStack::DB.connection : nil
65
+ db&.in_transaction? ? db.after_commit(&) : yield
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Realtime
5
+ module Brokers
6
+ # Records broadcasts for assertions (the default in tests); see
7
+ # GemStack::Realtime::Testing.
8
+ class Test
9
+ attr_reader :messages
10
+
11
+ def initialize
12
+ @messages = []
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def start(&on_message)
17
+ @on_message = on_message
18
+ self
19
+ end
20
+
21
+ def publish(message)
22
+ @mutex.synchronize { @messages << message }
23
+ @on_message&.call(message)
24
+ end
25
+
26
+ def clear = @mutex.synchronize { @messages.clear }
27
+ def stop = @on_message = nil
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Realtime
5
+ # One browser's event stream on a hijacked socket. #push never blocks:
6
+ # bytes the socket can't take yet are buffered and flushed by the
7
+ # Streamer when it becomes writable. A client more than max_buffer bytes
8
+ # behind is disconnected (it reconnects and replays or refetches).
9
+ class Connection
10
+ attr_reader :io, :channels
11
+
12
+ def initialize(io, channels, streamer:, max_buffer: Realtime.config.max_buffer)
13
+ @io = io
14
+ @channels = channels.freeze
15
+ @streamer = streamer
16
+ @max_buffer = max_buffer
17
+ @buffer = String.new(encoding: Encoding::BINARY)
18
+ @mutex = Mutex.new
19
+ @closed = false
20
+ end
21
+
22
+ def closed? = @closed
23
+ def pending? = @mutex.synchronize { !@buffer.empty? }
24
+
25
+ def push(bytes)
26
+ wants_write = @mutex.synchronize do
27
+ return false if @closed
28
+
29
+ @buffer << bytes.b
30
+ if @buffer.bytesize > @max_buffer
31
+ GemStack.logger.warn("realtime: slow client disconnected", buffered: @buffer.bytesize)
32
+ close_locked
33
+ return false
34
+ end
35
+ !flush_locked
36
+ end
37
+ @streamer.want_write(self) if wants_write
38
+ true
39
+ end
40
+
41
+ # Writes as much as the socket accepts. Returns true when fully flushed.
42
+ def flush = @mutex.synchronize { @closed || flush_locked }
43
+
44
+ def close
45
+ @mutex.synchronize { close_locked }
46
+ end
47
+
48
+ private
49
+
50
+ def flush_locked
51
+ until @buffer.empty?
52
+ written = @io.write_nonblock(@buffer, exception: false)
53
+ return false if written == :wait_writable
54
+
55
+ @buffer = @buffer.byteslice(written, @buffer.bytesize) || String.new(encoding: Encoding::BINARY)
56
+ end
57
+ true
58
+ rescue IOError, SystemCallError
59
+ close_locked
60
+ true
61
+ end
62
+
63
+ def close_locked
64
+ return if @closed
65
+
66
+ @closed = true
67
+ @buffer.clear
68
+ @io.close unless @io.closed?
69
+ @streamer.closed(self)
70
+ rescue IOError, SystemCallError
71
+ nil
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Realtime
5
+ # This process's subscriptions and replay history. The broker's listener
6
+ # calls #deliver; connections are only ever written to without blocking.
7
+ class Hub
8
+ Entry = Struct.new(:message, :at)
9
+
10
+ def initialize(replay_size: Realtime.config.replay_size, replay_ttl: Realtime.config.replay_ttl)
11
+ # Identity sets: a subscriber is the same subscriber however its state changes.
12
+ @subscriptions = Hash.new { |hash, key| hash[key] = Set.new.compare_by_identity }
13
+ @history = []
14
+ @replay_size = replay_size
15
+ @replay_ttl = replay_ttl
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def add(connection)
20
+ @mutex.synchronize { connection.channels.each { |channel| @subscriptions[channel] << connection } }
21
+ connection
22
+ end
23
+
24
+ def remove(connection)
25
+ @mutex.synchronize do
26
+ connection.channels.each do |channel|
27
+ subscribers = @subscriptions[channel]
28
+ subscribers.delete(connection)
29
+ @subscriptions.delete(channel) if subscribers.empty?
30
+ end
31
+ end
32
+ end
33
+
34
+ def connections = @mutex.synchronize { @subscriptions.values.flat_map(&:to_a).uniq(&:object_id) }
35
+
36
+ def subscriber_count(channel)
37
+ @mutex.synchronize { @subscriptions.key?(channel) ? @subscriptions[channel].size : 0 }
38
+ end
39
+
40
+ def deliver(message)
41
+ subscribers = @mutex.synchronize do
42
+ @history << Entry.new(message, monotonic)
43
+ @history.shift while @history.size > @replay_size
44
+ @subscriptions.key?(message.channel) ? @subscriptions[message.channel].to_a : []
45
+ end
46
+ payload = message.sse
47
+ subscribers.each { |connection| connection.push(payload) }
48
+ subscribers.size
49
+ end
50
+
51
+ # Events on `channels` published after the event with id `last_id`.
52
+ # Returns [messages, gap]; gap is true when last_id is no longer in the
53
+ # history, so the client may have missed events and should refetch.
54
+ def replay(channels, last_id)
55
+ return [[], false] if last_id.nil? || last_id.empty?
56
+
57
+ @mutex.synchronize do
58
+ cutoff = monotonic - @replay_ttl
59
+ @history.reject! { |entry| entry.at < cutoff }
60
+ index = @history.rindex { |entry| entry.message.id == last_id }
61
+ return [[], true] unless index
62
+
63
+ [@history[(index + 1)..].map(&:message).select { |m| channels.include?(m.channel) }, false]
64
+ end
65
+ end
66
+
67
+ def shutdown
68
+ connections.each(&:close)
69
+ end
70
+
71
+ private
72
+
73
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemStack
4
+ module Realtime
5
+ # GET <api_path>/realtime?channels=a,b[&last_event_id=...]
6
+ #
7
+ # Validates and authorizes the channels (400 as a JSON error; 403 only when
8
+ # none is allowed — refused channels otherwise get a gemstack.denied event), then
9
+ # takes the socket over from the server (Rack full hijack, supported by
10
+ # Puma) and hands it to the Streamer, freeing the request thread. The
11
+ # response is a standard text/event-stream: events missed since
12
+ # Last-Event-ID are replayed first, or a `gemstack.gap` event tells the
13
+ # client to refetch because they're no longer available.
14
+ class Middleware
15
+ HEADERS = [
16
+ "HTTP/1.1 200 OK", "content-type: text/event-stream; charset=utf-8", "cache-control: no-cache, no-transform",
17
+ "x-accel-buffering: no", "x-content-type-options: nosniff", "connection: close"
18
+ ].freeze
19
+
20
+ def initialize(app, path: nil)
21
+ @app = app
22
+ @path = path
23
+ end
24
+
25
+ def call(env)
26
+ return @app.call(env) unless env[Rack::PATH_INFO] == path && env[Rack::REQUEST_METHOD] == "GET"
27
+
28
+ request = Rack::Request.new(env)
29
+ requested = requested_channels(request)
30
+ channels, denied = requested.partition { |channel| Realtime.channels.authorized?(channel, request) }
31
+ if channels.empty?
32
+ raise Forbidden.new("Not allowed to subscribe to #{denied.join(", ")}",
33
+ code: "channel_forbidden")
34
+ end
35
+
36
+ Realtime.listen!
37
+ last_id = env["HTTP_LAST_EVENT_ID"] || request.GET["last_event_id"]
38
+ preamble = preamble(channels, last_id, denied)
39
+ env["rack.hijack?"] ? hijack(env, channels, preamble) : stream(channels, preamble)
40
+ end
41
+
42
+ private
43
+
44
+ def path = @path ||= Realtime.config.path
45
+
46
+ def requested_channels(request)
47
+ channels = request.GET["channels"].to_s.split(",").map(&:strip).reject(&:empty?).uniq
48
+ raise BadRequest.new("Give at least one channel: ?channels=a,b", code: "channels_required") if channels.empty?
49
+
50
+ max = Realtime.config.max_channels
51
+ raise BadRequest.new("At most #{max} channels per connection", code: "too_many_channels") if channels.size > max
52
+
53
+ invalid = channels.grep_v(CHANNEL_NAME)
54
+ if invalid.any?
55
+ raise InvalidChannel.new("Invalid channel name(s): #{invalid.join(", ")}",
56
+ code: "invalid_channel")
57
+ end
58
+
59
+ channels
60
+ end
61
+
62
+ # One multiplexed stream serves many subscriptions, so a refused channel
63
+ # doesn't fail the others: its handlers get a `gemstack.denied` event.
64
+ def preamble(channels, last_id, denied)
65
+ messages, gap = Realtime.hub.replay(channels, last_id)
66
+ text = "retry: #{Realtime.config.retry_ms}\n\n"
67
+ denied.each { |channel| text << system_event("gemstack.denied", channel) }
68
+ messages.each { |message| text << message.sse }
69
+ text << system_event("gemstack.gap", nil) if gap
70
+ text
71
+ end
72
+
73
+ def system_event(name, channel) = "data: #{Message.new(nil, channel, name, nil).json}\n\n"
74
+
75
+ def hijack(env, channels, preamble)
76
+ io = env["rack.hijack"].call
77
+ io.write("#{HEADERS.join("\r\n")}\r\n\r\n")
78
+ streamer = Streamer.instance
79
+ connection = Connection.new(io, channels, streamer: streamer)
80
+ Realtime.hub.add(connection)
81
+ connection.push(preamble)
82
+ streamer.add(connection)
83
+ [200, {}, []] # ignored by the server after a full hijack
84
+ end
85
+
86
+ # Servers without full hijack: stream from the request thread (holds it
87
+ # for the life of the connection).
88
+ def stream(channels, preamble)
89
+ unless @warned
90
+ GemStack.logger.warn("realtime: server doesn't support rack.hijack; streaming on a request thread")
91
+ end
92
+ @warned = true
93
+ body = QueueBody.new(channels, preamble)
94
+ [200, { "content-type" => "text/event-stream; charset=utf-8", "cache-control" => "no-cache, no-transform" },
95
+ body]
96
+ end
97
+
98
+ # A Rack body fed by the hub (fallback path). Closing it unsubscribes.
99
+ class QueueBody
100
+ attr_reader :channels
101
+
102
+ def initialize(channels, preamble)
103
+ @channels = channels.freeze
104
+ @queue = Queue.new
105
+ @queue << preamble
106
+ Realtime.hub.add(self)
107
+ end
108
+
109
+ def push(bytes) = @queue << bytes
110
+ def closed? = @queue.closed?
111
+
112
+ def each
113
+ while (chunk = @queue.pop)
114
+ yield chunk
115
+ end
116
+ end
117
+
118
+ def close
119
+ Realtime.hub.remove(self)
120
+ @queue.close
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nio"
4
+
5
+ module GemStack
6
+ module Realtime
7
+ # A single event-loop thread (nio4r) that owns every open stream in the
8
+ # process: it notices disconnects (readable + EOF), flushes buffered
9
+ # writes when sockets become writable, and sends heartbeats. Request
10
+ # threads only hand connections over, so 10,000 open streams cost no
11
+ # server threads.
12
+ class Streamer
13
+ def initialize(heartbeat: Realtime.config.heartbeat, hub: Realtime.hub)
14
+ @heartbeat = heartbeat
15
+ @hub = hub
16
+ @selector = NIO::Selector.new
17
+ @commands = Queue.new
18
+ @monitors = {}
19
+ @thread = nil
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ def self.instance
24
+ @mutex ||= Mutex.new
25
+ @instance || @mutex.synchronize { @instance ||= new.start }
26
+ end
27
+
28
+ def self.reset!
29
+ @instance&.stop
30
+ @instance = nil
31
+ end
32
+
33
+ def start
34
+ @running = true
35
+ @thread = Thread.new { run }
36
+ self
37
+ end
38
+
39
+ def stop
40
+ @running = false
41
+ @selector.wakeup
42
+ @thread&.join(2)
43
+ @monitors.each_key(&:close)
44
+ end
45
+
46
+ def size = @monitors.size
47
+
48
+ # Called from request threads.
49
+ def add(connection) = command(:add, connection)
50
+ def want_write(connection) = command(:write, connection)
51
+ def closed(connection) = command(:remove, connection)
52
+
53
+ private
54
+
55
+ def command(name, connection)
56
+ @commands << [name, connection]
57
+ @selector.wakeup
58
+ end
59
+
60
+ def run
61
+ next_beat = monotonic + @heartbeat
62
+ while @running
63
+ @selector.select([next_beat - monotonic, 0.01].max) { |monitor| ready(monitor) }
64
+ drain_commands
65
+ next if monotonic < next_beat
66
+
67
+ @monitors.each_key { |connection| connection.push(": ping\n\n") }
68
+ next_beat = monotonic + @heartbeat
69
+ end
70
+ rescue StandardError => e
71
+ GemStack.logger.error("realtime streamer crashed", error: e, backtrace: Array(e.backtrace).first(10))
72
+ retry if @running
73
+ end
74
+
75
+ def drain_commands
76
+ until @commands.empty?
77
+ name, connection = @commands.pop
78
+ case name
79
+ when :add then register(connection)
80
+ when :write then @monitors[connection]&.interests = :rw
81
+ when :remove then deregister(connection)
82
+ end
83
+ end
84
+ end
85
+
86
+ def register(connection)
87
+ return if connection.closed? || @monitors.key?(connection)
88
+
89
+ monitor = @selector.register(connection.io, connection.pending? ? :rw : :r)
90
+ monitor.value = connection
91
+ @monitors[connection] = monitor
92
+ rescue IOError, SystemCallError
93
+ connection.close
94
+ end
95
+
96
+ def deregister(connection)
97
+ monitor = @monitors.delete(connection) or return
98
+ monitor.close
99
+ @hub.remove(connection)
100
+ end
101
+
102
+ def ready(monitor)
103
+ connection = monitor.value
104
+ if monitor.readable?
105
+ # SSE clients never send data after the request; readable means EOF.
106
+ data = connection.io.read_nonblock(1024, exception: false)
107
+ return connection.close if data.nil?
108
+ end
109
+ return unless monitor.writable?
110
+
111
+ monitor.interests = :r if connection.flush
112
+ rescue IOError, SystemCallError
113
+ connection.close
114
+ end
115
+
116
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gemstack/realtime"
4
+
5
+ module GemStack
6
+ module Realtime
7
+ # Test helpers (included into GemStack::TestCase by the generated test helper):
8
+ #
9
+ # def test_updating_an_order_notifies_its_viewers
10
+ # patch_json "/api/orders/#{order.id}", { status: "shipped" }
11
+ #
12
+ # assert_broadcast "orders:#{order.id}", "order.updated"
13
+ # end
14
+ module Testing
15
+ def self.included(base)
16
+ base.class_eval do
17
+ def before_setup
18
+ super
19
+ GemStack::Realtime.broker = GemStack::Realtime::Brokers::Test.new
20
+ end
21
+ end
22
+ end
23
+
24
+ def broadcasts = Realtime.broker.messages
25
+
26
+ # A broadcast on channel (optionally with this event name / data) happened.
27
+ def assert_broadcast(channel, event = nil, data: nil)
28
+ expected = data.nil? ? nil : as_json(data)
29
+ matching = broadcasts.select do |m|
30
+ m.channel == channel && (event.nil? || m.event == event.to_s) &&
31
+ (expected.nil? || as_json(m.data) == expected)
32
+ end
33
+ assert(!matching.empty?,
34
+ "Expected a broadcast on #{channel}#{" (#{event})" if event}; got #{broadcasts.map do |m|
35
+ [m.channel, m.event]
36
+ end}")
37
+ end
38
+
39
+ # What a browser receives (string keys, decimals as strings, ...).
40
+ def as_json(value) = JSON.parse(HTTP::JSONCodec.default.dump(value))
41
+
42
+ def refute_broadcast(channel)
43
+ assert(broadcasts.none? { |m| m.channel == channel }, "Expected no broadcast on #{channel}")
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "gemstack/core"
5
+ require "gemstack/schema"
6
+ require "gemstack/http"
7
+
8
+ module GemStack
9
+ # Realtime updates to browsers (ARCHITECTURE §10, DECISIONS D-044).
10
+ #
11
+ # GemStack.broadcast("orders:#{order.id}", "order.updated", order) # anywhere: controllers, jobs, console
12
+ #
13
+ # // browser
14
+ # realtime.subscribe(`orders:${id}`, (event) => { ... })
15
+ #
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 by
19
+ # default). Channels are deny-by-default: declare them in config/channels.rb.
20
+ module Realtime
21
+ class Config < Settings
22
+ setting :path, default: -> { "#{GemStack.config.http.api_path}/realtime" }
23
+ # :postgres (default with gemstack-db), :memory (single process),
24
+ # :redis, :test (default in tests), or a broker object.
25
+ setting :broker, default: lambda {
26
+ if GemStack.env.test? then :test
27
+ elsif defined?(GemStack::DB) then :postgres
28
+ else :memory
29
+ end
30
+ }
31
+ # Seconds between keep-alive comments (keeps proxies from closing idle streams).
32
+ setting :heartbeat, default: 15
33
+ # Recent events kept per process for Last-Event-ID replay after reconnects.
34
+ setting :replay_size, default: 1_000
35
+ setting :replay_ttl, default: 300
36
+ setting :max_channels, default: 50
37
+ # A client that falls this far behind (bytes buffered) is disconnected.
38
+ setting :max_buffer, default: 1024 * 1024
39
+ # Reconnect delay the browser is told to use (ms).
40
+ setting :retry_ms, default: 3_000
41
+ setting :redis_url, default: -> { ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
42
+ setting :redis_channel, default: -> { "gemstack:realtime:#{GemStack.config.name}" }
43
+ end
44
+
45
+ CHANNEL_NAME = /\A[A-Za-z0-9_\-.:]{1,200}\z/
46
+
47
+ class InvalidChannel < BadRequest; end
48
+ class PayloadTooLarge < Error; end
49
+
50
+ # One broadcast.
51
+ Message = Struct.new(:id, :channel, :event, :data) do
52
+ def to_h = { id: id, channel: channel, event: event, data: data }
53
+ def json = @json ||= HTTP::JSONCodec.default.dump(to_h)
54
+ def sse = "id: #{id}\ndata: #{json}\n\n"
55
+
56
+ def self.from_json(string)
57
+ hash = JSON.parse(string)
58
+ new(hash["id"], hash["channel"], hash["event"], hash["data"])
59
+ end
60
+ end
61
+
62
+ @mutex = Mutex.new
63
+
64
+ class << self
65
+ def config = GemStack.config.realtime
66
+
67
+ def broker
68
+ @broker || @mutex.synchronize { @broker ||= build_broker(config.broker) }
69
+ end
70
+
71
+ attr_writer :broker
72
+
73
+ def hub
74
+ @hub || @mutex.synchronize { @hub ||= Hub.new }
75
+ end
76
+
77
+ def channels
78
+ @channels ||= Channels.new
79
+ end
80
+
81
+ def build_broker(setting)
82
+ case setting
83
+ when :postgres, "postgres" then Brokers::Postgres.new
84
+ when :memory, "memory" then Brokers::Memory.new
85
+ when :redis, "redis" then Brokers::Redis.new
86
+ when :test, "test" then Brokers::Test.new
87
+ else
88
+ unless setting.respond_to?(:publish)
89
+ raise ConfigurationError,
90
+ "a realtime broker must respond to #publish and #start"
91
+ end
92
+
93
+ setting
94
+ end
95
+ end
96
+
97
+ def broadcast(channel, event, data = nil, context: {})
98
+ channel = channel.to_s
99
+ raise InvalidChannel, "invalid channel name #{channel.inspect}" unless CHANNEL_NAME.match?(channel)
100
+
101
+ message = Message.new(next_id, channel, event.to_s, Serializer.render(data, context))
102
+ broker.publish(message)
103
+ GemStack.logger.debug("realtime.broadcast", channel: channel, event: message.event, id: message.id)
104
+ message.id
105
+ end
106
+
107
+ # Starts delivering broker messages to this process's connections (idempotent).
108
+ def listen!
109
+ return true if @listening
110
+
111
+ # Resolve these before locking: both lazily take the same mutex.
112
+ active_broker = broker
113
+ active_hub = hub
114
+ @mutex.synchronize do
115
+ @listening ||= begin
116
+ active_broker.start { |message| active_hub.deliver(message) }
117
+ true
118
+ end
119
+ end
120
+ end
121
+
122
+ def reset!
123
+ @broker&.stop if @broker.respond_to?(:stop)
124
+ @broker = nil
125
+ @hub&.shutdown
126
+ @hub = nil
127
+ @listening = nil
128
+ end
129
+
130
+ private
131
+
132
+ def next_id = "#{Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)}-#{SecureRandom.hex(4)}"
133
+ end
134
+
135
+ # Channel authorization (config/channels.rb):
136
+ #
137
+ # GemStack.channels do
138
+ # channel "announcements" # anyone may subscribe
139
+ # channel "orders:*" do |order_id, request| # * = one segment, passed to the block
140
+ # Order.find_by(id: order_id)&.visible_to?(current_user(request))
141
+ # end
142
+ # end
143
+ #
144
+ # Channels that match no rule are refused (403).
145
+ class Channels
146
+ Rule = Struct.new(:pattern, :regex, :block)
147
+
148
+ def initialize
149
+ @rules = []
150
+ end
151
+
152
+ def draw(&) = instance_exec(&)
153
+ def clear = @rules.clear
154
+ def rules = @rules.dup
155
+
156
+ def channel(pattern, &block)
157
+ pattern = pattern.to_s
158
+ unless pattern.split(":").all? { |part| part == "*" || CHANNEL_NAME.match?(part) }
159
+ raise ArgumentError, "invalid channel pattern #{pattern.inspect}"
160
+ end
161
+
162
+ regex = Regexp.new("\\A#{pattern.split(":").map do |part|
163
+ part == "*" ? "([^:]+)" : Regexp.escape(part)
164
+ end.join(":")}\\z")
165
+ @rules << Rule.new(pattern, regex, block)
166
+ end
167
+
168
+ def authorized?(name, request)
169
+ @rules.each do |rule|
170
+ match = rule.regex.match(name) or next
171
+ return true unless rule.block
172
+
173
+ return rule.block.call(*match.captures, request) ? true : false
174
+ end
175
+ false
176
+ end
177
+ end
178
+ end
179
+
180
+ class << self
181
+ def broadcast(...) = Realtime.broadcast(...)
182
+
183
+ # config/channels.rb: GemStack.channels { channel "announcements" }
184
+ def channels(&)
185
+ return Realtime.channels unless block_given?
186
+
187
+ Realtime.channels.draw(&)
188
+ end
189
+ end
190
+ end
191
+
192
+ require_relative "realtime/hub"
193
+ require_relative "realtime/connection"
194
+ require_relative "realtime/streamer"
195
+ require_relative "realtime/middleware"
196
+ require_relative "realtime/brokers/memory"
197
+ require_relative "realtime/brokers/test"
198
+ require_relative "realtime/brokers/postgres"
199
+ require_relative "realtime/brokers/redis"
200
+
201
+ GemStack::Config.namespace(:realtime, GemStack::Realtime::Config)
202
+
203
+ GemStack::Plugins.register(:realtime) do |app|
204
+ next unless app.respond_to?(:root)
205
+
206
+ stack = app.config.http.middleware
207
+ unless stack.include?(GemStack::Realtime::Middleware)
208
+ stack.insert_before(GemStack::HTTP::Middleware::HealthCheck, GemStack::Realtime::Middleware)
209
+ end
210
+ channels_file = app.root.join("config/channels.rb")
211
+ load_channels = lambda do
212
+ GemStack::Realtime.channels.clear
213
+ load channels_file.to_s if channels_file.file?
214
+ end
215
+ load_channels.call
216
+ app.on_reload(&load_channels) if app.respond_to?(:on_reload)
217
+ app.on_shutdown { GemStack::Realtime.reset! } if app.respond_to?(:on_shutdown)
218
+ # The broker's listener holds one database connection for the process.
219
+ app.config.db.pool_size += 1 if app.config.realtime.broker.to_s == "postgres" && app.config.respond_to?(:db)
220
+ end
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gemstack-realtime
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shoaib Malik
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: gemstack-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: gemstack-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: gemstack-schema
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - '='
45
+ - !ruby/object:Gem::Version
46
+ version: 0.1.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '='
52
+ - !ruby/object:Gem::Version
53
+ version: 0.1.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: nio4r
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.7'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.7'
68
+ email:
69
+ - gemstack26@gmail.com
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - CHANGELOG.md
75
+ - LICENSE.txt
76
+ - README.md
77
+ - lib/gemstack/realtime.rb
78
+ - lib/gemstack/realtime/brokers/memory.rb
79
+ - lib/gemstack/realtime/brokers/postgres.rb
80
+ - lib/gemstack/realtime/brokers/redis.rb
81
+ - lib/gemstack/realtime/brokers/test.rb
82
+ - lib/gemstack/realtime/connection.rb
83
+ - lib/gemstack/realtime/hub.rb
84
+ - lib/gemstack/realtime/middleware.rb
85
+ - lib/gemstack/realtime/streamer.rb
86
+ - lib/gemstack/realtime/testing.rb
87
+ homepage: https://github.com/gemstack-rb/gemstack
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ rubygems_mfa_required: 'true'
92
+ source_code_uri: https://github.com/gemstack-rb/gemstack/tree/main/gems/gemstack-realtime
93
+ changelog_uri: https://github.com/gemstack-rb/gemstack/blob/main/gems/gemstack-realtime/CHANGELOG.md
94
+ bug_tracker_uri: https://github.com/gemstack-rb/gemstack/issues
95
+ documentation_uri: https://github.com/gemstack-rb/gemstack/tree/main/docs
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '4.0'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 4.0.20
111
+ specification_version: 4
112
+ summary: 'GemStack realtime: GemStack.broadcast to browsers over Server-Sent Events'
113
+ test_files: []