rage-rb 1.22.0 → 1.23.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 +26 -0
- data/README.md +1 -1
- data/lib/rage/all.rb +1 -0
- data/lib/rage/configuration.rb +107 -0
- data/lib/rage/controller/api.rb +15 -5
- data/lib/rage/controller/renderers.rb +47 -0
- data/lib/rage/deferred/backends/disk.rb +19 -3
- data/lib/rage/deferred/metadata.rb +1 -1
- data/lib/rage/deferred/queue.rb +5 -4
- data/lib/rage/deferred/task.rb +72 -5
- data/lib/rage/errors.rb +3 -0
- data/lib/rage/internal.rb +36 -0
- data/lib/rage/logger/logger.rb +1 -1
- data/lib/rage/openapi/converter.rb +43 -2
- data/lib/rage/openapi/openapi.rb +11 -0
- data/lib/rage/openapi/parsers/ext/alba.rb +5 -4
- data/lib/rage/params_parser.rb +3 -1
- data/lib/rage/pubsub/adapters/redis.rb +147 -0
- data/lib/rage/pubsub/pubsub.rb +25 -0
- data/lib/rage/rails.rb +16 -0
- data/lib/rage/router/README.md +1 -1
- data/lib/rage/router/dsl.rb +67 -10
- data/lib/rage/sse/application.rb +30 -2
- data/lib/rage/sse/sse.rb +96 -0
- data/lib/rage/sse/stream.rb +78 -0
- data/lib/rage/telemetry/spans/broadcast_sse_stream.rb +50 -0
- data/lib/rage/telemetry/spans/process_sse_stream.rb +1 -0
- data/lib/rage/telemetry/telemetry.rb +2 -1
- data/lib/rage/uploaded_file.rb +3 -7
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +2 -1
- metadata +7 -2
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
if !defined?(RedisClient)
|
|
6
|
+
fail <<~ERR
|
|
7
|
+
|
|
8
|
+
Redis adapter depends on the `redis-client` gem. Ensure the following line is added to your Gemfile:
|
|
9
|
+
gem "redis-client"
|
|
10
|
+
|
|
11
|
+
ERR
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Rage::PubSub::Adapters::Redis
|
|
15
|
+
REDIS_STREAM_NAME = "rage:pubsub:messages"
|
|
16
|
+
DEFAULT_REDIS_OPTIONS = { reconnect_attempts: [0.05, 0.1, 0.5] }
|
|
17
|
+
DEFAULT_POOL_SIZE = 10
|
|
18
|
+
DEFAULT_POOL_TIMEOUT = 1
|
|
19
|
+
REDIS_MIN_VERSION_SUPPORTED = Gem::Version.create(6)
|
|
20
|
+
|
|
21
|
+
def initialize(config)
|
|
22
|
+
@redis_stream = if (prefix = config.delete(:channel_prefix))
|
|
23
|
+
"#{prefix}:#{REDIS_STREAM_NAME}"
|
|
24
|
+
else
|
|
25
|
+
REDIS_STREAM_NAME
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@pool_size = (config.delete(:pool_size) || DEFAULT_POOL_SIZE).to_i
|
|
29
|
+
@pool_timeout = (config.delete(:pool_timeout) || DEFAULT_POOL_TIMEOUT).to_f
|
|
30
|
+
@redis_config = RedisClient.config(**DEFAULT_REDIS_OPTIONS.merge(config))
|
|
31
|
+
@server_uuid = SecureRandom.uuid
|
|
32
|
+
@broadcasters = {}
|
|
33
|
+
|
|
34
|
+
redis_version = get_redis_version
|
|
35
|
+
if redis_version.nil?
|
|
36
|
+
return
|
|
37
|
+
elsif redis_version < REDIS_MIN_VERSION_SUPPORTED
|
|
38
|
+
raise "Redis adapter only supports Redis 6+. Detected Redis version: #{redis_version}."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@trimming_strategy = redis_version < Gem::Version.create("6.2.0") ? :maxlen : :minid
|
|
42
|
+
|
|
43
|
+
Rage::Internal.pick_a_worker do
|
|
44
|
+
puts("INFO: #{Process.pid} is managing Redis subscriptions.") if Rage.logger.info?
|
|
45
|
+
poll
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def add_broadcaster(broadcaster_id, broadcaster)
|
|
50
|
+
@broadcasters[broadcaster_id] = broadcaster
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def publish(broadcaster_id, stream_name, data)
|
|
54
|
+
message_uuid = SecureRandom.uuid
|
|
55
|
+
|
|
56
|
+
redis_pool.with do |redis|
|
|
57
|
+
redis.call(
|
|
58
|
+
"XADD",
|
|
59
|
+
@redis_stream,
|
|
60
|
+
trimming_method, "~", trimming_value,
|
|
61
|
+
"*",
|
|
62
|
+
"1", stream_name,
|
|
63
|
+
"2", data.to_json,
|
|
64
|
+
"3", @server_uuid,
|
|
65
|
+
"4", message_uuid,
|
|
66
|
+
"5", broadcaster_id
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def redis_pool
|
|
74
|
+
@redis_pool ||= @redis_config.new_pool(size: @pool_size, timeout: @pool_timeout)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def trimming_method
|
|
78
|
+
@trimming_strategy == :maxlen ? "MAXLEN" : "MINID"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def trimming_value
|
|
82
|
+
@trimming_strategy == :maxlen ? "10000" : ((Time.now.to_f - 5 * 60) * 1000).to_i
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def get_redis_version
|
|
86
|
+
service_redis = @redis_config.new_client
|
|
87
|
+
version = service_redis.call("INFO").match(/redis_version:([[:graph:]]+)/)[1]
|
|
88
|
+
|
|
89
|
+
Gem::Version.create(version)
|
|
90
|
+
|
|
91
|
+
rescue RedisClient::Error => e
|
|
92
|
+
puts "FATAL: Couldn't connect to Redis - all broadcasts will be limited to the current server."
|
|
93
|
+
puts e.backtrace.join("\n")
|
|
94
|
+
nil
|
|
95
|
+
|
|
96
|
+
ensure
|
|
97
|
+
service_redis.close
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def error_backoff_intervals
|
|
101
|
+
@error_backoff_intervals ||= Enumerator.new do |y|
|
|
102
|
+
y << 0.2 << 0.5 << 1 << 2 << 5
|
|
103
|
+
loop { y << 10 }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def poll
|
|
108
|
+
unless Fiber.scheduler
|
|
109
|
+
Fiber.set_scheduler(Rage::FiberScheduler.new)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
Iodine.on_state(:start_shutdown) do
|
|
113
|
+
@stopping = true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Fiber.schedule do
|
|
117
|
+
read_redis = @redis_config.new_client
|
|
118
|
+
last_id = (Time.now.to_f * 1000).to_i
|
|
119
|
+
last_message_uuid = nil
|
|
120
|
+
|
|
121
|
+
loop do
|
|
122
|
+
data = read_redis.blocking_call(5, "XREAD", "COUNT", "100", "BLOCK", "5000", "STREAMS", @redis_stream, last_id)
|
|
123
|
+
|
|
124
|
+
if data
|
|
125
|
+
data[@redis_stream].each do |id, (_, stream_name, _, serialized_data, _, server_uuid, _, message_uuid, _, broadcaster_id)|
|
|
126
|
+
if server_uuid != @server_uuid && message_uuid != last_message_uuid
|
|
127
|
+
@broadcasters[broadcaster_id]&.broadcast(stream_name, JSON.parse(serialized_data))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
last_id = id
|
|
131
|
+
last_message_uuid = message_uuid
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
break if @stopping
|
|
136
|
+
|
|
137
|
+
rescue RedisClient::Error => e
|
|
138
|
+
Rage.logger.error("Subscriber error: #{e.message} (#{e.class})")
|
|
139
|
+
sleep error_backoff_intervals.next
|
|
140
|
+
rescue SystemCallError => e
|
|
141
|
+
@stopping ? break : raise(e)
|
|
142
|
+
else
|
|
143
|
+
error_backoff_intervals.rewind
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
##
|
|
2
|
+
# The module provides the support for multi-server setups for `Rage::Cable` and `Rage::SSE`. It allows for broadcasting messages across multiple servers or from different runtimes, e.g. Sidekiq.
|
|
3
|
+
#
|
|
4
|
+
# To use the module, add the `redis-client` gem to your Gemfile and create the environment-specific configuration in `config/pubsub.yml`:
|
|
5
|
+
#
|
|
6
|
+
# ```yaml
|
|
7
|
+
# production:
|
|
8
|
+
# adapter: redis
|
|
9
|
+
# url: <%= ENV["REDIS_URL"] %>
|
|
10
|
+
# ```
|
|
11
|
+
#
|
|
12
|
+
# The configuration supports the following options:
|
|
13
|
+
#
|
|
14
|
+
# - `adapter` (required): The adapter to use for Pub/Sub. The only supported value is `redis`.
|
|
15
|
+
# - `channel_prefix` (optional): A prefix to use for the Redis stream name. This can be useful if you want to share a Redis instance with other applications or services.
|
|
16
|
+
# - `pool_size` (optional): The size of the Redis connection pool. Default is 10.
|
|
17
|
+
# - `pool_timeout` (optional): The timeout in seconds for acquiring a connection from the pool. Default is 1 second.
|
|
18
|
+
#
|
|
19
|
+
# The rest of the options are passed directly to `redis-client`.
|
|
20
|
+
#
|
|
21
|
+
module Rage::PubSub
|
|
22
|
+
module Adapters
|
|
23
|
+
autoload :Redis, "rage/pubsub/adapters/redis"
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/rage/rails.rb
CHANGED
|
@@ -54,4 +54,20 @@ Rails.configuration.after_initialize do
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# load deferred routes in Rails 8+
|
|
58
|
+
if Rails::VERSION::MAJOR >= 8
|
|
59
|
+
# reset Rage routes before Rails reloads routes
|
|
60
|
+
routes_reloader_patch = Module.new do
|
|
61
|
+
def reload!
|
|
62
|
+
Rage.__router.reset_routes
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
Rails::Application::RoutesReloader.prepend(routes_reloader_patch)
|
|
67
|
+
|
|
68
|
+
Rails.application.config.after_initialize do
|
|
69
|
+
Rails.application.reload_routes!
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
57
73
|
require "rage/ext/setup"
|
data/lib/rage/router/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
This is an almost complete rewrite of https://github.com/delvedor/find-my-way.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Currently, the only constraint supported is the `host` constraint. Regexp constraints are likely to be added. Custom/lambda constraints are unlikely to be added.
|
|
4
4
|
|
|
5
5
|
Compared to the Rails router, the most notable difference except constraints is that a wildcard segment can only be in the last section of the path and cannot be named.
|
|
6
6
|
|
data/lib/rage/router/dsl.rb
CHANGED
|
@@ -348,20 +348,12 @@ class Rage::Router::DSL
|
|
|
348
348
|
_module, _path, _only, _except, _param = opts.values_at(:module, :path, :only, :except, :param)
|
|
349
349
|
raise ArgumentError, ":param option can't contain colons" if _param.to_s.include?(":")
|
|
350
350
|
|
|
351
|
-
|
|
352
|
-
_except = Array(_except) if _except
|
|
353
|
-
actions = @default_actions.select do |action|
|
|
354
|
-
(_only.nil? || _only.include?(action)) && (_except.nil? || !_except.include?(action))
|
|
355
|
-
end
|
|
351
|
+
actions = __filter_actions(@default_actions, _only, _except)
|
|
356
352
|
|
|
357
353
|
resource = _resources[0].to_s
|
|
358
|
-
_path ||= resource
|
|
359
354
|
_param ||= "id"
|
|
360
355
|
|
|
361
|
-
|
|
362
|
-
scope_opts[:module] = _module if _module
|
|
363
|
-
|
|
364
|
-
scope(scope_opts) do
|
|
356
|
+
__resource_scope(resource, _path, _module) do
|
|
365
357
|
get("/", to: "#{resource}#index") if actions.include?(:index)
|
|
366
358
|
post("/", to: "#{resource}#create") if actions.include?(:create)
|
|
367
359
|
get("/:#{_param}", to: "#{resource}#show") if actions.include?(:show)
|
|
@@ -373,6 +365,45 @@ class Rage::Router::DSL
|
|
|
373
365
|
end
|
|
374
366
|
end
|
|
375
367
|
|
|
368
|
+
# Automatically create REST routes for a singular resource.
|
|
369
|
+
#
|
|
370
|
+
# @param [Hash] opts resource options
|
|
371
|
+
# @option opts [String] :module the namespace for the controller
|
|
372
|
+
# @option opts [String] :path the path prefix for the routes
|
|
373
|
+
# @option opts [Symbol, Array<Symbol>] :only only generate routes for the given actions
|
|
374
|
+
# @option opts [Symbol, Array<Symbol>] :except generate all routes except for the given actions
|
|
375
|
+
# @example Create singular routes mapped to a plural controller:
|
|
376
|
+
# resource :photo
|
|
377
|
+
# # POST /photo => photos#create
|
|
378
|
+
# # GET /photo => photos#show
|
|
379
|
+
# # PATCH /photo => photos#update
|
|
380
|
+
# # PUT /photo => photos#update
|
|
381
|
+
# # DELETE /photo => photos#destroy
|
|
382
|
+
# @note This helper doesn't generate the `new` and `edit` routes.
|
|
383
|
+
# @note :param is not supported for singular resources.
|
|
384
|
+
def resource(*_resources, **opts, &block)
|
|
385
|
+
if _resources.length > 1
|
|
386
|
+
_resources.each { |_resource| resource(_resource, **opts, &block) }
|
|
387
|
+
return
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
_module, _path, _only, _except = opts.values_at(:module, :path, :only, :except)
|
|
391
|
+
|
|
392
|
+
actions = __filter_actions(%i(create show update destroy), _only, _except)
|
|
393
|
+
|
|
394
|
+
resource_name = _resources[0].to_s
|
|
395
|
+
controller_name = to_plural(resource_name)
|
|
396
|
+
__resource_scope(resource_name, _path, _module) do
|
|
397
|
+
post("/", to: "#{controller_name}#create") if actions.include?(:create)
|
|
398
|
+
get("/", to: "#{controller_name}#show") if actions.include?(:show)
|
|
399
|
+
patch("/", to: "#{controller_name}#update") if actions.include?(:update)
|
|
400
|
+
put("/", to: "#{controller_name}#update") if actions.include?(:update)
|
|
401
|
+
delete("/", to: "#{controller_name}#destroy") if actions.include?(:destroy)
|
|
402
|
+
|
|
403
|
+
scope(controller: controller_name, &block) if block
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
376
407
|
# Mount a Rack-based application to be used within the application.
|
|
377
408
|
#
|
|
378
409
|
# @example
|
|
@@ -445,6 +476,25 @@ class Rage::Router::DSL
|
|
|
445
476
|
end
|
|
446
477
|
end
|
|
447
478
|
|
|
479
|
+
# Filters a list of actions based on :only and :except options
|
|
480
|
+
def __filter_actions(default_actions, only, except)
|
|
481
|
+
only = Array(only) if only
|
|
482
|
+
except = Array(except) if except
|
|
483
|
+
|
|
484
|
+
default_actions.select do |action|
|
|
485
|
+
(only.nil? || only.include?(action)) &&
|
|
486
|
+
(except.nil? || !except.include?(action))
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Wraps route definitions in the correct path/module scope
|
|
491
|
+
def __resource_scope(resource, path, mod, &block)
|
|
492
|
+
path ||= resource
|
|
493
|
+
scope_opts = { path: path }
|
|
494
|
+
scope_opts[:module] = mod if mod
|
|
495
|
+
scope(scope_opts, &block)
|
|
496
|
+
end
|
|
497
|
+
|
|
448
498
|
def to_singular(str)
|
|
449
499
|
@active_support_loaded ||= str.respond_to?(:singularize) || :false
|
|
450
500
|
return str.singularize if @active_support_loaded != :false
|
|
@@ -462,5 +512,12 @@ class Rage::Router::DSL
|
|
|
462
512
|
|
|
463
513
|
str.sub(@regexp, @endings)
|
|
464
514
|
end
|
|
515
|
+
|
|
516
|
+
def to_plural(str)
|
|
517
|
+
@active_support_loaded ||= str.respond_to?(:pluralize) || :false
|
|
518
|
+
return str.pluralize if @active_support_loaded != :false
|
|
519
|
+
|
|
520
|
+
str.end_with?("s") ? str : "#{str}s"
|
|
521
|
+
end
|
|
465
522
|
end
|
|
466
523
|
end
|
data/lib/rage/sse/application.rb
CHANGED
|
@@ -12,6 +12,8 @@ class Rage::SSE::Application
|
|
|
12
12
|
:stream
|
|
13
13
|
elsif @stream.is_a?(Proc)
|
|
14
14
|
:manual
|
|
15
|
+
elsif @stream.is_a?(Rage::SSE::Stream)
|
|
16
|
+
:broadcast
|
|
15
17
|
else
|
|
16
18
|
:single
|
|
17
19
|
end
|
|
@@ -20,7 +22,13 @@ class Rage::SSE::Application
|
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def on_open(connection)
|
|
23
|
-
@type == :single
|
|
25
|
+
if @type == :single
|
|
26
|
+
send_data(connection)
|
|
27
|
+
elsif @type == :broadcast
|
|
28
|
+
start_broadcast_stream(connection)
|
|
29
|
+
else
|
|
30
|
+
start_stream(connection)
|
|
31
|
+
end
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
private
|
|
@@ -28,18 +36,35 @@ class Rage::SSE::Application
|
|
|
28
36
|
def send_data(connection)
|
|
29
37
|
Rage::Telemetry.tracer.span_sse_stream_process(connection:, type: @type) do
|
|
30
38
|
connection.write(Rage::SSE.__serialize(@stream))
|
|
31
|
-
|
|
39
|
+
end
|
|
40
|
+
ensure
|
|
41
|
+
connection.close
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def start_broadcast_stream(connection)
|
|
45
|
+
channel = "sse:#{@stream.name}"
|
|
46
|
+
|
|
47
|
+
connection.subscribe(channel) do |_, msg|
|
|
48
|
+
msg == Rage::SSE::CLOSE_STREAM_MSG ? connection.close : connection.write(msg)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
buffered_messages = Rage::SSE::Stream.__claim_buffered_messages(@stream)
|
|
52
|
+
buffered_messages&.each do |msg|
|
|
53
|
+
msg == Rage::SSE::CLOSE_STREAM_MSG ? connection.close : connection.write(msg)
|
|
32
54
|
end
|
|
33
55
|
end
|
|
34
56
|
|
|
35
57
|
def start_stream(connection)
|
|
36
58
|
Fiber.schedule do
|
|
59
|
+
Iodine.task_inc!
|
|
37
60
|
Fiber[:__rage_logger_tags], Fiber[:__rage_logger_context] = @log_tags, @log_context
|
|
38
61
|
Rage::Telemetry.tracer.span_sse_stream_process(connection:, type: @type) do
|
|
39
62
|
@type == :stream ? start_formatted_stream(connection) : start_raw_stream(connection)
|
|
40
63
|
end
|
|
41
64
|
rescue => e
|
|
42
65
|
Rage.logger.error("SSE stream failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
|
|
66
|
+
ensure
|
|
67
|
+
Iodine.task_dec!
|
|
43
68
|
end
|
|
44
69
|
end
|
|
45
70
|
|
|
@@ -54,5 +79,8 @@ class Rage::SSE::Application
|
|
|
54
79
|
|
|
55
80
|
def start_raw_stream(connection)
|
|
56
81
|
@stream.call(Rage::SSE::ConnectionProxy.new(connection))
|
|
82
|
+
rescue => e
|
|
83
|
+
connection.close if connection.open?
|
|
84
|
+
raise e
|
|
57
85
|
end
|
|
58
86
|
end
|
data/lib/rage/sse/sse.rb
CHANGED
|
@@ -14,6 +14,18 @@ module Rage::SSE
|
|
|
14
14
|
Message.new(data:, id:, event:, retry:)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# A factory method for creating unbounded SSE streams.
|
|
18
|
+
#
|
|
19
|
+
# @param streamable [#id, String, Symbol, Numeric, Array] An object to generate the stream name from.
|
|
20
|
+
# @return [Stream] A new SSE stream instance.
|
|
21
|
+
# @example
|
|
22
|
+
# render sse: Rage::SSE.stream("#{current_user.id}-notifications")
|
|
23
|
+
# @example
|
|
24
|
+
# render sse: Rage::SSE.stream([current_user.id, "notifications"])
|
|
25
|
+
def self.stream(streamable)
|
|
26
|
+
Stream.new(streamable:)
|
|
27
|
+
end
|
|
28
|
+
|
|
17
29
|
# @private
|
|
18
30
|
def self.__serialize(data)
|
|
19
31
|
if data.is_a?(String)
|
|
@@ -24,8 +36,92 @@ module Rage::SSE
|
|
|
24
36
|
"data: #{data.to_json}\n\n"
|
|
25
37
|
end
|
|
26
38
|
end
|
|
39
|
+
|
|
40
|
+
# @private
|
|
41
|
+
def self.__adapter=(adapter)
|
|
42
|
+
@__adapter = adapter
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @private
|
|
46
|
+
CLOSE_STREAM_MSG = "rage-close-stream"
|
|
47
|
+
|
|
48
|
+
# @private
|
|
49
|
+
PUBSUB_BROADCASTER_ID = "sse"
|
|
50
|
+
|
|
51
|
+
# Close an unbounded SSE stream. Unbounded streams will remain open until either the client disconnects or the server explicitly closes them.
|
|
52
|
+
#
|
|
53
|
+
# @param streamable [#id, String, Symbol, Numeric, Array] The identifier of the stream to close.
|
|
54
|
+
# @example
|
|
55
|
+
# Rage::SSE.close_stream("#{current_user.id}-notifications")
|
|
56
|
+
# @example
|
|
57
|
+
# Rage::SSE.close_stream([current_user.id, "notifications"])
|
|
58
|
+
def self.close_stream(streamable)
|
|
59
|
+
stream_name = Rage::Internal.stream_name_for(streamable)
|
|
60
|
+
|
|
61
|
+
InternalBroadcast.broadcast(stream_name, CLOSE_STREAM_MSG, Iodine::PubSub::CLUSTER) if Iodine.running?
|
|
62
|
+
@__adapter&.publish(PUBSUB_BROADCASTER_ID, stream_name, CLOSE_STREAM_MSG)
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Broadcast a message to all clients subscribed to a given stream.
|
|
68
|
+
#
|
|
69
|
+
# @param streamable [#id, String, Symbol, Numeric, Array] The identifier of the stream to broadcast to.
|
|
70
|
+
# @param data [String, #to_json, Message] The message to broadcast.
|
|
71
|
+
# @example
|
|
72
|
+
# Rage::SSE.broadcast("#{current_user.id}-notifications", "You have a new notification!")
|
|
73
|
+
# @example
|
|
74
|
+
# Rage::SSE.broadcast([current_user.id, "notifications"], { title: "New Notification", body: "You have a new notification!" })
|
|
75
|
+
def self.broadcast(streamable, data)
|
|
76
|
+
Rage::Telemetry.tracer.span_sse_stream_broadcast(stream: streamable) do
|
|
77
|
+
stream_name = Rage::Internal.stream_name_for(streamable)
|
|
78
|
+
serialized_data = __serialize(data)
|
|
79
|
+
|
|
80
|
+
InternalBroadcast.broadcast(stream_name, serialized_data, Iodine::PubSub::CLUSTER) if Iodine.running?
|
|
81
|
+
@__adapter&.publish(PUBSUB_BROADCASTER_ID, stream_name, serialized_data)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @private
|
|
88
|
+
module InternalBroadcast
|
|
89
|
+
def self.broadcast(stream_name, data, engine)
|
|
90
|
+
if Rage::SSE::Stream.__message_buffer.has_key?(stream_name)
|
|
91
|
+
Rage::SSE::Stream.__store_message(stream_name, data)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
Iodine.publish("sse:#{stream_name}", data, engine)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @private
|
|
99
|
+
module Relay
|
|
100
|
+
def self.broadcast(stream_name, data)
|
|
101
|
+
Iodine.publish("sse-relay", "#{stream_name}\x00#{data}")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
27
104
|
end
|
|
28
105
|
|
|
29
106
|
require_relative "application"
|
|
30
107
|
require_relative "connection_proxy"
|
|
31
108
|
require_relative "message"
|
|
109
|
+
require_relative "stream"
|
|
110
|
+
|
|
111
|
+
Rage.config.after_initialize do
|
|
112
|
+
if (adapter = Rage.config.pubsub.adapter)
|
|
113
|
+
Iodine.on_state(:on_start) do
|
|
114
|
+
Iodine.subscribe("sse-relay") do |_, msg|
|
|
115
|
+
stream_name, data = msg.split("\x00", 2)
|
|
116
|
+
Rage::SSE::InternalBroadcast.broadcast(stream_name, data, Iodine::PubSub::PROCESS)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
Iodine.on_state(:on_finish) do
|
|
121
|
+
Iodine.unsubscribe("sse-relay")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
adapter.add_broadcaster(Rage::SSE::PUBSUB_BROADCASTER_ID, Rage::SSE::Relay)
|
|
125
|
+
Rage::SSE.__adapter = adapter
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# The class representing an unbounded Server-Sent Events stream. It allows for broadcasting messages to all connected clients subscribed to the stream.
|
|
5
|
+
#
|
|
6
|
+
# Create a stream:
|
|
7
|
+
#
|
|
8
|
+
# ```ruby
|
|
9
|
+
# render sse: Rage::SSE.stream([current_user, "notifications"])
|
|
10
|
+
# ```
|
|
11
|
+
#
|
|
12
|
+
# Broadcast a message to all connections subscribed to the stream:
|
|
13
|
+
# ```ruby
|
|
14
|
+
# Rage::SSE.broadcast([current_user, "notifications"], "You have a new notification!")
|
|
15
|
+
# ```
|
|
16
|
+
#
|
|
17
|
+
# Close the stream:
|
|
18
|
+
# ```ruby
|
|
19
|
+
# Rage::SSE.close_stream([current_user, "notifications"])
|
|
20
|
+
# ```
|
|
21
|
+
#
|
|
22
|
+
# Messages to known streams are buffered until a connection is fully established:
|
|
23
|
+
# ```ruby
|
|
24
|
+
# # Create a stream first
|
|
25
|
+
# stream = Rage::SSE.stream([current_user, "notifications"])
|
|
26
|
+
#
|
|
27
|
+
# # No connection yet, but the message is buffered
|
|
28
|
+
# Rage::SSE.broadcast([current_user, "notifications"], "You have a new notification!")
|
|
29
|
+
#
|
|
30
|
+
# # Establish a connection, which will claim the buffered message
|
|
31
|
+
# render sse: stream
|
|
32
|
+
# ```
|
|
33
|
+
#
|
|
34
|
+
class Rage::SSE::Stream
|
|
35
|
+
class << self
|
|
36
|
+
# @private
|
|
37
|
+
def __message_buffer
|
|
38
|
+
@__message_buffer ||= Hash.new { |h, k| h[k] = {} }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @private
|
|
42
|
+
def __store_message(stream, message)
|
|
43
|
+
__message_buffer[stream].transform_values! do |buffer|
|
|
44
|
+
buffer.frozen? ? [message] : buffer.push(message)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @private
|
|
49
|
+
def __claim_buffered_messages(stream)
|
|
50
|
+
messages = __message_buffer[stream.name][stream.owner] if __message_buffer.has_key?(stream.name)
|
|
51
|
+
cleanup_message_buffer
|
|
52
|
+
|
|
53
|
+
messages
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def cleanup_message_buffer
|
|
59
|
+
__message_buffer.delete_if do |_, connection_buffers|
|
|
60
|
+
connection_buffers.keys.none?(&:alive?)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
DEFAULT_BUFFER = [].freeze
|
|
66
|
+
private_constant :DEFAULT_BUFFER
|
|
67
|
+
|
|
68
|
+
# @private
|
|
69
|
+
attr_reader :name, :owner
|
|
70
|
+
|
|
71
|
+
# @param streamable [#id, String, Symbol, Numeric, Array] an object that will be used to generate the stream name
|
|
72
|
+
def initialize(streamable:)
|
|
73
|
+
@name = Rage::Internal.stream_name_for(streamable)
|
|
74
|
+
@owner = Fiber.current
|
|
75
|
+
|
|
76
|
+
self.class.__message_buffer[@name][@owner] ||= DEFAULT_BUFFER
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# The **sse.stream.broadcast** span wraps the process of broadcasting a message to an unbounded SSE stream.
|
|
5
|
+
#
|
|
6
|
+
# This span is started when {Rage::SSE.broadcast Rage::SSE.broadcast} is called, and ends when the broadcast operation is complete.
|
|
7
|
+
# See {handle handle} for the list of arguments passed to handler methods.
|
|
8
|
+
#
|
|
9
|
+
# @see Rage::Telemetry::Handler Rage::Telemetry::Handler
|
|
10
|
+
#
|
|
11
|
+
class Rage::Telemetry::Spans::BroadcastSSEStream
|
|
12
|
+
class << self
|
|
13
|
+
# @private
|
|
14
|
+
def id
|
|
15
|
+
"sse.stream.broadcast"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @private
|
|
19
|
+
def span_parameters
|
|
20
|
+
%w[stream:]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @private
|
|
24
|
+
def handler_arguments
|
|
25
|
+
{
|
|
26
|
+
name: '"Rage::SSE.broadcast"',
|
|
27
|
+
stream: "stream"
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @!parse [ruby]
|
|
32
|
+
# # @param id ["sse.stream.broadcast"] ID of the span
|
|
33
|
+
# # @param name ["Rage::SSE.broadcast"] human-readable name of the operation
|
|
34
|
+
# # @param stream [Object] the identifier of the stream to which the message is being broadcasted
|
|
35
|
+
# # @yieldreturn [Rage::Telemetry::SpanResult]
|
|
36
|
+
# #
|
|
37
|
+
# # @example
|
|
38
|
+
# # class MyTelemetryHandler < Rage::Telemetry::Handler
|
|
39
|
+
# # handle "sse.stream.broadcast", with: :my_handler
|
|
40
|
+
# #
|
|
41
|
+
# # def my_handler(id:, name:, stream:)
|
|
42
|
+
# # yield
|
|
43
|
+
# # end
|
|
44
|
+
# # end
|
|
45
|
+
# # @note Rage automatically detects which parameters your handler method accepts and only passes those parameters.
|
|
46
|
+
# # You can omit any of the parameters described here.
|
|
47
|
+
# def handle(id:, name:, stream:)
|
|
48
|
+
# end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
# This span starts when a connection is opened and ends when the stream is finished.
|
|
7
7
|
# See {handle handle} for the list of arguments passed to handler methods.
|
|
8
8
|
#
|
|
9
|
+
# @note This span is not used for unbounded SSE streams created via {Rage::SSE.stream Rage::SSE.stream}.
|
|
9
10
|
# @see Rage::Telemetry::Handler Rage::Telemetry::Handler
|
|
10
11
|
#
|
|
11
12
|
class Rage::Telemetry::Spans::ProcessSSEStream
|
|
@@ -70,7 +70,7 @@ module Rage::Telemetry
|
|
|
70
70
|
#
|
|
71
71
|
# | ID | Reference | Description |
|
|
72
72
|
# | --- | --- |
|
|
73
|
-
# | `core.fiber.dispatch` | {DispatchFiber} | Wraps the scheduling and processing of system-level fibers created by the framework to process requests
|
|
73
|
+
# | `core.fiber.dispatch` | {DispatchFiber} | Wraps the scheduling and processing of system-level fibers created by the framework to process requests, deferred tasks, or SSE streams |
|
|
74
74
|
# | `core.fiber.spawn` | {SpawnFiber} | Wraps the scheduling and processing of application-level fibers created via {Fiber.schedule} |
|
|
75
75
|
# | `core.fiber.await` | {AwaitFiber} | Wraps the processing of the {Fiber.await} calls |
|
|
76
76
|
# | `controller.action.process` | {ProcessControllerAction} | Wraps the processing of controller actions |
|
|
@@ -83,6 +83,7 @@ module Rage::Telemetry
|
|
|
83
83
|
# | `events.event.publish` | {PublishEvent} | Wraps the publishing of events via {Rage::Events Rage::Events} |
|
|
84
84
|
# | `events.subscriber.process` | {ProcessEventSubscriber} | Wraps the processing of events by subscribers |
|
|
85
85
|
# | `sse.stream.process` | {ProcessSSEStream} | Wraps the processing of an SSE stream |
|
|
86
|
+
# | `sse.stream.broadcast` | {BroadcastSSEStream} | Wraps the process of broadcasting a message to an unbounded SSE stream |
|
|
86
87
|
#
|
|
87
88
|
module Spans
|
|
88
89
|
end
|
data/lib/rage/uploaded_file.rb
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
# of its interface is available directly for convenience.
|
|
8
8
|
#
|
|
9
9
|
# Rage will automatically unlink the files, so there is no need to clean them with a separate maintenance task.
|
|
10
|
+
#
|
|
10
11
|
class Rage::UploadedFile
|
|
11
12
|
# The basename of the file in the client.
|
|
12
13
|
attr_reader :original_filename
|
|
@@ -29,14 +30,9 @@ class Rage::UploadedFile
|
|
|
29
30
|
@file.read(length, buffer)
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
# Shortcut for `file.open`.
|
|
33
|
-
def open
|
|
34
|
-
@file.open
|
|
35
|
-
end
|
|
36
|
-
|
|
37
33
|
# Shortcut for `file.close`.
|
|
38
|
-
def close
|
|
39
|
-
@file.close
|
|
34
|
+
def close
|
|
35
|
+
@file.close
|
|
40
36
|
end
|
|
41
37
|
|
|
42
38
|
# Shortcut for `file.path`.
|
data/lib/rage/version.rb
CHANGED
data/lib/rage-rb.rb
CHANGED
|
@@ -191,10 +191,11 @@ module Rage
|
|
|
191
191
|
autoload :OpenAPI, "rage/openapi/openapi"
|
|
192
192
|
autoload :Deferred, "rage/deferred/deferred"
|
|
193
193
|
autoload :Events, "rage/events/events"
|
|
194
|
-
autoload :
|
|
194
|
+
autoload :PubSub, "rage/pubsub/pubsub"
|
|
195
195
|
end
|
|
196
196
|
|
|
197
197
|
module RageController
|
|
198
|
+
autoload :Renderers, "rage/controller/renderers"
|
|
198
199
|
end
|
|
199
200
|
|
|
200
201
|
require_relative "rage/env"
|