* WiP initial setup, ServerSentEventGenerator class

* WiP working merge_fragments in Rails

* #merge_fragments and #merge_signals

* Handle SSE vs Data* options

* Test that #merge_fragments works with a #call(view_context:) interface

* Test Dispatcher#stream

* #remove_fragments

* #remove_signals

* #execute_script

* execute_script with attributes Hash

* Connection: keep-alive

* Use 2 line-breaks as message end, plus last line's 1 line break (3 total)

* Connection callbacks. #on_connect, #on_disconnect, #on_error

* Dispatcher#signals

* Omit retry if using default value (1000)

* Omit defaults

* Multiline scripts

* Test Rack endpoint

* Document test Rack endpoint

* Add missing defaults

* Spawn multiple streams in threads, client_disconnect and server_disconnect handlers

* Move ThreadSpawner to configuration

* Configure a RailsThreadSpawner when Rails detected

* Move Railtie one dir up

* Global error callback

Datastar.config.on_error { |err| Sentry.notify(err) }

* Catch exception from stream threads in main thread

* Linearlize exception handling

* Refactor dispatcher to handle single stream in main thread, multi streams in separate threads

* spawner => executor. Rails Async executor using fibers.

* Support Async for fiber-based concurrency

* Finalize response for Rack and Rails

* test Rack app

* Threaded demo

* Test Dispatcher#sse?

Also do not check for SSE Accept on stream.
Leave it up to the user.

* Do not check Accept header in test app. Test scripts don't send it properly.

* Document code

* Example progress bar Rack app

* README

* Link to D* SSE docs

* See examples

* Document callbacks

* List Ruby SDK in SDKs.md

* Ruby struct in consts.go

* Document running tasks with arguments via Docker

* Code-gen Ruby constants from shared data via template

* Make test rely on constants

* Datastar.from_rack_env(env) => Datastar::Dispatcher

* Ruby example snippets

* #redirect(location)

* Ruby snippet using #redirect(new_path)

* Add X-Accel-Buffering: no header

To disable response buffering by NGinx and other proxies.

* Clarify linearisation of updates in Readme

* Tidy-up progress example

* Move examples to /examples/ruby

* Document Rails and Phlex

* Version 1.0.0.beta.1

* Version 1.0.0.beta.1

* Do not set Connection header if not HTTP/1.1

* Don't touch BUILDING.md docs in this PR

* Remove Changelog for now

* Sort Ruby alphabetically (just "ruby", not the entire line)

* Add hello world example, remove progress bar one.

* Add hello-world example to code-gen

* Typos
This commit is contained in:
Ismael Celis 2025-02-05 18:02:18 +00:00 committed by GitHub
parent ca5414c358
commit 5d46096dc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2207 additions and 0 deletions

View File

