+ SSE events will be streamed from the backend to the frontend.
+
+
+
+
+
+
+
+
+
Hello, world!
+
+
+
\ No newline at end of file
diff --git a/examples/ruby/hello-world/hello-world.ru b/examples/ruby/hello-world/hello-world.ru
new file mode 100644
index 000000000..7925f2de0
--- /dev/null
+++ b/examples/ruby/hello-world/hello-world.ru
@@ -0,0 +1,37 @@
+require 'bundler/setup'
+
+require 'datastar'
+
+# This is a test Rack endpoint
+# with a hello world example using Datastar.
+# To run:
+#
+# # install dependencies
+# bundle install
+# # run this endpoint with Puma server
+# bundle exec puma ./hello-world.ru
+#
+# Then open http://localhost:9292
+#
+HTML = File.read(File.expand_path('hello-world.html', __dir__))
+
+run do |env|
+ datastar = Datastar.from_rack_env(env)
+
+ if datastar.sse?
+ delay = (datastar.signals['delay'] || 0).to_i
+ delay /= 1000.0 if delay.positive?
+ message = 'Hello, world!'
+
+ datastar.stream do |sse|
+ message.size.times do |i|
+ sse.merge_fragments(%(
#{message[0..i]}
))
+ sleep delay
+ end
+ end
+ else
+ [200, { 'content-type' => 'text/html' }, [HTML]]
+ end
+end
+
+trap('INT') { exit }
diff --git a/examples/ruby/threads.ru b/examples/ruby/threads.ru
new file mode 100644
index 000000000..60ff43a54
--- /dev/null
+++ b/examples/ruby/threads.ru
@@ -0,0 +1,84 @@
+require 'bundler/setup'
+
+require 'datastar'
+
+# This is a test Rack endpoint
+# to demo streaming Datastar updates from multiple threads.
+# To run:
+#
+# # install dependencies
+# bundle install
+# # run this endpoint with Puma server
+# bundle exec puma ./threaded.ru
+#
+# visit http://localhost:9292
+#
+INDEX = <<~HTML
+
+
+
+
+ Datastar counter
+
+
+
+
+
+
Slow thread: waiting
+
Fast thread: waiting
+
Disconnected...
+
+
+HTML
+
+trap('INT') { exit }
+
+run do |env|
+ # Initialize Datastar with callbacks
+ datastar = Datastar
+ .from_rack_env(env)
+ .on_connect do |sse|
+ sse.merge_fragments(%(
Connected...
))
+ p ['connect', sse]
+ end.on_server_disconnect do |sse|
+ sse.merge_fragments(%(
Done...
))
+ p ['server disconnect', sse]
+ end.on_client_disconnect do |socket|
+ p ['client disconnect', socket]
+ end.on_error do |error|
+ p ['exception', error]
+ puts error.backtrace.join("\n")
+ end
+
+ if datastar.sse?
+ # This will run in its own thread / fiber
+ datastar.stream do |sse|
+ 11.times do |i|
+ sleep 1
+ # Raising an error to demonstrate error handling
+ # raise ArgumentError, 'This is an error' if i > 5
+
+ sse.merge_fragments(%(#{i}))
+ end
+ end
+
+ # Another thread / fiber
+ datastar.stream do |sse|
+ 1000.times do |i|
+ sleep 0.01
+ sse.merge_fragments(%(#{i}))
+ end
+ end
+ else
+ [200, { 'content-type' => 'text/html' }, [INDEX]]
+ end
+end
diff --git a/examples/ruby/threads/Gemfile b/examples/ruby/threads/Gemfile
new file mode 100644
index 000000000..27d6a9d92
--- /dev/null
+++ b/examples/ruby/threads/Gemfile
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
+
+gem 'puma'
+gem 'rack'
+# gem 'datastar'
+gem 'datastar', path: '../../../sdk/ruby'
diff --git a/examples/ruby/threads/Gemfile.lock b/examples/ruby/threads/Gemfile.lock
new file mode 100644
index 000000000..83df723bf
--- /dev/null
+++ b/examples/ruby/threads/Gemfile.lock
@@ -0,0 +1,25 @@
+PATH
+ remote: ../../../sdk/ruby
+ specs:
+ datastar (1.0.0.beta.1)
+ rack (~> 3.0)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ nio4r (2.7.4)
+ puma (6.6.0)
+ nio4r (~> 2.0)
+ rack (3.1.9)
+
+PLATFORMS
+ arm64-darwin-24
+ ruby
+
+DEPENDENCIES
+ datastar!
+ puma
+ rack
+
+BUNDLED WITH
+ 2.6.3
diff --git a/examples/ruby/threads/threads.ru b/examples/ruby/threads/threads.ru
new file mode 100644
index 000000000..425174dae
--- /dev/null
+++ b/examples/ruby/threads/threads.ru
@@ -0,0 +1,84 @@
+require 'bundler/setup'
+
+require 'datastar'
+
+# This is a test Rack endpoint
+# to demo streaming Datastar updates from multiple threads.
+# To run:
+#
+# # install dependencies
+# bundle install
+# # run this endpoint with Puma server
+# bundle exec puma examples/threaded.ru
+#
+# visit http://localhost:9292
+#
+INDEX = <<~HTML
+
+
+
+
+ Datastar counter
+
+
+
+
+
+
Slow thread: waiting
+
Fast thread: waiting
+
Disconnected...
+
+
+HTML
+
+trap('INT') { exit }
+
+run do |env|
+ # Initialize Datastar with callbacks
+ datastar = Datastar
+ .from_rack_env(env)
+ .on_connect do |sse|
+ sse.merge_fragments(%(
Connected...
))
+ p ['connect', sse]
+ end.on_server_disconnect do |sse|
+ sse.merge_fragments(%(
Done...
))
+ p ['server disconnect', sse]
+ end.on_client_disconnect do |socket|
+ p ['client disconnect', socket]
+ end.on_error do |error|
+ p ['exception', error]
+ puts error.backtrace.join("\n")
+ end
+
+ if datastar.sse?
+ # This will run in its own thread / fiber
+ datastar.stream do |sse|
+ 11.times do |i|
+ sleep 1
+ # Raising an error to demonstrate error handling
+ # raise ArgumentError, 'This is an error' if i > 5
+
+ sse.merge_fragments(%(#{i}))
+ end
+ end
+
+ # Another thread / fiber
+ datastar.stream do |sse|
+ 1000.times do |i|
+ sleep 0.01
+ sse.merge_fragments(%(#{i}))
+ end
+ end
+ else
+ [200, { 'content-type' => 'text/html' }, [INDEX]]
+ end
+end
diff --git a/sdk/ruby/.gitignore b/sdk/ruby/.gitignore
new file mode 100644
index 000000000..b04a8c840
--- /dev/null
+++ b/sdk/ruby/.gitignore
@@ -0,0 +1,11 @@
+/.bundle/
+/.yardoc
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+
+# rspec failure tracking
+.rspec_status
diff --git a/sdk/ruby/.rspec b/sdk/ruby/.rspec
new file mode 100644
index 000000000..34c5164d9
--- /dev/null
+++ b/sdk/ruby/.rspec
@@ -0,0 +1,3 @@
+--format documentation
+--color
+--require spec_helper
diff --git a/sdk/ruby/Gemfile b/sdk/ruby/Gemfile
new file mode 100644
index 000000000..b8f8aabf3
--- /dev/null
+++ b/sdk/ruby/Gemfile
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
+
+# Specify your gem's dependencies in datastar.gemspec
+gemspec
+
+gem 'rake', '~> 13.0'
+
+gem 'rspec', '~> 3.0'
+
+gem 'debug'
+
+group :test do
+ # Async to test Datastar::AsyncExecutor
+ gem 'async'
+ # Puma to host test server
+ gem 'puma'
+end
diff --git a/sdk/ruby/Gemfile.lock b/sdk/ruby/Gemfile.lock
new file mode 100644
index 000000000..cb252369c
--- /dev/null
+++ b/sdk/ruby/Gemfile.lock
@@ -0,0 +1,81 @@
+PATH
+ remote: .
+ specs:
+ datastar (1.0.0.beta.1)
+ rack (~> 3.0)
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ async (2.21.3)
+ console (~> 1.29)
+ fiber-annotation
+ io-event (~> 1.7)
+ metrics (~> 0.12)
+ traces (~> 0.15)
+ console (1.29.2)
+ fiber-annotation
+ fiber-local (~> 1.1)
+ json
+ date (3.4.1)
+ debug (1.10.0)
+ irb (~> 1.10)
+ reline (>= 0.3.8)
+ diff-lcs (1.5.1)
+ fiber-annotation (0.2.0)
+ fiber-local (1.1.0)
+ fiber-storage
+ fiber-storage (1.0.0)
+ io-console (0.8.0)
+ io-event (1.7.5)
+ irb (1.15.1)
+ pp (>= 0.6.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ json (2.9.1)
+ metrics (0.12.1)
+ nio4r (2.7.4)
+ pp (0.6.2)
+ prettyprint
+ prettyprint (0.2.0)
+ psych (5.2.3)
+ date
+ stringio
+ puma (6.6.0)
+ nio4r (~> 2.0)
+ rack (3.1.9)
+ rake (13.2.1)
+ rdoc (6.11.0)
+ psych (>= 4.0.0)
+ reline (0.6.0)
+ io-console (~> 0.5)
+ rspec (3.13.0)
+ rspec-core (~> 3.13.0)
+ rspec-expectations (~> 3.13.0)
+ rspec-mocks (~> 3.13.0)
+ rspec-core (3.13.2)
+ rspec-support (~> 3.13.0)
+ rspec-expectations (3.13.3)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-mocks (3.13.2)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-support (3.13.2)
+ stringio (3.1.2)
+ traces (0.15.2)
+
+PLATFORMS
+ arm64-darwin-24
+ ruby
+
+DEPENDENCIES
+ async
+ datastar!
+ debug
+ puma
+ rake (~> 13.0)
+ rspec (~> 3.0)
+
+BUNDLED WITH
+ 2.5.23
diff --git a/sdk/ruby/README.md b/sdk/ruby/README.md
new file mode 100644
index 000000000..24cf919ef
--- /dev/null
+++ b/sdk/ruby/README.md
@@ -0,0 +1,256 @@
+# Datastar Ruby SDK
+
+Implement the [Datastart SSE procotocol](https://data-star.dev/reference/sse_events) in Ruby. It can be used in any Rack handler, and Rails controllers.
+
+## Installation
+
+Install the gem and add to the application's Gemfile by executing:
+
+```bash
+bundle add datastar
+```
+
+Or point your `Gemfile` to the source
+
+```bash
+gem 'datastar', git: 'https://github.com/starfederation/datastar', glob: 'sdk/ruby/*.gemspec'
+```
+
+## Usage
+
+### Initialize the Datastar dispatcher
+
+In your Rack handler or Rails controller:
+
+```ruby
+# Rails controllers, as well as Sinatra and others,
+# already have request and response objects
+
+datastar = Datastar.new(request:, response:, view_context: self)
+
+# In a Rack handler, you can instantiate from the Rack env
+datastar = Datastar.from_rack_env(env)
+```
+
+### Sending updates to the browser
+
+There are two ways to use this gem in HTTP handlers:
+
+* One-off responses, where you want to send a single update down to the browser.
+* Streaming responses, where you want to send multiple updates down to the browser.
+
+#### One-off update:
+
+```ruby
+datastar.merge_fragments(%(
Hello, World!
))
+```
+In this mode, the response is closed after the fragment is sent.
+
+#### Streaming updates
+
+```ruby
+datastar.stream do |sse|
+ sse.merge_fragments(%(
))
+ end
+end
+```
+In this mode, the response is kept open until `stream` blocks have finished.
+
+#### Concurrent streaming blocks
+
+Multiple `stream` blocks will be launched in threads/fibers, and will run concurrently.
+Their updates are linearized and sent to the browser as they are produced.
+
+```ruby
+# Stream to the browser from two concurrent threads
+datastar.stream do |sse|
+ 100.times do |i|
+ sleep 1
+ sse.merge_fragments(%(
#{i}!
))
+ end
+end
+
+datastar.stream do |sse|
+ 1000.times do |i|
+ sleep 0.1
+ sse.merge_fragments(%(
#{i}!
))
+ end
+end
+```
+
+See the [examples](https://github.com/starfederation/datastar/tree/main/examples/ruby) directory.
+
+### Datastar methods
+
+All these methods are available in both the one-off and the streaming modes.
+
+#### `merge_fragments`
+See https://data-star.dev/reference/sse_events#datastar-merge-fragments
+
+```ruby
+sse.merge_fragments(%(
\nhello\n
))
+
+# or a Phlex view object
+sse.merge_fragments(UserComponet.new)
+
+# Or pass options
+sse.merge_fragments(
+ %(
\nhello\n
),
+ merge_mode: 'append'
+)
+```
+
+#### `remove_fragments`
+ See https://data-star.dev/reference/sse_events#datastar-remove-fragments
+
+```ruby
+sse.remove_fragments('#users')
+```
+
+#### `merge_signals`
+ See https://data-star.dev/reference/sse_events#datastar-merge-signals
+
+```ruby
+sse.merge_signals(count: 4, user: { name: 'John' })
+```
+
+#### `remove_signals`
+ See https://data-star.dev/reference/sse_events#datastar-remove-signals
+
+```ruby
+sse.remove_signals(['user.name', 'user.email'])
+```
+
+#### `execute_script`
+See https://data-star.dev/reference/sse_events#datastar-execute-script
+
+```ruby
+sse.execute_scriprt(%(alert('Hello World!'))
+ ```
+
+#### `signals`
+See https://data-star.dev/guide/getting_started#data-signals
+
+Returns signals sent by the browser.
+
+```ruby
+sse.signals # => { user: { name: 'John' } }
+ ```
+
+#### `redirect`
+This is just a helper to send a script to update the browser's location.
+
+```ruby
+sse.redirect('/new_location')
+ ```
+
+### Lifecycle callbacks
+
+#### `on_connect`
+Register server-side code to run when the connection is first handled.
+
+```ruby
+datastar.on_connect do
+ puts 'A user has connected'
+end
+```
+
+#### `on_client_disconnect`
+Register server-side code to run when the connection is closed by the client
+
+```ruby
+datastar.on_client_connect do
+ puts 'A user has disconnected connected'
+end
+```
+
+#### `on_server_disconnect`
+Register server-side code to run when the connection is closed by the server.
+Ie when the served is done streaming without errors.
+
+```ruby
+datastar.on_server_connect do
+ puts 'Server is done streaming'
+end
+```
+
+#### `on_error`
+Ruby code to handle any exceptions raised by streaming blocks.
+
+```ruby
+datastar.on_error do |exception|
+ Sentry.notify(exception)
+end
+```
+Note that this callback can be registered globally, too.
+
+### Global configuration
+
+```ruby
+Datastar.configure do |config|
+ config.on_error do |exception|
+ Sentry.notify(exception)
+ end
+end
+```
+
+### Rails
+
+#### Rendering Rails templates
+
+```ruby
+datastar.stream do |sse|
+ 10.times do |i|
+ sleep 1
+ tpl = render_to_string('events/user', layout: false, locals: { name: "David #{i}" })
+ sse.merge_fragments tpl
+ end
+end
+```
+
+#### Rendering Phlex components
+
+`#merge_fragments` supports [Phlex](https://www.phlex.fun) component instances.
+
+```ruby
+sse.merge_fragments(UserComponent.new(user: User.first))
+```
+
+### Tests
+
+```ruby
+bundle exec rspec
+```
+
+#### Running Datastar's SDK test suite
+
+Install dependencies.
+```bash
+bundle install
+```
+
+From this library's root, run the bundled-in test Rack app:
+
+```bash
+bundle puma examples/test.ru
+```
+
+Now run the test bash scripts in the `test` directory in this repo.
+
+```bash
+./test-all.sh http://localhost:9292
+```
+
+## Development
+
+After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
+
+To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
+
+## Contributing
+
+Bug reports and pull requests are welcome on GitHub at https://github.com/starfederation/datastar.
diff --git a/sdk/ruby/Rakefile b/sdk/ruby/Rakefile
new file mode 100644
index 000000000..b6ae73410
--- /dev/null
+++ b/sdk/ruby/Rakefile
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require "bundler/gem_tasks"
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+
+task default: :spec
diff --git a/sdk/ruby/bin/console b/sdk/ruby/bin/console
new file mode 100755
index 000000000..3b74ca07d
--- /dev/null
+++ b/sdk/ruby/bin/console
@@ -0,0 +1,11 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "bundler/setup"
+require "datastar"
+
+# You can add fixtures and/or initialization code here to make experimenting
+# with your gem easier. You can also use a different console, if you like.
+
+require "irb"
+IRB.start(__FILE__)
diff --git a/sdk/ruby/bin/setup b/sdk/ruby/bin/setup
new file mode 100755
index 000000000..dce67d860
--- /dev/null
+++ b/sdk/ruby/bin/setup
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+IFS=$'\n\t'
+set -vx
+
+bundle install
+
+# Do any other automated setup that you need to do here
diff --git a/sdk/ruby/datastar.gemspec b/sdk/ruby/datastar.gemspec
new file mode 100644
index 000000000..1b668cc7d
--- /dev/null
+++ b/sdk/ruby/datastar.gemspec
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require_relative 'lib/datastar/version'
+
+Gem::Specification.new do |spec|
+ spec.name = 'datastar'
+ spec.version = Datastar::VERSION
+ spec.authors = ['Ismael Celis']
+ spec.email = ['ismaelct@gmail.com']
+
+ spec.summary = 'Ruby SDK for Datastar. Rack-compatible.'
+ spec.homepage = 'https://github.com/starfederation/datastar#readme'
+ spec.required_ruby_version = '>= 3.0.0'
+
+ spec.metadata['homepage_uri'] = spec.homepage
+ spec.metadata['source_code_uri'] = 'https://github.com/starfederation/datastar'
+
+ # Specify which files should be added to the gem when it is released.
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
+ gemspec = File.basename(__FILE__)
+ spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
+ ls.readlines("\x0", chomp: true).reject do |f|
+ (f == gemspec) ||
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile])
+ end
+ end
+ spec.bindir = 'exe'
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
+ spec.require_paths = ['lib']
+
+ # Uncomment to register a new dependency of your gem
+ spec.add_dependency 'rack', '~> 3.0'
+
+ # For more information and examples about making a new gem, check out our
+ # guide at: https://bundler.io/guides/creating_gem.html
+end
diff --git a/sdk/ruby/examples/test.ru b/sdk/ruby/examples/test.ru
new file mode 100644
index 000000000..4e505ecf6
--- /dev/null
+++ b/sdk/ruby/examples/test.ru
@@ -0,0 +1,56 @@
+require 'bundler'
+Bundler.setup(:test)
+
+require 'datastar'
+
+# This is a test Rack endpoint to run
+# Datastar's SDK test suite agains.
+# To run:
+#
+# # install dependencies
+# bundle install
+# # run this endpoint with Puma server
+# bundle exec puma examples/test.ru
+#
+# Then you can run SDK's test bash script:
+# See https://github.com/starfederation/datastar/blob/develop/sdk/test/README.md
+#
+# ./test-all.sh http://localhost:9292
+#
+run do |env|
+ datastar = Datastar
+ .from_rack_env(env)
+ .on_connect do |socket|
+ p ['connect', socket]
+ end.on_server_disconnect do |socket|
+ p ['server disconnect', socket]
+ end.on_client_disconnect do |socket|
+ p ['client disconnect', socket]
+ end.on_error do |error|
+ p ['exception', error]
+ puts error.backtrace.join("\n")
+ end
+
+ datastar.stream do |sse|
+ sse.signals['events'].each do |event|
+ type = event.delete('type')
+ case type
+ when 'mergeSignals'
+ arg = event.delete('signals')
+ sse.merge_signals(arg, event)
+ when 'removeSignals'
+ arg = event.delete('paths')
+ sse.remove_signals(arg, event)
+ when 'executeScript'
+ arg = event.delete('script')
+ sse.execute_script(arg, event)
+ when 'mergeFragments'
+ arg = event.delete('fragments')
+ sse.merge_fragments(arg, event)
+ when 'removeFragments'
+ arg = event.delete('selector')
+ sse.remove_fragments(arg, event)
+ end
+ end
+ end
+end
diff --git a/sdk/ruby/lib/datastar.rb b/sdk/ruby/lib/datastar.rb
new file mode 100644
index 000000000..8441a3df4
--- /dev/null
+++ b/sdk/ruby/lib/datastar.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require_relative 'datastar/version'
+require_relative 'datastar/consts'
+
+module Datastar
+ BLANK_OPTIONS = {}.freeze
+
+ def self.config
+ @config ||= Configuration.new
+ end
+
+ def self.configure(&)
+ yield config if block_given?
+ config.freeze
+ config
+ end
+
+ def self.new(...)
+ Dispatcher.new(...)
+ end
+
+ def self.from_rack_env(env, view_context: nil)
+ request = Rack::Request.new(env)
+ Dispatcher.new(request:, view_context:)
+ end
+end
+
+require_relative 'datastar/configuration'
+require_relative 'datastar/dispatcher'
+require_relative 'datastar/server_sent_event_generator'
+require_relative 'datastar/railtie' if defined?(Rails::Railtie)
diff --git a/sdk/ruby/lib/datastar/async_executor.rb b/sdk/ruby/lib/datastar/async_executor.rb
new file mode 100644
index 000000000..9faab1515
--- /dev/null
+++ b/sdk/ruby/lib/datastar/async_executor.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'async'
+require 'async/queue'
+
+module Datastar
+ # An executor that uses Fibers (via the Async library)
+ # Use this when Rails is configured to use Fibers
+ # or when using the Falcon web server
+ # See https://github.com/socketry/falcon
+ class AsyncExecutor
+ def initialize
+ # Async::Task instances
+ # that raise exceptions log
+ # the error with :warn level,
+ # even if the exception is handled upstream
+ # See https://github.com/socketry/async/blob/9851cb945ae49a85375d120219000fe7db457307/lib/async/task.rb#L204
+ # Not great to silence these logs for ALL tasks
+ # in a Rails app (I only want to silence them for Datastar tasks)
+ Console.logger.disable(Async::Task)
+ end
+
+ def new_queue = Async::Queue.new
+
+ def prepare(response); end
+
+ def spawn(&block)
+ Async(&block)
+ end
+
+ def stop(threads)
+ threads.each(&:stop)
+ end
+ end
+end
diff --git a/sdk/ruby/lib/datastar/configuration.rb b/sdk/ruby/lib/datastar/configuration.rb
new file mode 100644
index 000000000..6edbdff8a
--- /dev/null
+++ b/sdk/ruby/lib/datastar/configuration.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'thread'
+
+module Datastar
+ # The default executor based on Ruby threads
+ class ThreadExecutor
+ def new_queue = Queue.new
+
+ def prepare(response); end
+
+ def spawn(&block)
+ Thread.new(&block)
+ end
+
+ def stop(threads)
+ threads.each(&:kill)
+ end
+ end
+
+ # Datastar configuration
+ # @example
+ #
+ # Datastar.configure do |config|
+ # config.on_error do |error|
+ # Sentry.notify(error)
+ # end
+ # end
+ #
+ # You'd normally do this on app initialization
+ # For example in a Rails initializer
+ class Configuration
+ NOOP_CALLBACK = ->(_error) {}
+ RACK_FINALIZE = ->(_view_context, response) { response.finish }
+
+ attr_accessor :executor, :error_callback, :finalize
+
+ def initialize
+ @executor = ThreadExecutor.new
+ @error_callback = NOOP_CALLBACK
+ @finalize = RACK_FINALIZE
+ end
+
+ def on_error(callable = nil, &block)
+ @error_callback = callable || block
+ self
+ end
+ end
+end
diff --git a/sdk/ruby/lib/datastar/consts.rb b/sdk/ruby/lib/datastar/consts.rb
new file mode 100644
index 000000000..80bef754d
--- /dev/null
+++ b/sdk/ruby/lib/datastar/consts.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+# This is auto-generated by Datastar. DO NOT EDIT.
+module Datastar
+ module Consts
+ DATASTAR_KEY = 'datastar'
+ VERSION = '1.0.0-beta.3'
+
+ # The default duration for settling during fragment merges. Allows for CSS transitions to complete.
+ DEFAULT_FRAGMENTS_SETTLE_DURATION = 300
+
+ # The default duration for retrying SSE on connection reset. This is part of the underlying retry mechanism of SSE.
+ DEFAULT_SSE_RETRY_DURATION = 1000
+
+ # Should fragments be merged using the ViewTransition API?
+ DEFAULT_FRAGMENTS_USE_VIEW_TRANSITIONS = false
+
+ # Should a given set of signals merge if they are missing?
+ DEFAULT_MERGE_SIGNALS_ONLY_IF_MISSING = false
+
+ # Should script element remove itself after execution?
+ DEFAULT_EXECUTE_SCRIPT_AUTO_REMOVE = true
+
+ # The default attributes for element use when executing scripts. It is a set of key-value pairs delimited by a newline \\n character.}
+ DEFAULT_EXECUTE_SCRIPT_ATTRIBUTES = 'type module'
+
+ module FragmentMergeMode
+
+ # Morphs the fragment into the existing element using idiomorph.
+ MORPH = 'morph'
+
+ # Replaces the inner HTML of the existing element.
+ INNER = 'inner'
+
+ # Replaces the outer HTML of the existing element.
+ OUTER = 'outer'
+
+ # Prepends the fragment to the existing element.
+ PREPEND = 'prepend'
+
+ # Appends the fragment to the existing element.
+ APPEND = 'append'
+
+ # Inserts the fragment before the existing element.
+ BEFORE = 'before'
+
+ # Inserts the fragment after the existing element.
+ AFTER = 'after'
+
+ # Upserts the attributes of the existing element.
+ UPSERT_ATTRIBUTES = 'upsertAttributes'
+ end
+
+ # The mode in which a fragment is merged into the DOM.
+ DEFAULT_FRAGMENT_MERGE_MODE = FragmentMergeMode::MORPH
+
+ # Dataline literals.
+ SELECTOR_DATALINE_LITERAL = 'selector'
+ MERGE_MODE_DATALINE_LITERAL = 'mergeMode'
+ SETTLE_DURATION_DATALINE_LITERAL = 'settleDuration'
+ FRAGMENTS_DATALINE_LITERAL = 'fragments'
+ USE_VIEW_TRANSITION_DATALINE_LITERAL = 'useViewTransition'
+ SIGNALS_DATALINE_LITERAL = 'signals'
+ ONLY_IF_MISSING_DATALINE_LITERAL = 'onlyIfMissing'
+ PATHS_DATALINE_LITERAL = 'paths'
+ SCRIPT_DATALINE_LITERAL = 'script'
+ ATTRIBUTES_DATALINE_LITERAL = 'attributes'
+ AUTO_REMOVE_DATALINE_LITERAL = 'autoRemove'
+ end
+end
\ No newline at end of file
diff --git a/sdk/ruby/lib/datastar/dispatcher.rb b/sdk/ruby/lib/datastar/dispatcher.rb
new file mode 100644
index 000000000..20e5b9c24
--- /dev/null
+++ b/sdk/ruby/lib/datastar/dispatcher.rb
@@ -0,0 +1,361 @@
+# frozen_string_literal: true
+
+module Datastar
+ # The Dispatcher encapsulates the logic of handling a request
+ # and building a response with streaming datastar messages.
+ # You'll normally instantiate a Dispatcher in your controller action of Rack handler
+ # via Datastar.new.
+ # @example
+ #
+ # datastar = Datastar.new(request:, response:, view_context: self)
+ #
+ # # One-off fragment response
+ # datastar.merge_fragments(template)
+ #
+ # # Streaming response with multiple messages
+ # datastar.stream do |sse|
+ # sse.merge_fragments(template)
+ # 10.times do |i|
+ # sleep 0.1
+ # sse.merge_signals(count: i)
+ # end
+ # end
+ #
+ class Dispatcher
+ BLANK_BODY = [].freeze
+ SSE_CONTENT_TYPE = 'text/event-stream'
+ HTTP_ACCEPT = 'HTTP_ACCEPT'
+ HTTP1 = 'HTTP/1.1'
+
+ attr_reader :request, :response
+
+ # @option request [Rack::Request] the request object
+ # @option response [Rack::Response, nil] the response object
+ # @option view_context [Object, nil] the view context object, to use when rendering templates. Ie. a controller, or Sinatra app.
+ # @option executor [Object] the executor object to use for managing threads and queues
+ # @option error_callback [Proc] the callback to call when an error occurs
+ # @option finalize [Proc] the callback to call when the response is finalized
+ def initialize(
+ request:,
+ response: nil,
+ view_context: nil,
+ executor: Datastar.config.executor,
+ error_callback: Datastar.config.error_callback,
+ finalize: Datastar.config.finalize
+ )
+ @on_connect = []
+ @on_client_disconnect = []
+ @on_server_disconnect = []
+ @on_error = [error_callback]
+ @finalize = finalize
+ @streamers = []
+ @queue = nil
+ @executor = executor
+ @view_context = view_context
+ @request = request
+ @response = Rack::Response.new(BLANK_BODY, 200, response&.headers || {})
+ @response.content_type = SSE_CONTENT_TYPE
+ @response.headers['Cache-Control'] = 'no-cache'
+ @response.headers['Connection'] = 'keep-alive' if @request.env['SERVER_PROTOCOL'] == HTTP1
+ # Disable response buffering in NGinx and other proxies
+ @response.headers['X-Accel-Buffering'] = 'no'
+ @response.delete_header 'Content-Length'
+ @executor.prepare(@response)
+ end
+
+ # Check if the request accepts SSE responses
+ # @return [Boolean]
+ def sse?
+ @request.get_header(HTTP_ACCEPT) == SSE_CONTENT_TYPE
+ end
+
+ # Register an on-connect callback
+ # Triggered when the request is handled
+ # @param callable [Proc, nil] the callback to call
+ # @yieldparam sse [ServerSentEventGenerator] the generator object
+ # @return [self]
+ def on_connect(callable = nil, &block)
+ @on_connect << (callable || block)
+ self
+ end
+
+ # Register a callback for client disconnection
+ # Ex. when the browser is closed mid-stream
+ # @param callable [Proc, nil] the callback to call
+ # @return [self]
+ def on_client_disconnect(callable = nil, &block)
+ @on_client_disconnect << (callable || block)
+ self
+ end
+
+ # Register a callback for server disconnection
+ # Ex. when the server finishes serving the request
+ # @param callable [Proc, nil] the callback to call
+ # @return [self]
+ def on_server_disconnect(callable = nil, &block)
+ @on_server_disconnect << (callable || block)
+ self
+ end
+
+ # Register a callback server-side exceptions
+ # Ex. when one of the server threads raises an exception
+ # @param callable [Proc, nil] the callback to call
+ # @return [self]
+ def on_error(callable = nil, &block)
+ @on_error << (callable || block)
+ self
+ end
+
+ # Parse and returns Datastar signals sent by the client.
+ # See https://data-star.dev/guide/getting_started#data-signals
+ # @return [Hash]
+ def signals
+ @signals ||= parse_signals(request).freeze
+ end
+
+ # Send one-off fragments to the UI
+ # See https://data-star.dev/reference/sse_events#datastar-merge-fragments
+ # @example
+ #
+ # datastar.merge_fragments(%(
\nhello\n
\n))
+ # # or a Phlex view object
+ # datastar.merge_fragments(UserComponet.new)
+ #
+ # @param fragments [String, #call(view_context: Object) => Object] the HTML fragment or object
+ # @param options [Hash] the options to send with the message
+ def merge_fragments(fragments, options = BLANK_OPTIONS)
+ stream do |sse|
+ sse.merge_fragments(fragments, options)
+ end
+ end
+
+ # One-off remove fragments from the UI
+ # See https://data-star.dev/reference/sse_events#datastar-remove-fragments
+ # @example
+ #
+ # datastar.remove_fragments('#users')
+ #
+ # @param selector [String] a CSS selector for the fragment to remove
+ # @param options [Hash] the options to send with the message
+ def remove_fragments(selector, options = BLANK_OPTIONS)
+ stream do |sse|
+ sse.remove_fragments(selector, options)
+ end
+ end
+
+ # One-off merge signals in the UI
+ # See https://data-star.dev/reference/sse_events#datastar-merge-signals
+ # @example
+ #
+ # datastar.merge_signals(count: 1, toggle: true)
+ #
+ # @param signals [Hash] signals to merge
+ # @param options [Hash] the options to send with the message
+ def merge_signals(signals, options = BLANK_OPTIONS)
+ stream do |sse|
+ sse.merge_signals(signals, options)
+ end
+ end
+
+ # One-off remove signals from the UI
+ # See https://data-star.dev/reference/sse_events#datastar-remove-signals
+ # @example
+ #
+ # datastar.remove_signals(['user.name', 'user.email'])
+ #
+ # @param paths [Array] 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 do |sse|
+ sse.remove_signals(paths, options)
+ end
+ end
+
+ # One-off execute script in the UI
+ # See https://data-star.dev/reference/sse_events#datastar-execute-script
+ # @example
+ #
+ # datastar.execute_scriprt(%(alert('Hello World!'))
+ #
+ # @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 do |sse|
+ sse.execute_script(script, options)
+ end
+ end
+
+ # Send an execute_script event
+ # to change window.location
+ #
+ # @param url [String] the URL or path to redirect to
+ def redirect(url)
+ stream do |sse|
+ sse.redirect(url)
+ end
+ end
+
+ # Start a streaming response
+ # A generator object is passed to the block
+ # The generator supports all the Datastar methods listed above (it's the same type)
+ # But you can call them multiple times to send multiple messages down an open SSE connection.
+ # @example
+ #
+ # datastar.stream do |sse|
+ # total = 300
+ # sse.merge_fragments(%())
+ # total.times do |i|
+ # sse.merge_signals(progress: i)
+ # end
+ # end
+ #
+ # This methods also captures exceptions raised in the block and triggers
+ # any error callbacks. Client disconnection errors trigger the @on_client_disconnect callbacks.
+ # Finally, when the block is done streaming, the @on_server_disconnect callbacks are triggered.
+ #
+ # When multiple streams are scheduled this way,
+ # this SDK will spawn each block in separate threads (or fibers, depending on executor)
+ # and linearize their writes to the connection socket
+ # @example
+ #
+ # datastar.stream do |sse|
+ # # update things here
+ # end
+ #
+ # datastar.stream do |sse|
+ # # more concurrent updates here
+ # end
+ #
+ # As a last step, the finalize callback is called with the view context and the response
+ # This is so that different frameworks can setup their responses correctly.
+ # 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.
+ #
+ # @param streamer [#call(ServerSentEventGenerator), nil] a callable to call with the generator
+ # @yieldparam sse [ServerSentEventGenerator] the generator object
+ # @return [Object] depends on the finalize callback
+ def stream(streamer = nil, &block)
+ streamer ||= block
+ @streamers << streamer
+
+ body = if @streamers.size == 1
+ stream_one(streamer)
+ else
+ stream_many(streamer)
+ end
+
+ @response.body = body
+ @finalize.call(@view_context, @response)
+ end
+
+ private
+
+ # Produce a response body for a single stream
+ # In this case, the SSE generator can write directly to the socket
+ #
+ # @param streamer [#call(ServerSentEventGenerator)]
+ # @return [Proc]
+ # @api private
+ def stream_one(streamer)
+ proc do |socket|
+ generator = ServerSentEventGenerator.new(socket, signals:, view_context: @view_context)
+ @on_connect.each { |callable| callable.call(generator) }
+ handling_errors(generator, socket) do
+ streamer.call(generator)
+ end
+ ensure
+ socket.close
+ end
+ end
+
+ # Produce a response body for multiple streams
+ # Each "streamer" is spawned in a separate thread
+ # and they write to a shared queue
+ # Then we wait on the queue and write to the socket
+ # In this way we linearize socket writes
+ # Exceptions raised in streamer threads are pushed to the queue
+ # so that the main thread can re-raise them and handle them linearly.
+ #
+ # @param streamer [#call(ServerSentEventGenerator)]
+ # @return [Proc]
+ # @api private
+ def stream_many(streamer)
+ @queue ||= @executor.new_queue
+
+ proc do |socket|
+ signs = signals
+ conn_generator = ServerSentEventGenerator.new(socket, signals: signs, view_context: @view_context)
+ @on_connect.each { |callable| callable.call(conn_generator) }
+
+ threads = @streamers.map do |streamer|
+ @executor.spawn do
+ # TODO: Review thread-safe view context
+ generator = ServerSentEventGenerator.new(@queue, signals: signs, view_context: @view_context)
+ streamer.call(generator)
+ @queue << :done
+ rescue StandardError => e
+ @queue << e
+ end
+ end
+
+ handling_errors(conn_generator, socket) do
+ done_count = 0
+
+ while (data = @queue.pop)
+ if data == :done
+ done_count += 1
+ @queue << nil if done_count == threads.size
+ elsif data.is_a?(Exception)
+ raise data
+ else
+ socket << data
+ end
+ end
+ end
+ ensure
+ @executor.stop(threads) if threads
+ socket.close
+ end
+ end
+
+ # Run a streaming block while handling errors
+ # @param generator [ServerSentEventGenerator]
+ # @param socket [IO]
+ # @yield
+ # @api private
+ def handling_errors(generator, socket, &)
+ yield
+
+ @on_server_disconnect.each { |callable| callable.call(generator) }
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
+ @on_client_disconnect.each { |callable| callable.call(socket) }
+ rescue Exception => e
+ @on_error.each { |callable| callable.call(e) }
+ end
+
+ # Parse signals from the request
+ # Support Rails requests with already parsed request bodies
+ #
+ # @param request [Rack::Request]
+ # @return [Hash]
+ # @api private
+ def parse_signals(request)
+ if request.post? || request.put? || request.patch?
+ payload = request.env['action_dispatch.request.request_parameters']
+ if payload
+ return payload['event'] || {}
+ elsif request.media_type == 'application/json'
+ request.body.rewind
+ return JSON.parse(request.body.read)
+ elsif request.media_type == 'multipart/form-data'
+ return request.params
+ end
+ else
+ query = request.params['datastar']
+ return query ? JSON.parse(query) : request.params
+ end
+
+ {}
+ end
+ end
+end
diff --git a/sdk/ruby/lib/datastar/rails_async_executor.rb b/sdk/ruby/lib/datastar/rails_async_executor.rb
new file mode 100644
index 000000000..6d981a632
--- /dev/null
+++ b/sdk/ruby/lib/datastar/rails_async_executor.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require 'datastar/async_executor'
+
+module Datastar
+ class RailsAsyncExecutor < Datastar::AsyncExecutor
+ def prepare(response)
+ response.delete_header 'Connection'
+ end
+
+ def spawn(&block)
+ Async do
+ Rails.application.executor.wrap(&block)
+ end
+ end
+ end
+end
diff --git a/sdk/ruby/lib/datastar/rails_thread_executor.rb b/sdk/ruby/lib/datastar/rails_thread_executor.rb
new file mode 100644
index 000000000..a0e2199e7
--- /dev/null
+++ b/sdk/ruby/lib/datastar/rails_thread_executor.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Datastar
+ # See https://guides.rubyonrails.org/threading_and_code_execution.html#wrapping-application-code
+ class RailsThreadExecutor < Datastar::ThreadExecutor
+ def spawn(&block)
+ Thread.new do
+ Rails.application.executor.wrap(&block)
+ end
+ end
+ end
+end
diff --git a/sdk/ruby/lib/datastar/railtie.rb b/sdk/ruby/lib/datastar/railtie.rb
new file mode 100644
index 000000000..8bf4d6cf2
--- /dev/null
+++ b/sdk/ruby/lib/datastar/railtie.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Datastar
+ class Railtie < ::Rails::Railtie
+ FINALIZE = proc do |view_context, response|
+ view_context.response = response
+ end
+
+ initializer 'datastar' do |_app|
+ Datastar.config.finalize = FINALIZE
+
+ Datastar.config.executor = if config.active_support.isolation_level == :fiber
+ require 'datastar/rails_async_executor'
+ RailsAsyncExecutor.new
+ else
+ require 'datastar/rails_thread_executor'
+ RailsThreadExecutor.new
+ end
+ end
+ end
+end
diff --git a/sdk/ruby/lib/datastar/server_sent_event_generator.rb b/sdk/ruby/lib/datastar/server_sent_event_generator.rb
new file mode 100644
index 000000000..111e9df68
--- /dev/null
+++ b/sdk/ruby/lib/datastar/server_sent_event_generator.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'json'
+
+module Datastar
+ class ServerSentEventGenerator
+ MSG_END = "\n\n"
+
+ SSE_OPTION_MAPPING = {
+ 'eventId' => 'id',
+ 'retryDuration' => 'retry',
+ 'id' => 'id',
+ 'retry' => 'retry',
+ }.freeze
+
+ OPTION_DEFAULTS = {
+ 'retry' => Consts::DEFAULT_SSE_RETRY_DURATION,
+ Consts::AUTO_REMOVE_DATALINE_LITERAL => Consts::DEFAULT_EXECUTE_SCRIPT_AUTO_REMOVE,
+ Consts::MERGE_MODE_DATALINE_LITERAL => Consts::DEFAULT_FRAGMENT_MERGE_MODE,
+ Consts::SETTLE_DURATION_DATALINE_LITERAL => Consts::DEFAULT_FRAGMENTS_SETTLE_DURATION,
+ Consts::USE_VIEW_TRANSITION_DATALINE_LITERAL => Consts::DEFAULT_FRAGMENTS_USE_VIEW_TRANSITIONS,
+ Consts::ONLY_IF_MISSING_DATALINE_LITERAL => Consts::DEFAULT_MERGE_SIGNALS_ONLY_IF_MISSING,
+ }.freeze
+
+ # ATTRIBUTE_DEFAULTS = {
+ # 'type' => 'module'
+ # }.freeze
+ ATTRIBUTE_DEFAULTS = Consts::DEFAULT_EXECUTE_SCRIPT_ATTRIBUTES
+ .split("\n")
+ .map { |attr| attr.split(' ') }
+ .to_h
+ .freeze
+
+ attr_reader :signals
+
+ def initialize(stream, signals:, view_context: nil)
+ @stream = stream
+ @signals = signals
+ @view_context = view_context
+ end
+
+ def merge_fragments(fragments, options = BLANK_OPTIONS)
+ # Support Phlex components
+ fragments = fragments.call(view_context:) if fragments.respond_to?(:call)
+ fragment_lines = fragments.to_s.split("\n")
+
+ buffer = +"event: datastar-merge-fragments\n"
+ build_options(options, buffer)
+ fragment_lines.each { |line| buffer << "data: fragments #{line}\n" }
+
+ write(buffer)
+ end
+
+ def remove_fragments(selector, options = BLANK_OPTIONS)
+ buffer = +"event: datastar-remove-fragments\n"
+ build_options(options, buffer)
+ buffer << "data: selector #{selector}\n"
+ write(buffer)
+ end
+
+ def merge_signals(signals, options = BLANK_OPTIONS)
+ signals = JSON.dump(signals) unless signals.is_a?(String)
+
+ buffer = +"event: datastar-merge-signals\n"
+ build_options(options, buffer)
+ buffer << "data: signals #{signals}\n"
+ write(buffer)
+ end
+
+ def remove_signals(paths, options = BLANK_OPTIONS)
+ paths = [paths].flatten
+
+ buffer = +"event: datastar-remove-signals\n"
+ build_options(options, buffer)
+ paths.each { |path| buffer << "data: paths #{path}\n" }
+ write(buffer)
+ end
+
+ def execute_script(script, options = BLANK_OPTIONS)
+ buffer = +"event: datastar-execute-script\n"
+ build_options(options, buffer)
+ scripts = script.to_s.split("\n")
+ scripts.each do |sc|
+ buffer << "data: script #{sc}\n"
+ end
+ write(buffer)
+ end
+
+ def redirect(url)
+ execute_script %(setTimeout(() => { window.location = '#{url}' }))
+ end
+
+ def write(buffer)
+ buffer << MSG_END
+ @stream << buffer
+ end
+
+ private
+
+ attr_reader :view_context, :stream
+
+ def build_options(options, buffer)
+ options.each do |k, v|
+ k = camelize(k)
+ if (sse_key = SSE_OPTION_MAPPING[k])
+ default_value = OPTION_DEFAULTS[sse_key]
+ buffer << "#{sse_key}: #{v}\n" unless v == default_value
+ elsif v.is_a?(Hash)
+ v.each do |kk, vv|
+ default_value = ATTRIBUTE_DEFAULTS[kk.to_s]
+ buffer << "data: #{k} #{kk} #{vv}\n" unless vv == default_value
+ end
+ else
+ default_value = OPTION_DEFAULTS[k]
+ buffer << "data: #{k} #{v}\n" unless v == default_value
+ end
+ end
+ end
+
+ def camelize(str)
+ str.to_s.split('_').map.with_index { |word, i| i == 0 ? word : word.capitalize }.join
+ end
+ end
+end
diff --git a/sdk/ruby/lib/datastar/version.rb b/sdk/ruby/lib/datastar/version.rb
new file mode 100644
index 000000000..fb88c942d
--- /dev/null
+++ b/sdk/ruby/lib/datastar/version.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+module Datastar
+ VERSION = '1.0.0.beta.1'
+end
diff --git a/sdk/ruby/sig/datastar.rbs b/sdk/ruby/sig/datastar.rbs
new file mode 100644
index 000000000..f6630627f
--- /dev/null
+++ b/sdk/ruby/sig/datastar.rbs
@@ -0,0 +1,4 @@
+module Datastar
+ VERSION: String
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
+end
diff --git a/sdk/ruby/spec/dispatcher_spec.rb b/sdk/ruby/spec/dispatcher_spec.rb
new file mode 100644
index 000000000..010f8a928
--- /dev/null
+++ b/sdk/ruby/spec/dispatcher_spec.rb
@@ -0,0 +1,464 @@
+# frozen_string_literal: true
+
+class TestSocket
+ attr_reader :lines, :open
+ def initialize
+ @lines = []
+ @open = true
+ end
+
+ def <<(line)
+ @lines << line
+ end
+
+ def close = @open = false
+end
+
+RSpec.describe Datastar::Dispatcher do
+ include DispatcherExamples
+
+ subject(:dispatcher) { Datastar.new(request:, response:, view_context:) }
+
+ let(:request) { build_request('/events') }
+ let(:response) { Rack::Response.new(nil, 200) }
+ let(:view_context) { double('View context') }
+
+ describe '#initialize' do
+ it 'sets Content-Type to text/event-stream' do
+ expect(dispatcher.response['Content-Type']).to eq('text/event-stream')
+ end
+
+ it 'sets Cache-Control to no-cache' do
+ expect(dispatcher.response['Cache-Control']).to eq('no-cache')
+ end
+
+ it 'sets Connection to keep-alive' do
+ expect(dispatcher.response['Connection']).to eq('keep-alive')
+ end
+
+ it 'sets X-Accel-Buffering: no for NGinx and other proxies' do
+ expect(dispatcher.response['X-Accel-Buffering']).to eq('no')
+ end
+
+ it 'does not set Connection header if not HTTP/1.1' do
+ request.env['SERVER_PROTOCOL'] = 'HTTP/2.0'
+ expect(dispatcher.response['Connection']).to be_nil
+ end
+ end
+
+ specify '.from_rack_env' do
+ dispatcher = Datastar.from_rack_env(request.env)
+
+ expect(dispatcher.response['Content-Type']).to eq('text/event-stream')
+ expect(dispatcher.response['Cache-Control']).to eq('no-cache')
+ expect(dispatcher.response['Connection']).to eq('keep-alive')
+ end
+
+ specify '#sse?' do
+ expect(dispatcher.sse?).to be(true)
+ request = build_request('/events', headers: { 'HTTP_ACCEPT' => 'application/json' })
+
+ dispatcher = Datastar.new(request:, response:, view_context:)
+ expect(dispatcher.sse?).to be(false)
+ end
+
+ describe '#merge_fragments' do
+ it 'produces a streameable response body with D* fragments' do
+ dispatcher.merge_fragments %(
\n\n\n")
+ expect(disconnects).to eq([true])
+ end
+
+ it 'catches exceptions raised from threads' do
+ Thread.report_on_exception = false
+ errs = []
+
+ dispatcher = Datastar
+ .new(request:, response:, executor:)
+ .on_error { |err| errs << err }
+
+ dispatcher.stream do |sse|
+ sleep 0.01
+ raise ArgumentError, 'Invalid argument'
+ end
+
+ dispatcher.stream do |sse|
+ sse.merge_signals(foo: 'bar')
+ end
+
+ socket = TestSocket.new
+ dispatcher.response.body.call(socket)
+ expect(errs.first).to be_a(ArgumentError)
+ Thread.report_on_exception = true
+ end
+ end
+end
+
diff --git a/site/static/code_snippets/getting_started/multiple_events.rubysnippet b/site/static/code_snippets/getting_started/multiple_events.rubysnippet
new file mode 100644
index 000000000..12dbaea5b
--- /dev/null
+++ b/site/static/code_snippets/getting_started/multiple_events.rubysnippet
@@ -0,0 +1,6 @@
+datastar.stream do |sse|
+ sse.merge_fragments('
...
')
+ sse.merge_fragments('
...
')
+ sse.merge_signals(answer: '...')
+ sse.merge_signals(prize: '...')
+end
diff --git a/site/static/code_snippets/getting_started/setup.rubysnippet b/site/static/code_snippets/getting_started/setup.rubysnippet
new file mode 100644
index 000000000..651108778
--- /dev/null
+++ b/site/static/code_snippets/getting_started/setup.rubysnippet
@@ -0,0 +1,17 @@
+require 'datastar'
+
+# Create a Datastar::Dispatcher instance
+
+datastar = Datastar.new(request:, response:)
+
+# In a Rack handler, you can instantiate from the Rack env
+# datastar = Datastar.from_rack_env(env)
+
+# Start a streaming response
+datastar.stream do |sse|
+ # Merges fragment into the DOM
+ sse.merge_fragments %(