rage-rb 1.22.1 → 1.24.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 +42 -0
- data/CONTRIBUTING.md +240 -0
- data/README.md +2 -1
- data/lib/rage/all.rb +1 -0
- data/lib/rage/application.rb +1 -0
- data/lib/rage/cable/cable.rb +20 -15
- data/lib/rage/cable/channel.rb +2 -1
- data/lib/rage/configuration.rb +229 -27
- data/lib/rage/controller/api.rb +17 -33
- data/lib/rage/controller/renderers.rb +47 -0
- data/lib/rage/deferred/backends/disk.rb +19 -3
- data/lib/rage/deferred/deferred.rb +7 -0
- data/lib/rage/deferred/metadata.rb +9 -1
- data/lib/rage/deferred/queue.rb +5 -4
- data/lib/rage/deferred/scheduler.rb +25 -0
- data/lib/rage/deferred/task.rb +90 -9
- data/lib/rage/errors.rb +86 -0
- data/lib/rage/events/subscriber.rb +6 -1
- data/lib/rage/internal.rb +45 -0
- data/lib/rage/logger/logger.rb +1 -1
- data/lib/rage/middleware/fiber_wrapper.rb +1 -0
- data/lib/rage/openapi/builder.rb +1 -1
- data/lib/rage/openapi/converter.rb +48 -3
- data/lib/rage/openapi/nodes/method.rb +2 -1
- data/lib/rage/openapi/nodes/root.rb +2 -1
- data/lib/rage/openapi/openapi.rb +12 -1
- data/lib/rage/openapi/parser.rb +73 -2
- data/lib/rage/openapi/parsers/ext/alba.rb +35 -6
- data/lib/rage/openapi/parsers/request.rb +2 -2
- data/lib/rage/openapi/parsers/response.rb +2 -2
- data/lib/rage/openapi/parsers/yaml.rb +27 -5
- data/lib/rage/params_parser.rb +2 -2
- data/lib/rage/{cable → pubsub}/adapters/redis.rb +43 -23
- 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 +72 -10
- data/lib/rage/sse/application.rb +31 -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/telemetry/tracer.rb +1 -0
- data/lib/rage/uploaded_file.rb +3 -7
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +8 -1
- metadata +9 -4
- data/lib/rage/cable/adapters/base.rb +0 -16
|
@@ -8,9 +8,9 @@ class Rage::OpenAPI::Parsers::Response
|
|
|
8
8
|
Rage::OpenAPI::Parsers::YAML
|
|
9
9
|
]
|
|
10
10
|
|
|
11
|
-
def self.parse(response_tag, namespace:)
|
|
11
|
+
def self.parse(response_tag, namespace:, root:)
|
|
12
12
|
parser = AVAILABLE_PARSERS.find do |parser_class|
|
|
13
|
-
parser = parser_class.new(namespace:)
|
|
13
|
+
parser = parser_class.new(namespace:, root:)
|
|
14
14
|
break parser if parser.known_definition?(response_tag)
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Rage::OpenAPI::Parsers::YAML
|
|
4
|
+
# @private
|
|
5
|
+
class OptionalParam < String
|
|
6
|
+
end
|
|
7
|
+
|
|
4
8
|
def initialize(**)
|
|
5
9
|
end
|
|
6
10
|
|
|
7
11
|
def known_definition?(yaml)
|
|
8
|
-
object =
|
|
12
|
+
object = process_yaml(yaml) rescue nil
|
|
9
13
|
!!object && object.is_a?(Enumerable)
|
|
10
14
|
end
|
|
11
15
|
|
|
12
16
|
def parse(yaml)
|
|
13
|
-
__parse(
|
|
17
|
+
__parse(process_yaml(yaml))
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
private
|
|
@@ -22,6 +26,8 @@ class Rage::OpenAPI::Parsers::YAML
|
|
|
22
26
|
spec = { "type" => "object", "properties" => {} }
|
|
23
27
|
|
|
24
28
|
object.each do |key, value|
|
|
29
|
+
key = OptionalParam.new(key[0...-1]) if key.end_with?("?")
|
|
30
|
+
|
|
25
31
|
spec["properties"][key] = if value.is_a?(Enumerable)
|
|
26
32
|
__parse(value)
|
|
27
33
|
else
|
|
@@ -29,6 +35,8 @@ class Rage::OpenAPI::Parsers::YAML
|
|
|
29
35
|
end
|
|
30
36
|
end
|
|
31
37
|
|
|
38
|
+
spec["required"] = spec["properties"].keys.select { |k| !k.is_a?(OptionalParam) }
|
|
39
|
+
|
|
32
40
|
elsif object.is_a?(Array) && object.length == 1
|
|
33
41
|
spec = { "type" => "array", "items" => object[0].is_a?(Enumerable) ? __parse(object[0]) : type_to_spec(object[0]) }
|
|
34
42
|
|
|
@@ -39,9 +47,23 @@ class Rage::OpenAPI::Parsers::YAML
|
|
|
39
47
|
spec
|
|
40
48
|
end
|
|
41
49
|
|
|
42
|
-
private
|
|
43
|
-
|
|
44
50
|
def type_to_spec(type)
|
|
45
|
-
|
|
51
|
+
is_collection, type_str = if type.is_a?(String)
|
|
52
|
+
Rage::OpenAPI.__try_parse_collection(type)
|
|
53
|
+
else
|
|
54
|
+
[false, type]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
spec = Rage::OpenAPI.__type_to_spec(type_str) || { "type" => "string", "enum" => [type_str] }
|
|
58
|
+
|
|
59
|
+
if is_collection
|
|
60
|
+
{ "type" => "array", "items" => spec }
|
|
61
|
+
else
|
|
62
|
+
spec
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def process_yaml(str)
|
|
67
|
+
YAML.safe_load(str.gsub(/Array<([^>]+)>/, '[\1]'))
|
|
46
68
|
end
|
|
47
69
|
end
|
data/lib/rage/params_parser.rb
CHANGED
|
@@ -14,9 +14,9 @@ class Rage::ParamsParser
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
request_params = if content_type.start_with?("application/json")
|
|
17
|
-
json_parse(env["rack.input"].read)
|
|
17
|
+
json_parse(env["rack.input"].tap { |io| io.rewind }.read)
|
|
18
18
|
elsif content_type.start_with?("application/x-www-form-urlencoded")
|
|
19
|
-
Iodine::Rack::Utils.parse_urlencoded_nested_query(env["rack.input"].read)
|
|
19
|
+
Iodine::Rack::Utils.parse_urlencoded_nested_query(env["rack.input"].tap { |io| io.rewind }.read)
|
|
20
20
|
elsif content_type.start_with?("multipart/form-data")
|
|
21
21
|
Iodine::Rack::Utils.parse_multipart(env["rack.input"], content_type)
|
|
22
22
|
end
|
|
@@ -5,15 +5,17 @@ require "securerandom"
|
|
|
5
5
|
if !defined?(RedisClient)
|
|
6
6
|
fail <<~ERR
|
|
7
7
|
|
|
8
|
-
Redis adapter depends on the `redis-client` gem.
|
|
8
|
+
Redis adapter depends on the `redis-client` gem. Ensure the following line is added to your Gemfile:
|
|
9
9
|
gem "redis-client"
|
|
10
10
|
|
|
11
11
|
ERR
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
class Rage::
|
|
15
|
-
REDIS_STREAM_NAME = "rage:
|
|
14
|
+
class Rage::PubSub::Adapters::Redis
|
|
15
|
+
REDIS_STREAM_NAME = "rage:pubsub:messages"
|
|
16
16
|
DEFAULT_REDIS_OPTIONS = { reconnect_attempts: [0.05, 0.1, 0.5] }
|
|
17
|
+
DEFAULT_POOL_SIZE = 10
|
|
18
|
+
DEFAULT_POOL_TIMEOUT = 1
|
|
17
19
|
REDIS_MIN_VERSION_SUPPORTED = Gem::Version.create(6)
|
|
18
20
|
|
|
19
21
|
def initialize(config)
|
|
@@ -23,38 +25,53 @@ class Rage::Cable::Adapters::Redis < Rage::Cable::Adapters::Base
|
|
|
23
25
|
REDIS_STREAM_NAME
|
|
24
26
|
end
|
|
25
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
|
|
26
30
|
@redis_config = RedisClient.config(**DEFAULT_REDIS_OPTIONS.merge(config))
|
|
27
31
|
@server_uuid = SecureRandom.uuid
|
|
32
|
+
@broadcasters = {}
|
|
28
33
|
|
|
29
34
|
redis_version = get_redis_version
|
|
30
|
-
if redis_version
|
|
35
|
+
if redis_version.nil?
|
|
36
|
+
return
|
|
37
|
+
elsif redis_version < REDIS_MIN_VERSION_SUPPORTED
|
|
31
38
|
raise "Redis adapter only supports Redis 6+. Detected Redis version: #{redis_version}."
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
@trimming_strategy = redis_version < Gem::Version.create("6.2.0") ? :maxlen : :minid
|
|
35
42
|
|
|
36
|
-
pick_a_worker
|
|
43
|
+
Rage::Internal.pick_a_worker(purpose: "redis-pubsub") 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
|
|
37
51
|
end
|
|
38
52
|
|
|
39
|
-
def publish(stream_name, data)
|
|
53
|
+
def publish(broadcaster_id, stream_name, data)
|
|
40
54
|
message_uuid = SecureRandom.uuid
|
|
41
55
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
52
69
|
end
|
|
53
70
|
|
|
54
71
|
private
|
|
55
72
|
|
|
56
|
-
def
|
|
57
|
-
@
|
|
73
|
+
def redis_pool
|
|
74
|
+
@redis_pool ||= @redis_config.new_pool(size: @pool_size, timeout: @pool_timeout)
|
|
58
75
|
end
|
|
59
76
|
|
|
60
77
|
def trimming_method
|
|
@@ -74,7 +91,7 @@ class Rage::Cable::Adapters::Redis < Rage::Cable::Adapters::Base
|
|
|
74
91
|
rescue RedisClient::Error => e
|
|
75
92
|
puts "FATAL: Couldn't connect to Redis - all broadcasts will be limited to the current server."
|
|
76
93
|
puts e.backtrace.join("\n")
|
|
77
|
-
|
|
94
|
+
nil
|
|
78
95
|
|
|
79
96
|
ensure
|
|
80
97
|
service_redis.close
|
|
@@ -105,9 +122,9 @@ class Rage::Cable::Adapters::Redis < Rage::Cable::Adapters::Base
|
|
|
105
122
|
data = read_redis.blocking_call(5, "XREAD", "COUNT", "100", "BLOCK", "5000", "STREAMS", @redis_stream, last_id)
|
|
106
123
|
|
|
107
124
|
if data
|
|
108
|
-
data[@redis_stream].each do |id, (_, stream_name, _, serialized_data, _,
|
|
109
|
-
if
|
|
110
|
-
|
|
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))
|
|
111
128
|
end
|
|
112
129
|
|
|
113
130
|
last_id = id
|
|
@@ -115,10 +132,13 @@ class Rage::Cable::Adapters::Redis < Rage::Cable::Adapters::Base
|
|
|
115
132
|
end
|
|
116
133
|
end
|
|
117
134
|
|
|
135
|
+
break if @stopping
|
|
136
|
+
|
|
118
137
|
rescue RedisClient::Error => e
|
|
119
138
|
Rage.logger.error("Subscriber error: #{e.message} (#{e.class})")
|
|
139
|
+
Rage::Errors.report(e)
|
|
120
140
|
sleep error_backoff_intervals.next
|
|
121
|
-
rescue => e
|
|
141
|
+
rescue SystemCallError => e
|
|
122
142
|
@stopping ? break : raise(e)
|
|
123
143
|
else
|
|
124
144
|
error_backoff_intervals.rewind
|
|
@@ -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
|
@@ -62,6 +62,8 @@ class Rage::Router::DSL
|
|
|
62
62
|
@router = router
|
|
63
63
|
|
|
64
64
|
@default_actions = %i(index create show update destroy)
|
|
65
|
+
@default_actions += %i(new edit) if Rage.config.router.form_actions
|
|
66
|
+
|
|
65
67
|
@default_match_methods = %i(get post put patch delete head)
|
|
66
68
|
@scope_opts = %i(module path controller)
|
|
67
69
|
|
|
@@ -348,31 +350,65 @@ class Rage::Router::DSL
|
|
|
348
350
|
_module, _path, _only, _except, _param = opts.values_at(:module, :path, :only, :except, :param)
|
|
349
351
|
raise ArgumentError, ":param option can't contain colons" if _param.to_s.include?(":")
|
|
350
352
|
|
|
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
|
|
353
|
+
actions = __filter_actions(@default_actions, _only, _except)
|
|
356
354
|
|
|
357
355
|
resource = _resources[0].to_s
|
|
358
|
-
_path ||= resource
|
|
359
356
|
_param ||= "id"
|
|
360
357
|
|
|
361
|
-
|
|
362
|
-
scope_opts[:module] = _module if _module
|
|
363
|
-
|
|
364
|
-
scope(scope_opts) do
|
|
358
|
+
__resource_scope(resource, _path, _module) do
|
|
365
359
|
get("/", to: "#{resource}#index") if actions.include?(:index)
|
|
366
360
|
post("/", to: "#{resource}#create") if actions.include?(:create)
|
|
367
361
|
get("/:#{_param}", to: "#{resource}#show") if actions.include?(:show)
|
|
368
362
|
patch("/:#{_param}", to: "#{resource}#update") if actions.include?(:update)
|
|
369
363
|
put("/:#{_param}", to: "#{resource}#update") if actions.include?(:update)
|
|
364
|
+
get("/new", to: "#{resource}#new") if actions.include?(:new)
|
|
365
|
+
get("/:#{_param}/edit", to: "#{resource}#edit") if actions.include?(:edit)
|
|
370
366
|
delete("/:#{_param}", to: "#{resource}#destroy") if actions.include?(:destroy)
|
|
371
367
|
|
|
372
368
|
scope(path: ":#{to_singular(resource)}_#{_param}", controller: resource, &block) if block
|
|
373
369
|
end
|
|
374
370
|
end
|
|
375
371
|
|
|
372
|
+
# Automatically create REST routes for a singular resource.
|
|
373
|
+
#
|
|
374
|
+
# @param [Hash] opts resource options
|
|
375
|
+
# @option opts [String] :module the namespace for the controller
|
|
376
|
+
# @option opts [String] :path the path prefix for the routes
|
|
377
|
+
# @option opts [Symbol, Array<Symbol>] :only only generate routes for the given actions
|
|
378
|
+
# @option opts [Symbol, Array<Symbol>] :except generate all routes except for the given actions
|
|
379
|
+
# @example Create singular routes mapped to a plural controller:
|
|
380
|
+
# resource :photo
|
|
381
|
+
# # POST /photo => photos#create
|
|
382
|
+
# # GET /photo => photos#show
|
|
383
|
+
# # PATCH /photo => photos#update
|
|
384
|
+
# # PUT /photo => photos#update
|
|
385
|
+
# # DELETE /photo => photos#destroy
|
|
386
|
+
# @note :param is not supported for singular resources.
|
|
387
|
+
def resource(*_resources, **opts, &block)
|
|
388
|
+
if _resources.length > 1
|
|
389
|
+
_resources.each { |_resource| resource(_resource, **opts, &block) }
|
|
390
|
+
return
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
_module, _path, _only, _except = opts.values_at(:module, :path, :only, :except)
|
|
394
|
+
|
|
395
|
+
actions = __filter_actions(@default_actions - [:index], _only, _except)
|
|
396
|
+
|
|
397
|
+
resource_name = _resources[0].to_s
|
|
398
|
+
controller_name = to_plural(resource_name)
|
|
399
|
+
__resource_scope(resource_name, _path, _module) do
|
|
400
|
+
post("/", to: "#{controller_name}#create") if actions.include?(:create)
|
|
401
|
+
get("/", to: "#{controller_name}#show") if actions.include?(:show)
|
|
402
|
+
patch("/", to: "#{controller_name}#update") if actions.include?(:update)
|
|
403
|
+
put("/", to: "#{controller_name}#update") if actions.include?(:update)
|
|
404
|
+
get("/new", to: "#{controller_name}#new") if actions.include?(:new)
|
|
405
|
+
get("/edit", to: "#{controller_name}#edit") if actions.include?(:edit)
|
|
406
|
+
delete("/", to: "#{controller_name}#destroy") if actions.include?(:destroy)
|
|
407
|
+
|
|
408
|
+
scope(controller: controller_name, &block) if block
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
376
412
|
# Mount a Rack-based application to be used within the application.
|
|
377
413
|
#
|
|
378
414
|
# @example
|
|
@@ -445,6 +481,25 @@ class Rage::Router::DSL
|
|
|
445
481
|
end
|
|
446
482
|
end
|
|
447
483
|
|
|
484
|
+
# Filters a list of actions based on :only and :except options
|
|
485
|
+
def __filter_actions(default_actions, only, except)
|
|
486
|
+
only = Array(only) if only
|
|
487
|
+
except = Array(except) if except
|
|
488
|
+
|
|
489
|
+
default_actions.select do |action|
|
|
490
|
+
(only.nil? || only.include?(action)) &&
|
|
491
|
+
(except.nil? || !except.include?(action))
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Wraps route definitions in the correct path/module scope
|
|
496
|
+
def __resource_scope(resource, path, mod, &block)
|
|
497
|
+
path ||= resource
|
|
498
|
+
scope_opts = { path: path }
|
|
499
|
+
scope_opts[:module] = mod if mod
|
|
500
|
+
scope(scope_opts, &block)
|
|
501
|
+
end
|
|
502
|
+
|
|
448
503
|
def to_singular(str)
|
|
449
504
|
@active_support_loaded ||= str.respond_to?(:singularize) || :false
|
|
450
505
|
return str.singularize if @active_support_loaded != :false
|
|
@@ -462,5 +517,12 @@ class Rage::Router::DSL
|
|
|
462
517
|
|
|
463
518
|
str.sub(@regexp, @endings)
|
|
464
519
|
end
|
|
520
|
+
|
|
521
|
+
def to_plural(str)
|
|
522
|
+
@active_support_loaded ||= str.respond_to?(:pluralize) || :false
|
|
523
|
+
return str.pluralize if @active_support_loaded != :false
|
|
524
|
+
|
|
525
|
+
str.end_with?("s") ? str : "#{str}s"
|
|
526
|
+
end
|
|
465
527
|
end
|
|
466
528
|
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,36 @@ 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
|
+
Rage::Errors.report(e)
|
|
67
|
+
ensure
|
|
68
|
+
Iodine.task_dec!
|
|
43
69
|
end
|
|
44
70
|
end
|
|
45
71
|
|
|
@@ -54,5 +80,8 @@ class Rage::SSE::Application
|
|
|
54
80
|
|
|
55
81
|
def start_raw_stream(connection)
|
|
56
82
|
@stream.call(Rage::SSE::ConnectionProxy.new(connection))
|
|
83
|
+
rescue => e
|
|
84
|
+
connection.close if connection.open?
|
|
85
|
+
raise e
|
|
57
86
|
end
|
|
58
87
|
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
|