@ -104,6 +104,12 @@ var Consts = &ConstTemplateData{
Icon: "vscode-icons:file-type-rust",
SdkUrl: "https://github.com/starfederation/datastar/tree/main/sdk/rust",
},
{
FileExtension: "ruby",
Name: "Ruby",
Icon: "vscode-icons:file-type-ruby",
SdkUrl: "https://github.com/starfederation/datastar/tree/main/sdk/ruby",
},
{
FileExtension: "ts",
Name: "TypeScript",

45
build/consts_ruby.qtpl Normal file
View File

@ -0,0 +1,45 @@
{%- func rubyConsts(data *ConstTemplateData) -%}
# frozen_string_literal: true
# {%s data.DoNotEdit %}
module Datastar
module Consts
DATASTAR_KEY = '{%s data.DatastarKey %}'
VERSION = '{%s data.Version %}'
{%- for _, d := range data.DefaultDurations %}
# {%s= d.Description %}
DEFAULT_{%s d.Name.ScreamingSnake %} = {%d durationToMs(d.Duration) %}
{%- endfor -%}
{%- for _, b := range data.DefaultBools %}
# {%s= b.Description %}
DEFAULT_{%s b.Name.ScreamingSnake %} = {%v b.Value %}
{%- endfor -%}
{%- for _, s := range data.DefaultStrings %}
# {%s= s.Description %}}
DEFAULT_{%s s.Name.ScreamingSnake %} = '{%s s.Value %}'
{%- endfor -%}
{%- for _, enum := range data.Enums -%}
{%- if enum.Name.Pascal == "FragmentMergeMode" -%}
module FragmentMergeMode
{%- for _, entry := range enum.Values %}
# {%s entry.Description %}
{%s entry.Name.ScreamingSnake %} = '{%s entry.Value %}'
{%- endfor -%}
end
{%- endif -%}
{%- endfor -%}
{%- for _, enum := range data.Enums -%}
{%- if enum.Default != nil %}
# {%s= enum.Description %}
DEFAULT_{%s enum.Name.ScreamingSnake %} = {%s enum.Name.Pascal %}::{%s enum.Default.Name.ScreamingSnake %}
{%- endif -%}
{%- endfor -%}
# Dataline literals.
{%- for _, literal := range data.DatalineLiterals -%}
{%s literal.ScreamingSnake %}_DATALINE_LITERAL = '{%s literal.Camel %}'
{%- endfor -%}
end
end
{%- endfunc -%}

View File

@ -140,6 +140,7 @@ func writeOutConsts(version string) error {
"sdk/java/core/src/main/java/starfederation/datastar/enums/FragmentMergeMode.java": javaFragmentMergeMode,
"sdk/python/src/datastar_py/consts.py": pythonConsts,
"sdk/typescript/src/consts.ts": typescriptConsts,
"sdk/ruby/lib/datastar/consts.rb": rubyConsts,
"sdk/rust/src/consts.rs": rustConsts,
"sdk/zig/src/consts.zig": zigConsts,
"examples/clojure/hello-world/resources/public/hello-world.html": helloWorldExample,
@ -148,6 +149,7 @@ func writeOutConsts(version string) error {
"examples/php/hello-world/public/hello-world.html": helloWorldExamplePHP,
"examples/zig/httpz/hello-world/src/hello-world.html": helloWorldExample,
"examples/zig/tokamak/hello-world/hello-world.html": helloWorldExample,
"examples/ruby/hello-world/hello-world.html": helloWorldExample,
"examples/rust/axum/hello-world/hello-world.html": helloWorldExample,
"examples/rust/rocket/hello-world/hello-world.html": helloWorldExample,
}

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gem 'puma'
gem 'rack'
# gem 'datastar'
gem 'datastar', path: '../../../sdk/ruby'

View File

@ -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

View File

@ -0,0 +1,35 @@
<!-- This is auto-generated by Datastar. DO NOT EDIT. -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Datastar SDK Demo</title>
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.3/bundles/datastar.js"></script>
</head>
<body class="bg-white dark:bg-gray-900 text-lg max-w-xl mx-auto my-16">
<div data-signals-delay="400" class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg px-6 py-8 ring shadow-xl ring-gray-900/5 space-y-2">
<div class="flex justify-between items-center">
<h1 class="text-gray-900 dark:text-white text-3xl font-semibold">
Datastar SDK Demo
</h1>
<img src="https://data-star.dev/static/images/rocket.png" alt="Rocket" width="64" height="64"/>
</div>
<p class="mt-2">
SSE events will be streamed from the backend to the frontend.
</p>
<div class="space-x-2">
<label for="delay">
Delay in milliseconds
</label>
<input data-bind-delay id="delay" type="number" step="100" min="0" class="w-36 rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-sky-500 focus:outline focus:outline-sky-500 dark:disabled:border-gray-700 dark:disabled:bg-gray-800/20" />
</div>
<button data-on-click="@get(&#39;/hello-world&#39;)" class="rounded-md bg-sky-500 px-5 py-2.5 leading-5 font-semibold text-white hover:bg-sky-700 hover:text-gray-100 cursor-pointer">
Start
</button>
</div>
<div class="my-16 text-8xl font-bold text-transparent" style="background: linear-gradient(to right in oklch, red, orange, yellow, green, blue, blue, violet); background-clip: text">
<div id="message">Hello, world!</div>
</div>
</body>
</html>

View File

@ -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(%(<div id="message">#{message[0..i]}</div>))
sleep delay
end
end
else
[200, { 'content-type' => 'text/html' }, [HTML]]
end
end
trap('INT') { exit }

84
examples/ruby/threads.ru Normal file
View File

@ -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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Datastar counter</title>
<style>
body { padding: 10em; }
.counter {#{' '}
font-size: 2em;#{' '}
span { font-weight: bold; }
}
</style>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.2/bundles/datastar.js"></script>
</head>
<body>
<button#{' '}
data-on-click="@get('/')"#{' '}
data-indicator-heartbeat#{' '}
>Start</button>
<p class="counter">Slow thread: <span id="slow">waiting</span></p>
<p class="counter">Fast thread: <span id="fast">waiting</span></p>
<p id="connection">Disconnected...</p>
</body>
<html>
HTML
trap('INT') { exit }
run do |env|
# Initialize Datastar with callbacks
datastar = Datastar
.from_rack_env(env)
.on_connect do |sse|
sse.merge_fragments(%(<p id="connection">Connected...</p>))
p ['connect', sse]
end.on_server_disconnect do |sse|
sse.merge_fragments(%(<p id="connection">Done...</p>))
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(%(<span id="slow">#{i}</span>))
end
end
# Another thread / fiber
datastar.stream do |sse|
1000.times do |i|
sleep 0.01
sse.merge_fragments(%(<span id="fast">#{i}</span>))
end
end
else
[200, { 'content-type' => 'text/html' }, [INDEX]]
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gem 'puma'
gem 'rack'
# gem 'datastar'
gem 'datastar', path: '../../../sdk/ruby'

View File

@ -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

View File

@ -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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Datastar counter</title>
<style>
body { padding: 10em; }
.counter {#{' '}
font-size: 2em;#{' '}
span { font-weight: bold; }
}
</style>
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-beta.2/bundles/datastar.js"></script>
</head>
<body>
<button#{' '}
data-on-click="@get('/')"#{' '}
data-indicator-heartbeat#{' '}
>Start</button>
<p class="counter">Slow thread: <span id="slow">waiting</span></p>
<p class="counter">Fast thread: <span id="fast">waiting</span></p>
<p id="connection">Disconnected...</p>
</body>
<html>
HTML
trap('INT') { exit }
run do |env|
# Initialize Datastar with callbacks
datastar = Datastar
.from_rack_env(env)
.on_connect do |sse|
sse.merge_fragments(%(<p id="connection">Connected...</p>))
p ['connect', sse]
end.on_server_disconnect do |sse|
sse.merge_fragments(%(<p id="connection">Done...</p>))
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(%(<span id="slow">#{i}</span>))
end
end
# Another thread / fiber
datastar.stream do |sse|
1000.times do |i|
sleep 0.01
sse.merge_fragments(%(<span id="fast">#{i}</span>))
end
end
else
[200, { 'content-type' => 'text/html' }, [INDEX]]
end
end

11
sdk/ruby/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
/.bundle/
/.yardoc
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/
# rspec failure tracking
.rspec_status

3
sdk/ruby/.rspec Normal file
View File

@ -0,0 +1,3 @@
--format documentation
--color
--require spec_helper

19
sdk/ruby/Gemfile Normal file
View File

@ -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

81
sdk/ruby/Gemfile.lock Normal file
View File

@ -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

256
sdk/ruby/README.md Normal file
View File

@ -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(%(<h1 id="title">Hello, World!</h1>))
```
In this mode, the response is closed after the fragment is sent.
#### Streaming updates
```ruby
datastar.stream do |sse|
sse.merge_fragments(%(<h1 id="title">Hello, World!</h1>))
# Streaming multiple updates
100.times do |i|
sleep 1
sse.merge_fragments(%(<h1 id="title">Hello, World #{i}!</h1>))
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(%(<h1 id="slow">#{i}!</h1>))
end
end
datastar.stream do |sse|
1000.times do |i|
sleep 0.1
sse.merge_fragments(%(<h1 id="fast">#{i}!</h1>))
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(%(<div id="foo">\n<span>hello</span>\n</div>))
# or a Phlex view object
sse.merge_fragments(UserComponet.new)
# Or pass options
sse.merge_fragments(
%(<div id="foo">\n<span>hello</span>\n</div>),
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.

8
sdk/ruby/Rakefile Normal file
View File

@ -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

11
sdk/ruby/bin/console Executable file
View File

@ -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__)

8
sdk/ruby/bin/setup Executable file
View File

@ -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

36
sdk/ruby/datastar.gemspec Normal file
View File

@ -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

56
sdk/ruby/examples/test.ru Normal file
View File

@ -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

32
sdk/ruby/lib/datastar.rb Normal file
View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 <script/> 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

View File

@ -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(%(<div id="foo">\n<span>hello</span>\n</div>\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<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 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(%(<progress data-signal-progress="0" id="progress" max="#{total}" data-attr-value="$progress">0</progress>))
# 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
module Datastar
VERSION = '1.0.0.beta.1'
end

View File

@ -0,0 +1,4 @@
module Datastar
VERSION: String
# See the writing guide of rbs: https://github.com/ruby/rbs#guides
end

View File

@ -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 %(<div id="foo">\n<span>hello</span>\n</div>\n)
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq(["event: datastar-merge-fragments\ndata: fragments <div id=\"foo\">\ndata: fragments <span>hello</span>\ndata: fragments </div>\n\n\n"])
end
it 'takes D* options' do
dispatcher.merge_fragments(
%(<div id="foo">\n<span>hello</span>\n</div>\n),
id: 72,
retry_duration: 2000,
settle_duration: 1000
)
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-merge-fragments\nid: 72\nretry: 2000\ndata: settleDuration 1000\ndata: fragments <div id="foo">\ndata: fragments <span>hello</span>\ndata: fragments </div>\n\n\n)])
end
it 'omits retry if using default value' do
dispatcher.merge_fragments(
%(<div id="foo">\n<span>hello</span>\n</div>\n),
id: 72,
retry_duration: 1000,
settle_duration: 1000
)
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-merge-fragments\nid: 72\ndata: settleDuration 1000\ndata: fragments <div id="foo">\ndata: fragments <span>hello</span>\ndata: fragments </div>\n\n\n)])
end
it 'works with #call(view_context:) interfaces' do
template_class = Class.new do
def self.call(view_context:) = %(<div id="foo">\n<span>#{view_context}</span>\n</div>\n)
end
dispatcher.merge_fragments(
template_class,
id: 72,
retry_duration: 2000,
settle_duration: 1000
)
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.lines).to eq([%(event: datastar-merge-fragments\nid: 72\nretry: 2000\ndata: settleDuration 1000\ndata: fragments <div id="foo">\ndata: fragments <span>#{view_context}</span>\ndata: fragments </div>\n\n\n)])
end
end
describe '#remove_fragments' do
it 'produces D* remove fragments' do
dispatcher.remove_fragments('#list-item-1')
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-remove-fragments\ndata: selector #list-item-1\n\n\n)])
end
it 'takes D* options' do
dispatcher.remove_fragments('#list-item-1', id: 72, settle_duration: 1000)
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-remove-fragments\nid: 72\ndata: settleDuration 1000\ndata: selector #list-item-1\n\n\n)])
end
end
describe '#merge_signals' do
it 'produces a streameable response body with D* signals' do
dispatcher.merge_signals %({ "foo": "bar" })
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-merge-signals\ndata: signals { "foo": "bar" }\n\n\n)])
end
it 'takes a Hash of signals' do
dispatcher.merge_signals(foo: 'bar')
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-merge-signals\ndata: signals {"foo":"bar"}\n\n\n)])
end
it 'takes D* options' do
dispatcher.merge_signals({foo: 'bar'}, event_id: 72, retry_duration: 2000, only_if_missing: true)
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-merge-signals\nid: 72\nretry: 2000\ndata: onlyIfMissing true\ndata: signals {"foo":"bar"}\n\n\n)])
end
end
describe '#remove_signals' do
it 'produces a streameable response body with D* remove-signals' do
dispatcher.remove_signals ['user.name', 'user.email']
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-remove-signals\ndata: paths user.name\ndata: paths user.email\n\n\n)])
end
it 'takes D* options' do
dispatcher.remove_signals 'user.name', event_id: 72, retry_duration: 2000
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-remove-signals\nid: 72\nretry: 2000\ndata: paths user.name\n\n\n)])
end
end
describe '#execute_script' do
it 'produces a streameable response body with D* execute-script' do
dispatcher.execute_script %(alert('hello'))
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-execute-script\ndata: script alert('hello')\n\n\n)])
end
it 'splits multi-line script into multiple data lines' do
dispatcher.execute_script %(alert('hello');\nalert('world'))
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-execute-script\ndata: script alert('hello');\ndata: script alert('world')\n\n\n)])
end
it 'takes D* options' do
dispatcher.execute_script %(alert('hello')), event_id: 72, auto_remove: !Datastar::Consts::DEFAULT_EXECUTE_SCRIPT_AUTO_REMOVE
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-execute-script\nid: 72\ndata: autoRemove false\ndata: script alert('hello')\n\n\n)])
end
it 'omits autoRemove true' do
dispatcher.execute_script %(alert('hello')), event_id: 72, auto_remove: Datastar::Consts::DEFAULT_EXECUTE_SCRIPT_AUTO_REMOVE
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-execute-script\nid: 72\ndata: script alert('hello')\n\n\n)])
end
it 'takes attributes Hash' do
dispatcher.execute_script %(alert('hello')), attributes: { type: 'text/javascript', title: 'alert' }
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-execute-script\ndata: attributes type text/javascript\ndata: attributes title alert\ndata: script alert('hello')\n\n\n)])
end
it 'takes attributes Hash' do
dispatcher.execute_script %(alert('hello')), attributes: { type: 'module', title: 'alert' }
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-execute-script\ndata: attributes title alert\ndata: script alert('hello')\n\n\n)])
end
end
describe '#redirect' do
it 'sends an execute_script event with a window.location change' do
dispatcher.redirect '/guide'
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines).to eq([%(event: datastar-execute-script\ndata: script setTimeout(() => { window.location = '/guide' })\n\n\n)])
end
end
describe '#signals' do
context 'with POST request' do
specify 'Rails parsed parameters' do
request = build_request(
'/events',
method: 'POST',
headers: {
'action_dispatch.request.request_parameters' => { 'event' => { 'foo' => 'bar' } }
}
)
dispatcher = Datastar.new(request:, response:)
expect(dispatcher.signals).to eq({ 'foo' => 'bar' })
end
specify 'no signals in Rails parameters' do
request = build_request(
'/events',
method: 'POST',
headers: {
'action_dispatch.request.request_parameters' => {}
}
)
dispatcher = Datastar.new(request:, response:)
expect(dispatcher.signals).to eq({})
end
specify 'JSON request with signals in body' do
request = build_request(
'/events',
method: 'POST',
content_type: 'application/json',
body: %({ "foo": "bar" })
)
dispatcher = Datastar.new(request:, response:)
expect(dispatcher.signals).to eq({ 'foo' => 'bar' })
end
specify 'multipart form request' do
request = build_request(
'/events',
method: 'POST',
content_type: 'multipart/form-data',
body: 'user[name]=joe&user[email]=joe@email.com'
)
dispatcher = Datastar.new(request:, response:)
expect(dispatcher.signals).to eq('user' => { 'name' => 'joe', 'email' => 'joe@email.com' })
end
end
context 'with GET request' do
specify 'with signals in ?datastar=[JSON signals]' do
query = %({"foo":"bar"})
request = build_request(
%(/events?datastar=#{URI.encode_uri_component(query)}),
method: 'GET',
)
dispatcher = Datastar.new(request:, response:)
expect(dispatcher.signals).to eq('foo' => 'bar')
end
specify 'with no signals' do
request = build_request(
%(/events),
method: 'GET',
)
dispatcher = Datastar.new(request:, response:)
expect(dispatcher.signals).to eq({})
end
end
end
describe '#stream' do
it 'writes multiple events to socket' do
dispatcher.stream do |sse|
sse.merge_fragments %(<div id="foo">\n<span>hello</span>\n</div>\n)
sse.merge_signals(foo: 'bar')
end
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines.size).to eq(2)
expect(socket.lines[0]).to eq("event: datastar-merge-fragments\ndata: fragments <div id=\"foo\">\ndata: fragments <span>hello</span>\ndata: fragments </div>\n\n\n")
expect(socket.lines[1]).to eq("event: datastar-merge-signals\ndata: signals {\"foo\":\"bar\"}\n\n\n")
end
it 'returns a Rack array response' do
status, headers, body = dispatcher.stream do |sse|
sse.merge_signals(foo: 'bar')
end
expect(status).to eq(200)
expect(headers['content-type']).to eq('text/event-stream')
expect(headers['cache-control']).to eq('no-cache')
expect(headers['connection']).to eq('keep-alive')
end
context 'with multiple streams' do
let(:executor) { Datastar.config.executor }
describe 'default thread-based executor' do
it_behaves_like 'a dispatcher handling concurrent streams'
end
describe 'Async-based executor' do
around do |example|
Sync do
example.run
end
end
let(:executor) { Datastar::AsyncExecutor.new }
it_behaves_like 'a dispatcher handling concurrent streams'
end
end
specify '#signals' do
request = build_request(
%(/events),
method: 'POST',
content_type: 'multipart/form-data',
body: 'user[name]=joe&user[email]=joe@email.com'
)
dispatcher = Datastar.new(request:, response:)
signals = nil
dispatcher.stream do |sse|
signals = sse.signals
end
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(signals['user']['name']).to eq('joe')
end
specify '#on_connect' do
connected = false
dispatcher.on_connect { |conn| connected = true }
dispatcher.stream do |sse|
sse.merge_signals(foo: 'bar')
end
socket = TestSocket.new
# allow(socket).to receive(:<<).and_raise(Errno::EPIPE, 'Socket closed')
#
dispatcher.response.body.call(socket)
expect(connected).to be(true)
end
specify '#on_client_disconnect' do
events = []
dispatcher
.on_connect { |conn| events << true }
.on_client_disconnect { |conn| events << false }
dispatcher.stream do |sse|
sse.merge_signals(foo: 'bar')
end
socket = TestSocket.new
allow(socket).to receive(:<<).and_raise(Errno::EPIPE, 'Socket closed')
dispatcher.response.body.call(socket)
expect(events).to eq([true, false])
end
specify '#on_server_disconnect' do
events = []
dispatcher
.on_connect { |conn| events << true }
.on_server_disconnect { |conn| events << false }
dispatcher.stream do |sse|
sse.merge_signals(foo: 'bar')
end
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(events).to eq([true, false])
end
specify '#on_error' do
errors = []
dispatcher.on_error { |ex| errors << ex }
dispatcher.stream do |sse|
sse.merge_signals(foo: 'bar')
end
socket = TestSocket.new
allow(socket).to receive(:<<).and_raise(ArgumentError, 'Invalid argument')
dispatcher.response.body.call(socket)
expect(errors.first).to be_a(ArgumentError)
end
specify 'with global on_error' do
errs = []
Datastar.config.on_error { |ex| errs << ex }
socket = TestSocket.new
allow(socket).to receive(:<<).and_raise(ArgumentError, 'Invalid argument')
dispatcher.stream do |sse|
sse.merge_signals(foo: 'bar')
end
dispatcher.response.body.call(socket)
expect(errs.first).to be_a(ArgumentError)
end
end
private
def build_request(path, method: 'GET', body: nil, content_type: 'application/json', accept: 'text/event-stream', headers: {})
headers = {
'HTTP_ACCEPT' => accept,
'CONTENT_TYPE' => content_type,
'REQUEST_METHOD' => method,
Rack::RACK_INPUT => body ? StringIO.new(body) : nil
}.merge(headers)
Rack::Request.new(Rack::MockRequest.env_for(path, headers))
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'datastar'
require 'rack'
require 'datastar/async_executor'
require 'debug'
require_relative './support/dispatcher_examples'
RSpec.configure do |config|
# Enable flags like --only-failures and --next-failure
config.example_status_persistence_file_path = '.rspec_status'
# Disable RSpec exposing methods globally on `Module` and `main`
config.disable_monkey_patching!
config.expect_with :rspec do |c|
c.syntax = :expect
end
end

