pulse_zero 0.3.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 (30) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +91 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +281 -0
  5. data/lib/generators/pulse_zero/install/install_generator.rb +186 -0
  6. data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/channel.rb.tt +6 -0
  7. data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/connection.rb.tt +59 -0
  8. data/lib/generators/pulse_zero/install/templates/backend/app/channels/pulse/channel.rb.tt +15 -0
  9. data/lib/generators/pulse_zero/install/templates/backend/app/controllers/concerns/pulse/request_id_tracking.rb.tt +17 -0
  10. data/lib/generators/pulse_zero/install/templates/backend/app/jobs/pulse/broadcast_job.rb.tt +28 -0
  11. data/lib/generators/pulse_zero/install/templates/backend/app/models/concerns/pulse/broadcastable.rb.tt +85 -0
  12. data/lib/generators/pulse_zero/install/templates/backend/app/models/current.rb.tt +9 -0
  13. data/lib/generators/pulse_zero/install/templates/backend/config/initializers/pulse.rb.tt +43 -0
  14. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/engine.rb.tt +43 -0
  15. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/broadcasts.rb.tt +80 -0
  16. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/stream_name.rb.tt +34 -0
  17. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/thread_debouncer.rb.tt +31 -0
  18. data/lib/generators/pulse_zero/install/templates/backend/lib/pulse.rb.tt +38 -0
  19. data/lib/generators/pulse_zero/install/templates/docs/PULSE_USAGE.md.tt +532 -0
  20. data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-pulse.ts.tt +66 -0
  21. data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-visibility-refresh.ts.tt +61 -0
  22. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-connection.ts.tt +169 -0
  23. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-recovery-strategy.ts.tt +156 -0
  24. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-visibility-manager.ts.tt +143 -0
  25. data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse.ts.tt +130 -0
  26. data/lib/pulse_zero/engine.rb +10 -0
  27. data/lib/pulse_zero/version.rb +5 -0
  28. data/lib/pulse_zero.rb +13 -0
  29. data/pulse_zero.gemspec +35 -0
  30. metadata +109 -0
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulse
4
+ class BroadcastJob < ApplicationJob
5
+ queue_as { Pulse.config.queue_name }
6
+
7
+ discard_on ActiveJob::DeserializationError
8
+
9
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5
10
+
11
+ def perform(streamables:, event:, payload:, request_id: nil)
12
+ return if ENV["PULSE_DISABLED"] == "true"
13
+
14
+ stream = Pulse::Streams::StreamName.signed_stream_name(streamables)
15
+ message = {
16
+ event: event,
17
+ payload: payload,
18
+ requestId: request_id,
19
+ at: Time.current.to_f
20
+ }
21
+
22
+ ActionCable.server.broadcast(stream, message.to_json)
23
+ rescue => e
24
+ Rails.logger.error "[Pulse] Broadcast job error: #{e.message}"
25
+ raise
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulse::Broadcastable
4
+ extend ActiveSupport::Concern
5
+ include Pulse::Streams::Broadcasts
6
+
7
+ included do
8
+ thread_mattr_accessor :suppressed_pulse_broadcasts, instance_accessor: false
9
+ end
10
+
11
+ def suppressed_pulse_broadcasts?
12
+ self.class.suppressed_pulse_broadcasts
13
+ end
14
+
15
+ class_methods do
16
+ # Configures the model to broadcast creates, updates, and destroys
17
+ def broadcasts_to(stream, **rendering)
18
+ after_create_commit -> { broadcast_created_later_to(stream.try(:call, self) || send(stream), **rendering) }
19
+ after_update_commit -> { broadcast_updated_later_to(stream.try(:call, self) || send(stream), **rendering) }
20
+ after_destroy_commit -> { broadcast_deleted_to(stream.try(:call, self) || send(stream), **rendering) }
21
+ end
22
+
23
+ def broadcasts(stream = model_name.plural, **rendering)
24
+ broadcasts_to(stream, **rendering)
25
+ end
26
+
27
+ def broadcasts_refreshes_to(stream, **rendering)
28
+ after_commit -> { broadcast_refresh_later_to(stream.try(:call, self) || send(stream), **rendering) }
29
+ end
30
+
31
+ # Suppress broadcasts during bulk operations
32
+ def suppressing_pulse_broadcasts(&block)
33
+ original, self.suppressed_pulse_broadcasts = self.suppressed_pulse_broadcasts, true
34
+ yield
35
+ ensure
36
+ self.suppressed_pulse_broadcasts = original
37
+ end
38
+ end
39
+
40
+ # Instance methods with payload extraction
41
+ def broadcast_created_to(*streamables, **rendering)
42
+ broadcast_event_to(*streamables, event: :created, **extract_options_and_add_payload(rendering))
43
+ end
44
+
45
+ def broadcast_updated_to(*streamables, **rendering)
46
+ broadcast_event_to(*streamables, event: :updated, **extract_options_and_add_payload(rendering))
47
+ end
48
+
49
+ def broadcast_deleted_to(*streamables, **rendering)
50
+ broadcast_event_to(*streamables, event: :deleted, **extract_options_and_add_payload(rendering))
51
+ end
52
+
53
+ def broadcast_refresh_to(*streamables, **rendering)
54
+ broadcast_event_to(*streamables, event: :refresh, payload: {}, **rendering)
55
+ end
56
+
57
+ # Async variants
58
+ def broadcast_created_later_to(*streamables, **rendering)
59
+ broadcast_event_later_to(*streamables, event: :created, **extract_options_and_add_payload(rendering))
60
+ end
61
+
62
+ def broadcast_updated_later_to(*streamables, **rendering)
63
+ broadcast_event_later_to(*streamables, event: :updated, **extract_options_and_add_payload(rendering))
64
+ end
65
+
66
+ def broadcast_deleted_later_to(*streamables, **rendering)
67
+ broadcast_event_later_to(*streamables, event: :deleted, **extract_options_and_add_payload(rendering))
68
+ end
69
+
70
+ def broadcast_refresh_later_to(*streamables, **rendering)
71
+ broadcast_event_later_to(*streamables, event: :refresh, payload: {}, **rendering)
72
+ end
73
+
74
+ private
75
+
76
+ def extract_options_and_add_payload(rendering = {})
77
+ return rendering if suppressed_pulse_broadcasts?
78
+
79
+ rendering.tap do |opts|
80
+ # Use custom payload if provided, otherwise use serialized model
81
+ opts[:payload] ||= Pulse.serializer.call(self)
82
+ opts[:request_id] ||= Current.pulse_request_id if defined?(Current.pulse_request_id)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Current < ActiveSupport::CurrentAttributes
4
+ attribute :pulse_request_id
5
+
6
+ # Add other attributes as needed for your application
7
+ # attribute :account
8
+ # attribute :request
9
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.configure do
4
+ # Configure Pulse real-time broadcasting
5
+ # Note: Pulse module must be loaded from lib/pulse.rb
6
+
7
+ if defined?(Pulse)
8
+ # Debounce window in milliseconds
9
+ # This prevents rapid-fire broadcasts from overwhelming clients
10
+ Pulse.config.debounce_ms = 300
11
+
12
+ # Background job queue name
13
+ # Use a lower priority queue if broadcasts are not time-critical
14
+ Pulse.config.queue_name = :default
15
+
16
+ # Custom serializer for broadcast payloads
17
+ # Return a hash that will be sent to the frontend
18
+ # Default: ->(rec) { rec.as_json }
19
+ #
20
+ # Pulse.config.serializer = lambda do |record|
21
+ # case record
22
+ # when Post
23
+ # {
24
+ # id: record.id,
25
+ # title: record.title,
26
+ # content: record.content,
27
+ # state: record.state,
28
+ # author_name: record.user.name,
29
+ # updated_at: record.updated_at.iso8601
30
+ # }
31
+ # else
32
+ # record.as_json
33
+ # end
34
+ # end
35
+
36
+ # Disable broadcasts in test environment
37
+ if Rails.env.test?
38
+ ENV["PULSE_DISABLED"] = "true"
39
+ end
40
+ else
41
+ Rails.logger.warn "[Pulse] Module not loaded. Ensure lib is in autoload paths and pulse.rb exists."
42
+ end
43
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Pulse
6
+ class Engine < Rails::Engine
7
+ isolate_namespace Pulse
8
+
9
+ config.pulse = ActiveSupport::OrderedOptions.new
10
+ config.pulse.queue_name = :default
11
+ config.pulse.debounce_ms = 300
12
+ config.pulse.serializer = ->(rec) { rec.as_json }
13
+
14
+ # Autoload paths for Pulse components
15
+ config.autoload_once_paths = %W[
16
+ #{root}/app/channels
17
+ #{root}/app/controllers/concerns
18
+ #{root}/app/models/concerns
19
+ #{root}/app/jobs
20
+ ]
21
+
22
+ # Don't eager load jobs if ActiveJob is not available
23
+ initializer "pulse.no_active_job", before: :set_eager_load_paths do
24
+ unless defined?(ActiveJob)
25
+ config.eager_load_paths.delete("#{root}/app/jobs")
26
+ end
27
+ end
28
+
29
+ # Don't eager load channels if ActionCable is not available
30
+ initializer "pulse.no_action_cable", before: :set_eager_load_paths do
31
+ unless defined?(ActionCable)
32
+ config.eager_load_paths.delete("#{root}/app/channels")
33
+ end
34
+ end
35
+
36
+ # Set up configuration after Rails loads
37
+ initializer "pulse.configure" do
38
+ if defined?(Rails) && Rails.application
39
+ Pulse.config = config.pulse
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulse::Streams::Broadcasts
4
+ include Pulse::Streams::StreamName
5
+
6
+ def broadcast_created_to(*streamables, **opts)
7
+ broadcast_event_to(*streamables, event: :created, **opts)
8
+ end
9
+
10
+ def broadcast_updated_to(*streamables, **opts)
11
+ broadcast_event_to(*streamables, event: :updated, **opts)
12
+ end
13
+
14
+ def broadcast_deleted_to(*streamables, **opts)
15
+ broadcast_event_to(*streamables, event: :deleted, **opts)
16
+ end
17
+
18
+ def broadcast_refresh_to(*streamables, **opts)
19
+ broadcast_event_to(*streamables, event: :refresh, payload: {}, **opts)
20
+ end
21
+
22
+ # Async variants
23
+ def broadcast_created_later_to(*streamables, **opts)
24
+ broadcast_event_later_to(*streamables, event: :created, **opts)
25
+ end
26
+
27
+ def broadcast_updated_later_to(*streamables, **opts)
28
+ broadcast_event_later_to(*streamables, event: :updated, **opts)
29
+ end
30
+
31
+ def broadcast_deleted_later_to(*streamables, **opts)
32
+ broadcast_event_later_to(*streamables, event: :deleted, **opts)
33
+ end
34
+
35
+ def broadcast_refresh_later_to(*streamables, **opts)
36
+ broadcast_event_later_to(*streamables, event: :refresh, payload: {}, **opts)
37
+ end
38
+
39
+ def broadcast_event_to(*streamables, event:, payload: nil, request_id: nil)
40
+ broadcast_stream_to(*streamables,
41
+ event: event,
42
+ payload: payload,
43
+ request_id: request_id
44
+ )
45
+ end
46
+
47
+ def broadcast_event_later_to(*streamables, **opts)
48
+ if defined?(Pulse::BroadcastJob)
49
+ streamables.flatten!
50
+ streamables.compact!
51
+ Pulse::BroadcastJob.perform_later(streamables: streamables, **opts)
52
+ else
53
+ broadcast_event_to(*streamables, **opts)
54
+ end
55
+ end
56
+
57
+ def broadcast_stream_to(*streamables, event:, payload:, request_id: nil)
58
+ streamables.flatten!
59
+ streamables.compact!
60
+
61
+ return if streamables.empty?
62
+ return if ENV["PULSE_DISABLED"] == "true"
63
+
64
+ stream = signed_stream_name(streamables)
65
+ message = {
66
+ event: event,
67
+ payload: payload,
68
+ requestId: request_id,
69
+ at: Time.current.to_f
70
+ }
71
+
72
+ if defined?(Pulse::ThreadDebouncer)
73
+ Pulse::ThreadDebouncer.for(stream).debounce do
74
+ ActionCable.server.broadcast(stream, message.to_json)
75
+ end
76
+ else
77
+ ActionCable.server.broadcast(stream, message.to_json)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulse::Streams::StreamName
4
+ extend self
5
+
6
+ # Used by Pulse::Channel to verify a signed stream name
7
+ def verified_stream_name(signed_stream_name)
8
+ Pulse.signed_stream_verifier.verified signed_stream_name
9
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
10
+ nil
11
+ end
12
+
13
+ # Used to generate a signed stream name from streamables
14
+ def signed_stream_name(streamables)
15
+ Pulse.signed_stream_verifier.generate stream_name_from(streamables)
16
+ end
17
+
18
+ module ClassMethods
19
+ # Can be used by custom channels to obtain signed stream name from params
20
+ def verified_stream_name_from_params
21
+ Pulse::Streams::StreamName.verified_stream_name(params[:'signed-stream-name'])
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def stream_name_from(streamables)
28
+ if streamables.is_a?(Array)
29
+ streamables.map { |streamable| stream_name_from(streamable) }.join(":")
30
+ else
31
+ streamables.then { |streamable| streamable.try(:to_gid_param) || streamable.to_param }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulse
4
+ class ThreadDebouncer
5
+ def self.for(key, **opt)
6
+ Thread.current[key] ||= new(key, Thread.current, **opt)
7
+ end
8
+ private_class_method :new
9
+
10
+ def initialize(key, thread, delay: 0.3)
11
+ @key, @thread = key, thread
12
+ @delay = delay
13
+ @timer = nil
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def debounce(&blk)
18
+ @mutex.synchronize do
19
+ @timer&.kill
20
+ @timer = Thread.new do
21
+ sleep @delay
22
+ blk.call
23
+ end.tap { Thread.current[@key] = nil }
24
+ end
25
+ end
26
+
27
+ def wait
28
+ @timer&.join
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pulse
4
+ extend ActiveSupport::Autoload
5
+
6
+ autoload :Engine, "pulse/engine"
7
+ autoload :ThreadDebouncer, "pulse/thread_debouncer"
8
+
9
+ module Streams
10
+ extend ActiveSupport::Autoload
11
+
12
+ autoload :Broadcasts, "pulse/streams/broadcasts"
13
+ autoload :StreamName, "pulse/streams/stream_name"
14
+ end
15
+
16
+ # Configuration
17
+ mattr_accessor :config
18
+ self.config = ActiveSupport::OrderedOptions.new
19
+ config.queue_name = :default
20
+ config.debounce_ms = 300
21
+ config.serializer = ->(rec) { rec.as_json }
22
+
23
+ # Thread-safe stream verifier
24
+ class << self
25
+ attr_writer :signed_stream_verifier
26
+
27
+ def signed_stream_verifier
28
+ @signed_stream_verifier ||= begin
29
+ key = Rails.application.secret_key_base
30
+ ActiveSupport::MessageVerifier.new(key, serializer: JSON)
31
+ end
32
+ end
33
+
34
+ def serializer
35
+ config.serializer
36
+ end
37
+ end
38
+ end