Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,21 @@ You can also set it to a different number (in seconds)
heartbeat: 0.5
```

#### Per-stream override

The `#stream` method also accepts a `heartbeat:` keyword that overrides the constructor-level setting for a single call. This is useful when a dispatcher is generally configured with a heartbeat but a particular response doesn't need one (e.g. a one-shot update). The previous value is restored once the call returns.

```ruby
datastar = Datastar.new(request:, response:) # default heartbeat

# Disable heartbeat for this single response
datastar.stream(heartbeat: false) do |sse|
sse.patch_elements(html)
end
```

The one-shot helpers (`#patch_elements`, `#remove_elements`, `#patch_signals`, `#remove_signals`, `#execute_script`, `#redirect`) use this internally to avoid spawning a heartbeat thread for a single message.

#### Manual connection check

If you want to check connection status on your own, you can disable the heartbeat and use `sse.check_connection!`, which will close the connection and trigger callbacks if the client is disconnected.
Expand Down
40 changes: 24 additions & 16 deletions lib/datastar/dispatcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class Dispatcher
HTTP_ACCEPT = 'HTTP_ACCEPT'
HTTP1 = 'HTTP/1.1'

attr_reader :request, :response
attr_reader :request, :response, :heartbeat

# @option request [Rack::Request] the request object
# @option response [Rack::Response, nil] the response object
Expand Down Expand Up @@ -137,7 +137,7 @@ def signals
# @param elements [String, #call(view_context: Object) => Object] the HTML elements or object
# @param options [Hash] the options to send with the message
def patch_elements(elements, options = BLANK_OPTIONS)
stream_no_heartbeat do |sse|
stream(heartbeat: false) do |sse|
sse.patch_elements(elements, options)
end
end
Expand All @@ -152,7 +152,7 @@ def patch_elements(elements, options = BLANK_OPTIONS)
# @param selector [String] a CSS selector for the fragment to remove
# @param options [Hash] the options to send with the message
def remove_elements(selector, options = BLANK_OPTIONS)
stream_no_heartbeat do |sse|
stream(heartbeat: false) do |sse|
sse.remove_elements(selector, options)
end
end
Expand All @@ -166,7 +166,7 @@ def remove_elements(selector, options = BLANK_OPTIONS)
# @param signals [Hash, String] signals to merge
# @param options [Hash] the options to send with the message
def patch_signals(signals, options = BLANK_OPTIONS)
stream_no_heartbeat do |sse|
stream(heartbeat: false) do |sse|
sse.patch_signals(signals, options)
end
end
Expand All @@ -180,7 +180,7 @@ def patch_signals(signals, options = BLANK_OPTIONS)
# @param paths [Array<String>] object paths to the signals to remove
# @param options [Hash] the options to send with the message
def remove_signals(paths, options = BLANK_OPTIONS)
stream_no_heartbeat do |sse|
stream(heartbeat: false) do |sse|
sse.remove_signals(paths, options)
end
end
Expand All @@ -194,7 +194,7 @@ def remove_signals(paths, options = BLANK_OPTIONS)
# @param script [String] the script to execute
# @param options [Hash] the options to send with the message
def execute_script(script, options = BLANK_OPTIONS)
stream_no_heartbeat do |sse|
stream(heartbeat: false) do |sse|
sse.execute_script(script, options)
end
end
Expand All @@ -204,7 +204,7 @@ def execute_script(script, options = BLANK_OPTIONS)
#
# @param url [String] the URL or path to redirect to
def redirect(url)
stream_no_heartbeat do |sse|
stream(heartbeat: false) do |sse|
sse.redirect(url)
end
end
Expand Down Expand Up @@ -245,10 +245,24 @@ def redirect(url)
# By default, the built-in Rack finalzer just returns the resposne Array which can be used by any Rack handler.
# On Rails, the Rails controller response is set to this objects streaming response.
#
# A per-call +heartbeat:+ keyword overrides the constructor-level heartbeat
# for the duration of this call. Pass +false+ to disable heartbeat for a
# one-shot message (e.g. a single +patch_elements+), or a Numeric interval
# to enable it. The previous value is restored once the call returns.
# @example Disable heartbeat for a single response
#
# datastar.stream(heartbeat: false) do |sse|
# sse.patch_elements(html)
# end
#
# @param streamer [#call(ServerSentEventGenerator), nil] a callable to call with the generator
# @param heartbeat [Numeric, false] override the heartbeat interval for this call, or +false+ to disable
# @yieldparam sse [ServerSentEventGenerator] the generator object
# @return [Object] depends on the finalize callback
def stream(streamer = nil, &block)
def stream(streamer = nil, heartbeat: @heartbeat, &block)
heartbeat_was = @heartbeat
@heartbeat = heartbeat

streamer ||= block
@streamers << streamer
if @heartbeat && !@heartbeat_on
Expand All @@ -269,18 +283,12 @@ def stream(streamer = nil, &block)

@response.body = body
@finalize.call(@view_context, @response)
ensure
@heartbeat = heartbeat_was
end

private

def stream_no_heartbeat(&block)
was = @heartbeat
@heartbeat = false
stream(&block).tap do
@heartbeat = was
end
end

# Produce a response body for a single stream
# In this case, the SSE generator can write directly to the socket
#
Expand Down
25 changes: 25 additions & 0 deletions spec/dispatcher_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,31 @@ def self.render_in(view_context) = %(<div id="foo">\n<span>#{view_context}</span
expect(block_called).to be(true)
end

specify '#stream with per-call heartbeat: false overrides constructor heartbeat' do
dispatcher = Datastar.new(request:, response:, heartbeat: 0.001)
connected = true
block_called = false
dispatcher.on_client_disconnect { |conn| connected = false }

socket = TestSocket.new(open: false)

dispatcher.stream(heartbeat: false) do |sse|
sleep 0.001
block_called = true
end

dispatcher.response.body.call(socket)
expect(connected).to be(true)
expect(block_called).to be(true)
end

specify '#stream restores heartbeat state after a per-call override' do
dispatcher = Datastar.new(request:, response:, heartbeat: 0.001)

dispatcher.stream(heartbeat: false) { |sse| }
expect(dispatcher.heartbeat).to eq(0.001)
end

specify '#signals' do
request = build_request(
%(/events),
Expand Down
Loading