View File

@ -0,0 +1,53 @@
module DispatcherExamples
RSpec.shared_examples 'a dispatcher handling concurrent streams' do
it 'spawns multiple streams in threads, triggering callbacks only once' do
disconnects = []
dispatcher = Datastar
.new(request:, response:, executor:)
.on_server_disconnect { |_| disconnects << true }
.on_error { |err| puts err.backtrace.join("\n") }
dispatcher.stream do |sse|
sleep 0.01
sse.merge_fragments %(<div id="foo">\n<span>hello</span>\n</div>\n)
end
dispatcher.stream do |sse|
sse.merge_signals(foo: 'bar')
end
socket = TestSocket.new
dispatcher.response.body.call(socket)
expect(socket.open).to be(false)
expect(socket.lines.size).to eq(2)
expect(socket.lines[0]).to eq("event: datastar-merge-signals\ndata: signals {\"foo\":\"bar\"}\n\n\n")
expect(socket.lines[1]).to eq("event: datastar-merge-fragments\ndata: fragments <div id=\"foo\">\ndata: fragments <span>hello</span>\ndata: fragments </div>\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

View File

@ -0,0 +1,6 @@
datastar.stream do |sse|
sse.merge_fragments('<div id="question">...</div>')
sse.merge_fragments('<div id="instructions">...</div>')
sse.merge_signals(answer: '...')
sse.merge_signals(prize: '...')
end

View File

@ -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 %(<div id="question">What do you put in a toaster?</div>)
# Merges signals
sse.merge_signals(response: '', answer: 'bread')
end

View File

@ -0,0 +1,11 @@
require 'datastar'
# Create a Datastar::Dispatcher instance
datastar = Datastar.new(request:, response:)
datastar.stream do |sse|
sse.merge_fragments('<div id="hello">Hello, world!</div>')
sse.merge_signals(foo: { bar: 1 })
sse.execute_script('console.log("Success!")')
end

View File

@ -0,0 +1,11 @@
datastar = Datastar.new(request:, response:)
current_time = Time.now.strftime('%Y-%m-%d %H:%M:%S')
datastar.merge_fragments <<~FRAGMENT
<div id="time"
data-on-interval__duration.5s="@get('/endpoint')"
>
#{current_time}
</div>
FRAGMENT

View File

@ -0,0 +1,14 @@
datastar = Datastar.new(request:, response:)
now = Time.now
current_time = now.strftime('%Y-%m-%d %H:%M:%S')
current_seconds = now.strftime('%S').to_i
duration = current_seconds < 50 ? 5 : 1
datastar.merge_fragments <<~FRAGMENT
<div id="time"
data-on-interval__duration.#{duration}s="@get('/endpoint')"
>
#{current_time}
</div>
FRAGMENT

View File

@ -0,0 +1,7 @@
datastar = Datastar.new(request:, response:)
datastar.stream do |sse|
sse.merge_fragments '<div id="indicator">Redirecting in 3 seconds...</div>'
sleep 3
sse.execute_script 'window.location = "/guide"'
end

View File

@ -0,0 +1,13 @@
datastar = Datastar.new(request:, response:)
datastar.stream do |sse|
sse.merge_fragments '<div id="indicator">Redirecting in 3 seconds...</div>'
sleep 3
sse.execute_script <<~JS
setTimeout(() => {
window.location = '/guide'
})
JS
end

View File

@ -0,0 +1,9 @@
datastar = Datastar.new(request:, response:)
datastar.stream do |sse|
sse.merge_fragments '<div id="indicator">Redirecting in 3 seconds...</div>'
sleep 3
sse.redirect '/guide'
end

View File

@ -51,6 +51,11 @@ _Author: [Felix Ingram](https://github.com/lllama)_
Rust [SDK](https://github.com/starfederation/datastar/tree/main/sdk/rust) and [examples](https://github.com/starfederation/datastar/tree/main/examples/rust).
_Author: [Johnathan Stevers](https://github.com/jmstevers)_
## Ruby
Ruby [SDK](https://github.com/starfederation/datastar/tree/main/sdk/ruby) and [examples](https://github.com/starfederation/datastar/tree/main/examples/ruby).
_Author: [Ismael Celis](https://github.com/ismasan)_
## TypeScript
TypeScript [SDK](https://github.com/starfederation/datastar/tree/main/sdk/typescript) and [examples](https://github.com/starfederation/datastar/tree/main/examples/typescript), including support for NodeJS and Web standard runtimes (Deno, Bun, etc.).