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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +28 -0
- data/lib/gemstack/realtime/brokers/memory.rb +30 -0
- data/lib/gemstack/realtime/brokers/postgres.rb +76 -0
- data/lib/gemstack/realtime/brokers/redis.rb +70 -0
- data/lib/gemstack/realtime/brokers/test.rb +31 -0
- data/lib/gemstack/realtime/connection.rb +75 -0
- data/lib/gemstack/realtime/hub.rb +76 -0
- data/lib/gemstack/realtime/middleware.rb +125 -0
- data/lib/gemstack/realtime/streamer.rb +119 -0
- data/lib/gemstack/realtime/testing.rb +47 -0
- data/lib/gemstack/realtime.rb +220 -0
- metadata +113 -0
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
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: []
|