rage-rb 1.21.2 → 1.22.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0bf38d642bfd73b2b1503d879649b40db0d22bd19114113a3706ee878969ff4f
4
- data.tar.gz: 6e9c1028686606c4d62b6de407674ad4534ffe5052eb90200833a83cc3224e31
3
+ metadata.gz: b9116b996c0dd32f117d192f6fbb5df697a721a11a833a6f0e3aba30d683c691
4
+ data.tar.gz: a0ea041ff5ef2c7059e12fd32ab095f36d60bb6281c0679c66c33804e5401227
5
5
  SHA512:
6
- metadata.gz: 8ca894824a43186b3ee65b1ea2984a937b6455ed43a44c258020656c27c23b24776cbd254bc33146c49d16f3a3320b6b1f0a481a8efafa56886a74500fbc304a
7
- data.tar.gz: d9315ab5630d31b1075fe7c2430a9060c90d18b60234ee2e5c31ab4d99c64ae369dffc9b289b17c35baa959d02ce4eaee597889daebedf2cf3881e9deee56301
6
+ metadata.gz: 3962b6460dfc475f5d8709bd8847f994745b40b9cb486f720c967bc565e812d787613616eaa61fcb66c38153d49d5390983e5a2a10039b73abcfb414db60fadc
7
+ data.tar.gz: e844472bcfbcee55e78d4e2daf63f9e344ad977d7cad413b44c0adb3ffd0483e8e95bc3de24cce874d7d4f23c7dd8a820e7ac87cfcd8dda623264f4a4129304c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.22.0] - 2026-03-12
4
+
5
+ ### Added
6
+
7
+ - [Cable] Add support for `stop_stream_from` and `stop_stream_for` by [@Digvijay](https://github.com/Digvijay-x1) (#217).
8
+ - Add support for signed cookies by [@rfronczyk](https://github.com/rfronczyk) (#226).
9
+ - [OpenAPI] Add support for shared components in `@auth` tags by [@Piyush-Goenka](https://github.com/Piyush-Goenka) (#221).
10
+ - Add support for server-sent events (#220).
11
+
3
12
  ## [1.21.2] - 2026-03-11
4
13
 
5
14
  - Fix duplicate Rake tasks (#238).
@@ -418,4 +427,3 @@
418
427
  - support the `root`, `get`, `post`, `patch`, `put`, `delete` methods;
419
428
  - support the `scope` method with the `path` and `module` options;
420
429
  - support `host` constraint;
421
-
@@ -44,7 +44,8 @@ module Rage::Cable
44
44
 
45
45
  application = ->(env) do
46
46
  Rage::Telemetry.tracer.span_cable_websocket_handshake(env:) do
47
- if env["rack.upgrade?"] == :websocket
47
+ if env["HTTP_UPGRADE"] == "websocket" || env["HTTP_UPGRADE"]&.downcase == "websocket"
48
+ env["rack.upgrade?"] = :websocket
48
49
  env["rack.upgrade"] = handler
49
50
  accept_response
50
51
  else
@@ -162,6 +163,12 @@ module Rage::Cable
162
163
  # def subscribe(name)
163
164
  # end
164
165
  #
166
+ # # Unsubscribe from a channel.
167
+ # #
168
+ # # @param name [String] the channel name
169
+ # def unsubscribe(name)
170
+ # end
171
+ #
165
172
  # # Close the connection.
166
173
  # def close
167
174
  # end
@@ -466,6 +466,40 @@ class Rage::Cable::Channel
466
466
  stream_from(self.class.__stream_name_for(streamable))
467
467
  end
468
468
 
469
+ # Unsubscribe from a global stream.
470
+ #
471
+ # @param stream [String] the name of the stream
472
+ # @raise [ArgumentError] if the stream name is not a String
473
+ # @example Unsubscribe from a stream and subscribe to a new one
474
+ # class ChatChannel < Rage::Cable::Channel
475
+ # def subscribed
476
+ # stream_from "chat_#{params[:room]}"
477
+ # end
478
+ #
479
+ # def switch_room(data)
480
+ # stop_stream_from "chat_#{params[:room]}"
481
+ # stream_from "chat_#{data['new_room']}"
482
+ # end
483
+ # end
484
+ def stop_stream_from(stream)
485
+ raise ArgumentError, "Stream name must be a String" unless stream.is_a?(String)
486
+ Rage.cable.__protocol.unsubscribe(@__connection, stream, @__params)
487
+ end
488
+
489
+ # Unsubscribe from a local stream. The counterpart to {stream_for}.
490
+ #
491
+ # @param streamable [#id, String, Symbol, Numeric, Array] an object that will be used to generate the stream name
492
+ # @raise [ArgumentError] if the streamable object does not satisfy the type requirements
493
+ # @example Unsubscribe from a model stream
494
+ # class NotificationsChannel < Rage::Cable::Channel
495
+ # def unfollow(data)
496
+ # stop_stream_for User.find(data['user_id'])
497
+ # end
498
+ # end
499
+ def stop_stream_for(streamable)
500
+ stop_stream_from(self.class.__stream_name_for(streamable))
501
+ end
502
+
469
503
  # Broadcast data to all the clients subscribed to a stream.
470
504
  #
471
505
  # @param stream [String] the name of the stream
@@ -62,6 +62,15 @@ class Rage::Cable::Protocols::Base
62
62
  end
63
63
  end
64
64
 
65
+ # Unsubscribe from a stream.
66
+ #
67
+ # @param connection [Rage::Cable::WebSocketConnection] the connection object
68
+ # @param name [String] the stream name
69
+ # @param params [Hash] parameters associated with the client
70
+ def unsubscribe(connection, name, params)
71
+ connection.unsubscribe("cable:#{name}:#{stream_id(params)}")
72
+ end
73
+
65
74
  # Broadcast data to all clients connected to a stream.
66
75
  #
67
76
  # @param name [String] the stream name
@@ -141,4 +141,9 @@ class Rage::Cable::Protocols::RawWebSocketJson < Rage::Cable::Protocols::Base
141
141
  def self.subscribe(connection, name, _)
142
142
  super(connection, name, "")
143
143
  end
144
+
145
+ # @private
146
+ def self.unsubscribe(connection, name, _)
147
+ super(connection, name, "")
148
+ end
144
149
  end
@@ -998,7 +998,7 @@ class Rage::Configuration
998
998
  middleware.insert_before(::Rack::Events, Rage::BodyFinalizer)
999
999
  end
1000
1000
 
1001
- Rage::Telemetry.__setup if @telemetry
1001
+ Rage::Telemetry.__setup(@telemetry.handlers_map) if @telemetry
1002
1002
  end
1003
1003
  end
1004
1004
 
@@ -484,23 +484,31 @@ class RageController::API
484
484
  #
485
485
  # @param json [String, Object] send a json response to the client; objects like arrays will be serialized automatically
486
486
  # @param plain [String] send a text response to the client
487
+ # @param sse [#each, Proc, #to_json] send an SSE response to the client
487
488
  # @param status [Integer, Symbol] set a response status
488
- # @example
489
+ # @example Render a JSON object
489
490
  # render json: { hello: "world" }
490
- # @example
491
+ # @example Set a response status
491
492
  # render status: :ok
492
- # @example
493
- # render plain: "hello world", status: 201
493
+ # @example Render an SSE stream
494
+ # render sse: "hello world".each_char
495
+ # @example Render a one-off SSE update
496
+ # render sse: { message: "hello world" }
497
+ # @example Write to an SSE connection manually
498
+ # render sse: ->(connection) do
499
+ # connection.write("data: Hello, World!\n\n")
500
+ # connection.close
501
+ # end
494
502
  # @note `render` doesn't terminate execution of the action, so if you want to exit an action after rendering, you need to do something like `render(...) and return`.
495
- def render(json: nil, plain: nil, status: nil)
496
- raise "Render was called multiple times in this action" if @__rendered
503
+ def render(json: nil, plain: nil, sse: nil, status: nil)
504
+ raise "Render was called multiple times in this action." if @__rendered
497
505
  @__rendered = true
498
506
 
499
507
  if json || plain
500
508
  @__body << if json
501
509
  json.is_a?(String) ? json : json.to_json
502
510
  else
503
- headers["content-type"] = "text/plain; charset=utf-8"
511
+ @__headers["content-type"] = "text/plain; charset=utf-8"
504
512
  plain.to_s
505
513
  end
506
514
 
@@ -514,6 +522,20 @@ class RageController::API
514
522
  status
515
523
  end
516
524
  end
525
+
526
+ if sse
527
+ raise ArgumentError, "Cannot render both a standard body and an SSE stream." unless @__body.empty?
528
+
529
+ if status
530
+ return if @__status == 204
531
+ raise ArgumentError, "SSE responses only support 200 and 204 statuses." if @__status != 200
532
+ end
533
+
534
+ @__env["rack.upgrade?"] = :sse
535
+ @__env["rack.upgrade"] = Rage::SSE::Application.new(sse)
536
+ @__status = 200
537
+ @__headers["content-type"] = "text/event-stream; charset=utf-8"
538
+ end
517
539
  end
518
540
 
519
541
  # Send a response with no body.
data/lib/rage/cookies.rb CHANGED
@@ -16,7 +16,7 @@ end
16
16
  # Cookies provide a convenient way to store small amounts of data on the client side that persists across requests.
17
17
  # They are commonly used for session management, personalization, and tracking user preferences.
18
18
  #
19
- # Rage cookies support both simple string-based cookies and encrypted cookies for sensitive data.
19
+ # Rage cookies support simple, signed, and encrypted cookies.
20
20
  #
21
21
  # To use cookies, add the `domain_name` gem to your `Gemfile`:
22
22
  #
@@ -24,7 +24,7 @@ end
24
24
  # bundle add domain_name
25
25
  # ```
26
26
  #
27
- # Additionally, if you need to use encrypted cookies, see {Session} for setup steps.
27
+ # Additionally, if you need to use signed or encrypted cookies, see {Session} for setup steps.
28
28
  #
29
29
  # ## Usage
30
30
  #
@@ -70,6 +70,18 @@ end
70
70
  #
71
71
  # ```
72
72
  #
73
+ # ### Signed Cookies
74
+ #
75
+ # Store readable values with tamper protection:
76
+ #
77
+ # ```ruby
78
+ # # Set a signed cookie
79
+ # cookies.signed[:user_id] = 123
80
+ #
81
+ # # Read a signed cookie
82
+ # cookies.signed[:user_id] # => "123"
83
+ # ```
84
+ #
73
85
  # ### Permanent Cookies
74
86
  #
75
87
  # Create cookies that expire 20 years from now:
@@ -146,6 +158,17 @@ class Rage::Cookies
146
158
  dup.tap { |c| c.jar = EncryptedJar }
147
159
  end
148
160
 
161
+ # Returns a jar that'll automatically sign cookie values before sending them to the client and verify them
162
+ # for read. If the cookie was tampered with by the user (or a 3rd party), `nil` will be returned.
163
+ #
164
+ # This jar requires that you set a suitable secret for the verification on your app's `secret_key_base`.
165
+ #
166
+ # @example
167
+ # cookies.signed[:user_id] = current_user.id
168
+ def signed
169
+ dup.tap { |c| c.jar = SignedJar }
170
+ end
171
+
149
172
  # Returns a jar that'll automatically set the assigned cookies to have an expiration date 20 years from now.
150
173
  #
151
174
  # @example
@@ -249,11 +272,43 @@ class Rage::Cookies
249
272
  end
250
273
  end
251
274
 
275
+ module RbNaClKeyBuilder
276
+ RBNACL_MIN_VERSION = Gem::Version.create("3.3.0")
277
+ RBNACL_MAX_VERSION = Gem::Version.create("8.0.0")
278
+
279
+ private
280
+
281
+ def ensure_rbnacl!(purpose:)
282
+ return if defined?(RbNaCl) &&
283
+ Gem::Version.create(RbNaCl::VERSION) >= RBNACL_MIN_VERSION &&
284
+ Gem::Version.create(RbNaCl::VERSION) < RBNACL_MAX_VERSION
285
+
286
+ fail <<~ERR
287
+
288
+ Rage depends on `rbnacl` [>= #{RBNACL_MIN_VERSION}, < #{RBNACL_MAX_VERSION}] to support #{purpose}. Ensure the following line is added to your Gemfile:
289
+ gem "rbnacl"
290
+
291
+ ERR
292
+ end
293
+
294
+ def build_key(secret, purpose:)
295
+ ensure_rbnacl!(purpose: purpose)
296
+
297
+ if !secret
298
+ raise "Rage.config.secret_key_base should be set to use #{purpose}"
299
+ end
300
+
301
+ RbNaCl::Hash.blake2b("", key: [secret].pack("H*"), digest_size: 32, personal: purpose)
302
+ end
303
+ end
304
+
252
305
  class EncryptedJar
253
- INFO = "encrypted cookie"
306
+ PURPOSE = "encrypted cookie"
254
307
  PADDING = "00"
255
308
 
256
309
  class << self
310
+ include RbNaClKeyBuilder
311
+
257
312
  def load(value)
258
313
  box = primary_box
259
314
 
@@ -282,38 +337,108 @@ class Rage::Cookies
282
337
  private
283
338
 
284
339
  def primary_box
285
- @primary_box ||= begin
286
- if !defined?(RbNaCl) || !(Gem::Version.create(RbNaCl::VERSION) >= Gem::Version.create("3.3.0") && Gem::Version.create(RbNaCl::VERSION) < Gem::Version.create("8.0.0"))
287
- fail <<~ERR
288
-
289
- Rage depends on `rbnacl` [>= 3.3, < 8.0] to encrypt cookies. Ensure the following line is added to your Gemfile:
290
- gem "rbnacl"
291
-
292
- ERR
293
- end
294
-
295
- unless Rage.config.secret_key_base
296
- raise "Rage.config.secret_key_base should be set to use encrypted cookies"
297
- end
298
-
299
- RbNaCl::SimpleBox.from_secret_key(build_key(Rage.config.secret_key_base))
300
- end
340
+ @primary_box ||= RbNaCl::SimpleBox.from_secret_key(build_key(Rage.config.secret_key_base, purpose: PURPOSE))
301
341
  end
302
342
 
303
343
  def fallback_boxes
304
344
  @fallback_boxes ||= begin
305
345
  fallbacks = Rage.config.fallback_secret_key_base.map do |key|
306
- RbNaCl::SimpleBox.from_secret_key(build_key(key))
346
+ RbNaCl::SimpleBox.from_secret_key(build_key(key, purpose: PURPOSE))
307
347
  end
308
348
 
309
349
  fallbacks << RbNaCl::SimpleBox.from_secret_key(
310
- RbNaCl::Hash.blake2b(Rage.config.secret_key_base, digest_size: 32, salt: INFO)
350
+ RbNaCl::Hash.blake2b(Rage.config.secret_key_base, digest_size: 32, salt: PURPOSE)
311
351
  )
312
352
  end
313
353
  end
354
+ end # class << self
355
+ end
356
+
357
+ class SignedJar
358
+ PURPOSE = "signed cookie"
359
+ SEPARATOR = "."
360
+ VERSION = "00"
361
+
362
+ class << self
363
+ include RbNaClKeyBuilder
364
+
365
+ def load(value)
366
+ version, encoded_value, digest = parse_signed_cookie(value)
367
+ return nil if digest.nil?
368
+
369
+ return nil unless version == VERSION
370
+
371
+ signed_payload = signed_payload_for(version, encoded_value)
372
+ return Base64.urlsafe_decode64(encoded_value) if verify_digest?(signed_payload, digest)
373
+
374
+ Rage.logger.debug("Failed to verify signed cookie")
375
+ nil
376
+ rescue ArgumentError
377
+ Rage.logger.debug("Failed to decode signed cookie")
378
+ nil
379
+ end
380
+
381
+ def dump(value)
382
+ encoded_value = Base64.urlsafe_encode64(value.to_s)
383
+ signed_payload = signed_payload_for(VERSION, encoded_value)
384
+ "#{signed_payload}#{SEPARATOR}#{digest_for(signed_payload, primary_signer)}"
385
+ end
386
+
387
+ private
388
+
389
+ def parse_signed_cookie(value)
390
+ parts = value.to_s.split(SEPARATOR, 3)
391
+ return [nil, nil, nil] unless parts.length == 3
392
+
393
+ version, encoded_value, digest = parts
394
+ return [nil, nil, nil] if version.empty? || digest.empty?
395
+
396
+ [version, encoded_value, digest]
397
+ end
398
+
399
+ def signed_payload_for(version, encoded_value)
400
+ "#{version}#{SEPARATOR}#{encoded_value}"
401
+ end
402
+
403
+ def primary_signer
404
+ @primary_signer ||= RbNaCl::HMAC::SHA512256.new(
405
+ build_key(Rage.config.secret_key_base, purpose: PURPOSE)
406
+ )
407
+ end
408
+
409
+ def fallback_signers
410
+ @fallback_signers ||= Rage.config.fallback_secret_key_base.map do |key|
411
+ RbNaCl::HMAC::SHA512256.new(build_key(key, purpose: PURPOSE))
412
+ end
413
+ end
414
+
415
+ def digest_for(value, signer)
416
+ Base64.urlsafe_encode64(signer.auth(value))
417
+ end
418
+
419
+ def verify_digest?(signed_payload, digest)
420
+ decoded_digest = Base64.urlsafe_decode64(digest)
421
+ signer = primary_signer
422
+ i = 0
423
+ while true
424
+ begin
425
+ if signer.verify(decoded_digest, signed_payload)
426
+ return true
427
+ end
428
+ rescue RbNaCl::BadAuthenticatorError, RbNaCl::CryptoError
429
+ # digest does not match this signer; continue to fallback keys
430
+ end
431
+
432
+ signer = fallback_signers[i]
433
+ break if signer.nil?
434
+
435
+ Rage.logger.debug { "Trying to verify signed cookie with fallback key ##{i + 1}" }
436
+ i += 1
437
+ end
314
438
 
315
- def build_key(secret)
316
- RbNaCl::Hash.blake2b("", key: [secret].pack("H*"), digest_size: 32, personal: INFO)
439
+ false
440
+ rescue ArgumentError
441
+ false
317
442
  end
318
443
  end # class << self
319
444
  end
@@ -97,7 +97,7 @@ class Rage::FiberScheduler
97
97
  unless fulfilled
98
98
  fulfilled = true
99
99
  ::Iodine.defer { ::Iodine.unsubscribe(channel) }
100
- f.resume
100
+ f.resume if f.alive?
101
101
  end
102
102
  end
103
103
 
@@ -129,8 +129,12 @@ class Rage::OpenAPI::Converter
129
129
 
130
130
  node.auth.filter_map do |auth_entry|
131
131
  if available_before_actions.any? { |action_entry| action_entry[:name] == auth_entry[:method].to_sym }
132
- auth_name = auth_entry[:name].gsub(/[^A-Za-z0-9\-._]/, "")
133
- @used_security_schemes << auth_entry.merge(name: auth_name)
132
+ if auth_entry[:ref]
133
+ auth_name = auth_entry[:name]
134
+ else
135
+ auth_name = auth_entry[:name].gsub(/[^A-Za-z0-9\-._]/, "")
136
+ @used_security_schemes << auth_entry.merge(name: auth_name)
137
+ end
134
138
 
135
139
  { auth_name => [] }
136
140
  end
@@ -44,25 +44,28 @@ class Rage::OpenAPI::Parser
44
44
  parse_response_tag(expression, node, comments[i])
45
45
 
46
46
  elsif expression =~ /@auth\s/
47
- method, name, tail_name = expression[6..].split(" ", 3)
47
+ method, name_or_ref, tail_name = expression[6..].split(" ", 3)
48
48
  children = find_children(comments[i + 1..], node)
49
49
 
50
50
  if tail_name
51
51
  Rage::OpenAPI.__log_warn "incorrect `@auth` name detected at #{location_msg(comments[i])}; security scheme name cannot contain spaces"
52
52
  end
53
53
 
54
- auth_entry = {
54
+ auth_entry = parse_auth_tag(
55
55
  method:,
56
- name: name || method,
57
- definition: children.any? ? YAML.safe_load(children.join("\n")) : { "type" => "http", "scheme" => "bearer" }
58
- }
59
-
60
- if !node.controller.__before_action_exists?(method.to_sym)
61
- Rage::OpenAPI.__log_warn "referenced before action `#{method}` is not defined in #{node.controller} at #{location_msg(comments[i])}; ensure a corresponding `before_action` call exists"
62
- elsif node.auth.include?(auth_entry) || node.root.parent_nodes.any? { |parent_node| parent_node.auth.include?(auth_entry) }
63
- Rage::OpenAPI.__log_warn "duplicate @auth tag detected at #{location_msg(comments[i])}"
64
- else
65
- node.auth << auth_entry
56
+ name_or_ref:,
57
+ children:,
58
+ comment: comments[i]
59
+ )
60
+
61
+ if auth_entry
62
+ if !node.controller.__before_action_exists?(method.to_sym)
63
+ Rage::OpenAPI.__log_warn "referenced before action `#{method}` is not defined in #{node.controller} at #{location_msg(comments[i])}; ensure a corresponding `before_action` call exists"
64
+ elsif node.auth.include?(auth_entry) || node.root.parent_nodes.any? { |parent_node| parent_node.auth.include?(auth_entry) }
65
+ Rage::OpenAPI.__log_warn "duplicate @auth tag detected at #{location_msg(comments[i])}"
66
+ else
67
+ node.auth << auth_entry
68
+ end
66
69
  end
67
70
  end
68
71
 
@@ -211,6 +214,48 @@ class Rage::OpenAPI::Parser
211
214
  end
212
215
  end
213
216
 
217
+ def parse_auth_tag(method:, name_or_ref:, children:, comment:)
218
+ if method&.start_with?("#/components/securitySchemes/")
219
+ Rage::OpenAPI.__log_warn "invalid `@auth` shared reference syntax detected at #{location_msg(comment)}; use `@auth <before_action> #{method}` and remove child definitions"
220
+ return
221
+ end
222
+
223
+ if name_or_ref&.start_with?("#/components")
224
+ ref = parse_auth_shared_reference(name_or_ref, comment)
225
+ return if ref.nil?
226
+
227
+ if children.any?
228
+ Rage::OpenAPI.__log_warn "ignored child `@auth` definition detected at #{location_msg(comment)}; shared security scheme references do not support child definitions"
229
+ end
230
+
231
+ return {
232
+ method:,
233
+ name: ref.delete_prefix("#/components/securitySchemes/"),
234
+ ref: { "$ref" => ref }
235
+ }
236
+ end
237
+
238
+ {
239
+ method:,
240
+ name: name_or_ref || method,
241
+ definition: children.any? ? YAML.safe_load(children.join("\n")) : { "type" => "http", "scheme" => "bearer" }
242
+ }
243
+ end
244
+
245
+ def parse_auth_shared_reference(name_or_ref, comment)
246
+ shared_reference_parser = Rage::OpenAPI::Parsers::SharedReference.new
247
+ is_valid_ref = shared_reference_parser.parse(name_or_ref)
248
+ has_valid_prefix = name_or_ref.start_with?("#/components/securitySchemes/")
249
+ scheme_name = name_or_ref.delete_prefix("#/components/securitySchemes/")
250
+
251
+ if is_valid_ref && has_valid_prefix && !scheme_name.empty? && !scheme_name.include?("/")
252
+ name_or_ref
253
+ else
254
+ Rage::OpenAPI.__log_warn "invalid shared reference detected at #{location_msg(comment)}"
255
+ nil
256
+ end
257
+ end
258
+
214
259
  def parse_param_tag(expression, node, comment)
215
260
  param = expression[7..].strip
216
261
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @private
4
+ # This class is responsible for handling the lifecycle of an SSE connection.
5
+ # It determines the type of SSE stream and manages the data flow.
6
+ #
7
+ class Rage::SSE::Application
8
+ def initialize(stream)
9
+ @stream = stream
10
+
11
+ @type = if @stream.is_a?(Enumerator)
12
+ :stream
13
+ elsif @stream.is_a?(Proc)
14
+ :manual
15
+ else
16
+ :single
17
+ end
18
+
19
+ @log_tags, @log_context = Fiber[:__rage_logger_tags], Fiber[:__rage_logger_context]
20
+ end
21
+
22
+ def on_open(connection)
23
+ @type == :single ? send_data(connection) : start_stream(connection)
24
+ end
25
+
26
+ private
27
+
28
+ def send_data(connection)
29
+ Rage::Telemetry.tracer.span_sse_stream_process(connection:, type: @type) do
30
+ connection.write(Rage::SSE.__serialize(@stream))
31
+ connection.close
32
+ end
33
+ end
34
+
35
+ def start_stream(connection)
36
+ Fiber.schedule do
37
+ Fiber[:__rage_logger_tags], Fiber[:__rage_logger_context] = @log_tags, @log_context
38
+ Rage::Telemetry.tracer.span_sse_stream_process(connection:, type: @type) do
39
+ @type == :stream ? start_formatted_stream(connection) : start_raw_stream(connection)
40
+ end
41
+ rescue => e
42
+ Rage.logger.error("SSE stream failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
43
+ end
44
+ end
45
+
46
+ def start_formatted_stream(connection)
47
+ @stream.each do |event|
48
+ break if !connection.open?
49
+ connection.write(Rage::SSE.__serialize(event)) if event
50
+ end
51
+ ensure
52
+ connection.close
53
+ end
54
+
55
+ def start_raw_stream(connection)
56
+ @stream.call(Rage::SSE::ConnectionProxy.new(connection))
57
+ end
58
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # This class acts as a proxy for the underlying SSE connection, providing a simplified and safe interface for interacting with the stream.
5
+ # It ensures that operations are only performed on an open connection and abstracts away the direct connection handling.
6
+ #
7
+ # Example:
8
+ #
9
+ # ```ruby
10
+ # render sse: ->(connection) do
11
+ # # `connection` is an instance of Rage::SSE::ConnectionProxy
12
+ # end
13
+ # ```
14
+ #
15
+ class Rage::SSE::ConnectionProxy
16
+ # @private
17
+ def initialize(connection)
18
+ @connection = connection
19
+ end
20
+
21
+ # Writes data to the SSE stream.
22
+ # @param data [#to_s]
23
+ # @raise [IOError] if the stream is already closed.
24
+ def write(data)
25
+ raise IOError, "closed stream" unless @connection.open?
26
+ @connection.write(data.to_s)
27
+ end
28
+
29
+ alias_method :<<, :write
30
+
31
+ # Closes the SSE stream.
32
+ def close
33
+ @connection.close
34
+ end
35
+
36
+ alias_method :close_write, :close
37
+
38
+ # Checks if the SSE stream is closed.
39
+ # @return [Boolean]
40
+ def closed?
41
+ !@connection.open?
42
+ end
43
+
44
+ # A no-op method to maintain interface compatibility.
45
+ # Flushing is handled by the underlying connection.
46
+ # @raise [IOError] if the stream is already closed.
47
+ def flush
48
+ raise IOError, "closed stream" unless @connection.open?
49
+ end
50
+
51
+ # A no-op method to maintain interface compatibility.
52
+ # Reading from an SSE stream is not supported on the server side.
53
+ def read(...)
54
+ end
55
+
56
+ # A no-op method to maintain interface compatibility.
57
+ # Reading from an SSE stream is not supported on the server side.
58
+ def close_read
59
+ end
60
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Represents a single Server-Sent Event. This class allows you to define the `id`, `event`, and `retry` fields for an SSE message.
4
+ #
5
+ # @!attribute id
6
+ # @return [String] The `id` field for the SSE event. This can be used to track messages.
7
+ # @!attribute event
8
+ # @return [String] The `event` field for the SSE event. This can be used to define custom event types.
9
+ # @!attribute retry
10
+ # @return [Integer] The `retry` field for the SSE event, in milliseconds. This value is a suggestion for the client about how long to wait before reconnecting.
11
+ # @!attribute data
12
+ # @return [String, #to_json] The `data` field for the SSE event. If the object provided is not a string, it will be serialized to JSON.
13
+ Rage::SSE::Message = Struct.new(:id, :event, :retry, :data, keyword_init: true) do
14
+ def to_s
15
+ data_entry = if !data.is_a?(String)
16
+ "data: #{data.to_json}\n"
17
+ elsif data.include?("\n")
18
+ data.split("\n").map { |d| "data: #{d}\n" }.join
19
+ else
20
+ "data: #{data}\n"
21
+ end
22
+
23
+ "#{data_entry}#{"id: #{id}\n" if id}#{"event: #{event}\n" if event}#{"retry: #{self.retry.to_i}\n" if self.retry && self.retry.to_i > 0}\n"
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rage::SSE
4
+ # A factory method for creating Server-Sent Events.
5
+ #
6
+ # @param data [String, #to_json] The `data` field for the SSE event. If the object provided is not a string, it will be serialized to JSON.
7
+ # @param id [String, nil] The `id` field for the SSE event. This can be used to track messages.
8
+ # @param event [String, nil] The `event` field for the SSE event. This can be used to define custom event types.
9
+ # @param retry [Integer, nil] The `retry` field for the SSE event, in milliseconds. This value is used to instruct the client how long to wait before attempting to reconnect.
10
+ # @return [Message] The formatted SSE event.
11
+ # @example
12
+ # render sse: Rage::SSE.message(current_user.profile, id: current_user.id)
13
+ def self.message(data, id: nil, event: nil, retry: nil)
14
+ Message.new(data:, id:, event:, retry:)
15
+ end
16
+
17
+ # @private
18
+ def self.__serialize(data)
19
+ if data.is_a?(String)
20
+ data.include?("\n") ? Message.new(data:).to_s : "data: #{data}\n\n"
21
+ elsif data.is_a?(Message)
22
+ data.to_s
23
+ else
24
+ "data: #{data.to_json}\n\n"
25
+ end
26
+ end
27
+ end
28
+
29
+ require_relative "application"
30
+ require_relative "connection_proxy"
31
+ require_relative "message"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  ##
4
- # The **core.fiber.dispatch** span tracks the scheduling and processing of system-level fibers created by the framework to process requests and deferred tasks.
4
+ # The **core.fiber.dispatch** span tracks the scheduling and processing of system-level fibers created by the framework to process requests, deferred tasks, or SSE streams.
5
5
  #
6
6
  # This span is started when a system fiber begins processing and ends when the fiber has completed processing.
7
7
  # See {handle handle} for the list of arguments passed to handler methods.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # The **sse.stream.process** span wraps the processing of an SSE stream.
5
+ #
6
+ # This span starts when a connection is opened and ends when the stream is finished.
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::ProcessSSEStream
12
+ class << self
13
+ # @private
14
+ def id
15
+ "sse.stream.process"
16
+ end
17
+
18
+ # @private
19
+ def span_parameters
20
+ %w[connection: type:]
21
+ end
22
+
23
+ # @private
24
+ def handler_arguments
25
+ {
26
+ name: '"SSE.process"',
27
+ env: "connection.env",
28
+ type: "type"
29
+ }
30
+ end
31
+
32
+ # @!parse [ruby]
33
+ # # @param id ["sse.stream.process"] ID of the span
34
+ # # @param name ["SSE.process"] human-readable name of the operation
35
+ # # @param env [Hash] the Rack environment associated with the connection
36
+ # # @param type [:stream, :single, :manual] the type of the SSE stream
37
+ # # @yieldreturn [Rage::Telemetry::SpanResult]
38
+ # #
39
+ # # @example
40
+ # # class MyTelemetryHandler < Rage::Telemetry::Handler
41
+ # # handle "sse.stream.process", with: :my_handler
42
+ # #
43
+ # # def my_handler(id:, name:, env:, type:)
44
+ # # yield
45
+ # # end
46
+ # # end
47
+ # # @note Rage automatically detects which parameters your handler method accepts and only passes those parameters.
48
+ # # You can omit any of the parameters described here.
49
+ # def handle(id:, name:, env:, type:)
50
+ # end
51
+ end
52
+ end
@@ -46,12 +46,13 @@ module Rage::Telemetry
46
46
 
47
47
  # @private
48
48
  def self.tracer
49
- @tracer ||= Tracer.new(__registry, Rage.config.telemetry.handlers_map)
49
+ @tracer ||= Tracer.new(__registry)
50
50
  end
51
51
 
52
52
  # @private
53
- def self.__setup
54
- tracer.setup
53
+ # @param handlers_map [Hash{String => Array<Rage::Telemetry::HandlerRef>}]
54
+ def self.__setup(handlers_map)
55
+ tracer.setup(handlers_map)
55
56
  end
56
57
 
57
58
  ##
@@ -81,6 +82,7 @@ module Rage::Telemetry
81
82
  # | `deferred.task.process` | {ProcessDeferredTask} | Wraps the processing of deferred tasks |
82
83
  # | `events.event.publish` | {PublishEvent} | Wraps the publishing of events via {Rage::Events Rage::Events} |
83
84
  # | `events.subscriber.process` | {ProcessEventSubscriber} | Wraps the processing of events by subscribers |
85
+ # | `sse.stream.process` | {ProcessSSEStream} | Wraps the processing of an SSE stream |
84
86
  #
85
87
  module Spans
86
88
  end
@@ -6,20 +6,18 @@ class Rage::Telemetry::Tracer
6
6
  private_constant :DEFAULT_SPAN_RESULT
7
7
 
8
8
  # @param spans_registry [Hash{String => Rage::Telemetry::Spans}]
9
- # @param handlers_map [Hash{String => Array<Rage::Telemetry::HandlerRef>}]
10
- def initialize(spans_registry, handlers_map)
9
+ def initialize(spans_registry)
11
10
  @spans_registry = spans_registry
12
- @handlers_map = handlers_map
13
-
14
- @all_handler_refs = handlers_map.values.flatten
15
11
 
16
12
  @spans_registry.each do |_, span|
17
13
  setup_noop(span)
18
14
  end
19
15
  end
20
16
 
21
- def setup
22
- @handlers_map.each do |span_id, handler_refs|
17
+ def setup(handlers_map)
18
+ @all_handler_refs = handlers_map.values.flatten
19
+
20
+ handlers_map.each do |span_id, handler_refs|
23
21
  setup_tracer(@spans_registry[span_id], handler_refs)
24
22
  end
25
23
  end
data/lib/rage/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rage
4
- VERSION = "1.21.2"
4
+ VERSION = "1.22.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -40,6 +40,12 @@ module Rage
40
40
  Rage::Events
41
41
  end
42
42
 
43
+ # Shorthand to access {Rage::SSE Rage::SSE}.
44
+ # @return [Rage::SSE]
45
+ def self.sse
46
+ Rage::SSE
47
+ end
48
+
43
49
  # Configure routes for the Rage application.
44
50
  # @return [Rage::Router::DSL::Handler]
45
51
  # @example
@@ -185,6 +191,7 @@ module Rage
185
191
  autoload :OpenAPI, "rage/openapi/openapi"
186
192
  autoload :Deferred, "rage/deferred/deferred"
187
193
  autoload :Events, "rage/events/events"
194
+ autoload :SSE, "rage/sse/sse"
188
195
  end
189
196
 
190
197
  module RageController
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.21.2
4
+ version: 1.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '4.3'
46
+ version: '5.2'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '4.3'
53
+ version: '5.2'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: zeitwerk
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -221,6 +221,10 @@ files:
221
221
  - lib/rage/rspec.rb
222
222
  - lib/rage/session.rb
223
223
  - lib/rage/setup.rb
224
+ - lib/rage/sse/application.rb
225
+ - lib/rage/sse/connection_proxy.rb
226
+ - lib/rage/sse/message.rb
227
+ - lib/rage/sse/sse.rb
224
228
  - lib/rage/tasks.rb
225
229
  - lib/rage/telemetry/handler.rb
226
230
  - lib/rage/telemetry/spans/await_fiber.rb
@@ -233,6 +237,7 @@ files:
233
237
  - lib/rage/telemetry/spans/process_controller_action.rb
234
238
  - lib/rage/telemetry/spans/process_deferred_task.rb
235
239
  - lib/rage/telemetry/spans/process_event_subscriber.rb
240
+ - lib/rage/telemetry/spans/process_sse_stream.rb
236
241
  - lib/rage/telemetry/spans/publish_event.rb
237
242
  - lib/rage/telemetry/spans/spawn_fiber.rb
238
243
  - lib/rage/telemetry/telemetry.rb