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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +91 -0
- data/LICENSE.txt +21 -0
- data/README.md +281 -0
- data/lib/generators/pulse_zero/install/install_generator.rb +186 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/channel.rb.tt +6 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/channels/application_cable/connection.rb.tt +59 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/channels/pulse/channel.rb.tt +15 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/controllers/concerns/pulse/request_id_tracking.rb.tt +17 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/jobs/pulse/broadcast_job.rb.tt +28 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/models/concerns/pulse/broadcastable.rb.tt +85 -0
- data/lib/generators/pulse_zero/install/templates/backend/app/models/current.rb.tt +9 -0
- data/lib/generators/pulse_zero/install/templates/backend/config/initializers/pulse.rb.tt +43 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/engine.rb.tt +43 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/broadcasts.rb.tt +80 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/streams/stream_name.rb.tt +34 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse/thread_debouncer.rb.tt +31 -0
- data/lib/generators/pulse_zero/install/templates/backend/lib/pulse.rb.tt +38 -0
- data/lib/generators/pulse_zero/install/templates/docs/PULSE_USAGE.md.tt +532 -0
- data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-pulse.ts.tt +66 -0
- data/lib/generators/pulse_zero/install/templates/frontend/hooks/use-visibility-refresh.ts.tt +61 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-connection.ts.tt +169 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-recovery-strategy.ts.tt +156 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse-visibility-manager.ts.tt +143 -0
- data/lib/generators/pulse_zero/install/templates/frontend/lib/pulse.ts.tt +130 -0
- data/lib/pulse_zero/engine.rb +10 -0
- data/lib/pulse_zero/version.rb +5 -0
- data/lib/pulse_zero.rb +13 -0
- data/pulse_zero.gemspec +35 -0
- 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,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
|