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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/CONTRIBUTING.md +240 -0
  4. data/README.md +2 -1
  5. data/lib/rage/all.rb +1 -0
  6. data/lib/rage/application.rb +1 -0
  7. data/lib/rage/cable/cable.rb +20 -15
  8. data/lib/rage/cable/channel.rb +2 -1
  9. data/lib/rage/configuration.rb +229 -27
  10. data/lib/rage/controller/api.rb +17 -33
  11. data/lib/rage/controller/renderers.rb +47 -0
  12. data/lib/rage/deferred/backends/disk.rb +19 -3
  13. data/lib/rage/deferred/deferred.rb +7 -0
  14. data/lib/rage/deferred/metadata.rb +9 -1
  15. data/lib/rage/deferred/queue.rb +5 -4
  16. data/lib/rage/deferred/scheduler.rb +25 -0
  17. data/lib/rage/deferred/task.rb +90 -9
  18. data/lib/rage/errors.rb +86 -0
  19. data/lib/rage/events/subscriber.rb +6 -1
  20. data/lib/rage/internal.rb +45 -0
  21. data/lib/rage/logger/logger.rb +1 -1
  22. data/lib/rage/middleware/fiber_wrapper.rb +1 -0
  23. data/lib/rage/openapi/builder.rb +1 -1
  24. data/lib/rage/openapi/converter.rb +48 -3
  25. data/lib/rage/openapi/nodes/method.rb +2 -1
  26. data/lib/rage/openapi/nodes/root.rb +2 -1
  27. data/lib/rage/openapi/openapi.rb +12 -1
  28. data/lib/rage/openapi/parser.rb +73 -2
  29. data/lib/rage/openapi/parsers/ext/alba.rb +35 -6
  30. data/lib/rage/openapi/parsers/request.rb +2 -2
  31. data/lib/rage/openapi/parsers/response.rb +2 -2
  32. data/lib/rage/openapi/parsers/yaml.rb +27 -5
  33. data/lib/rage/params_parser.rb +2 -2
  34. data/lib/rage/{cable → pubsub}/adapters/redis.rb +43 -23
  35. data/lib/rage/pubsub/pubsub.rb +25 -0
  36. data/lib/rage/rails.rb +16 -0
  37. data/lib/rage/router/README.md +1 -1
  38. data/lib/rage/router/dsl.rb +72 -10
  39. data/lib/rage/sse/application.rb +31 -2
  40. data/lib/rage/sse/sse.rb +96 -0
  41. data/lib/rage/sse/stream.rb +78 -0
  42. data/lib/rage/telemetry/spans/broadcast_sse_stream.rb +50 -0
  43. data/lib/rage/telemetry/spans/process_sse_stream.rb +1 -0
  44. data/lib/rage/telemetry/telemetry.rb +2 -1
  45. data/lib/rage/telemetry/tracer.rb +1 -0
  46. data/lib/rage/uploaded_file.rb +3 -7
  47. data/lib/rage/version.rb +1 -1
  48. data/lib/rage-rb.rb +8 -1
  49. metadata +9 -4
  50. 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 = YAML.safe_load(yaml) rescue nil
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(YAML.safe_load(yaml))
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
- Rage::OpenAPI.__type_to_spec(type) || { "type" => "string", "enum" => [type] }
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
@@ -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. Add the following line to your Gemfile:
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::Cable::Adapters::Redis < Rage::Cable::Adapters::Base
15
- REDIS_STREAM_NAME = "rage:cable:messages"
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 < REDIS_MIN_VERSION_SUPPORTED
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 { poll }
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
- publish_redis.call(
43
- "XADD",
44
- @redis_stream,
45
- trimming_method, "~", trimming_value,
46
- "*",
47
- "1", stream_name,
48
- "2", data.to_json,
49
- "3", @server_uuid,
50
- "4", message_uuid
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 publish_redis
57
- @publish_redis ||= @redis_config.new_client
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
- REDIS_MIN_VERSION_SUPPORTED
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, _, broadcaster_uuid, _, message_uuid)|
109
- if broadcaster_uuid != @server_uuid && message_uuid != last_message_uuid
110
- Rage.cable.__protocol.broadcast(stream_name, JSON.parse(serialized_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))
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"
@@ -1,6 +1,6 @@
1
1
  This is an almost complete rewrite of https://github.com/delvedor/find-my-way.
2
2
 
3
- Currrently, the only constraint supported is the `host` constraint. Regexp constraints are likely to be added. Custom/lambda constraints are unlikely to be added.
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
 
@@ -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
- _only = Array(_only) if _only
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
- scope_opts = { path: _path }
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
@@ -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 ? send_data(connection) : start_stream(connection)
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
- connection.close
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