Ruby SDK (#600)
* 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:
parent
ca5414c358
commit
5d46096dc2
|
@ -104,6 +104,12 @@ var Consts = &ConstTemplateData{
|
||||||
Icon: "vscode-icons:file-type-rust",
|
Icon: "vscode-icons:file-type-rust",
|
||||||
SdkUrl: "https://github.com/starfederation/datastar/tree/main/sdk/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",
|
FileExtension: "ts",
|
||||||
Name: "TypeScript",
|
Name: "TypeScript",
|
||||||
|
|
|
@ -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 -%}
|
|
@ -140,6 +140,7 @@ func writeOutConsts(version string) error {
|
||||||
"sdk/java/core/src/main/java/starfederation/datastar/enums/FragmentMergeMode.java": javaFragmentMergeMode,
|
"sdk/java/core/src/main/java/starfederation/datastar/enums/FragmentMergeMode.java": javaFragmentMergeMode,
|
||||||
"sdk/python/src/datastar_py/consts.py": pythonConsts,
|
"sdk/python/src/datastar_py/consts.py": pythonConsts,
|
||||||
"sdk/typescript/src/consts.ts": typescriptConsts,
|
"sdk/typescript/src/consts.ts": typescriptConsts,
|
||||||
|
"sdk/ruby/lib/datastar/consts.rb": rubyConsts,
|
||||||
"sdk/rust/src/consts.rs": rustConsts,
|
"sdk/rust/src/consts.rs": rustConsts,
|
||||||
"sdk/zig/src/consts.zig": zigConsts,
|
"sdk/zig/src/consts.zig": zigConsts,
|
||||||
"examples/clojure/hello-world/resources/public/hello-world.html": helloWorldExample,
|
"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/php/hello-world/public/hello-world.html": helloWorldExamplePHP,
|
||||||
"examples/zig/httpz/hello-world/src/hello-world.html": helloWorldExample,
|
"examples/zig/httpz/hello-world/src/hello-world.html": helloWorldExample,
|
||||||
"examples/zig/tokamak/hello-world/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/axum/hello-world/hello-world.html": helloWorldExample,
|
||||||
"examples/rust/rocket/hello-world/hello-world.html": helloWorldExample,
|
"examples/rust/rocket/hello-world/hello-world.html": helloWorldExample,
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
|
gem 'puma'
|
||||||
|
gem 'rack'
|
||||||
|
# gem 'datastar'
|
||||||
|
gem 'datastar', path: '../../../sdk/ruby'
|
|
@ -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
|
|
@ -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('/hello-world')" 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>
|
|
@ -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 }
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
source 'https://rubygems.org'
|
||||||
|
|
||||||
|
gem 'puma'
|
||||||
|
gem 'rack'
|
||||||
|
# gem 'datastar'
|
||||||
|
gem 'datastar', path: '../../../sdk/ruby'
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,11 @@
|
||||||
|
/.bundle/
|
||||||
|
/.yardoc
|
||||||
|
/_yardoc/
|
||||||
|
/coverage/
|
||||||
|
/doc/
|
||||||
|
/pkg/
|
||||||
|
/spec/reports/
|
||||||
|
/tmp/
|
||||||
|
|
||||||
|
# rspec failure tracking
|
||||||
|
.rspec_status
|
|
@ -0,0 +1,3 @@
|
||||||
|
--format documentation
|
||||||
|
--color
|
||||||
|
--require spec_helper
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
|
@ -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
|
|
@ -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__)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Datastar
|
||||||
|
VERSION = '1.0.0.beta.1'
|
||||||
|
end
|
|
@ -0,0 +1,4 @@
|
||||||
|
module Datastar
|
||||||
|
VERSION: String
|
||||||
|
# See the writing guide of rbs: https://github.com/ruby/rbs#guides
|
||||||
|
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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).
|
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)_
|
_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
|
||||||
|
|
||||||
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.).
|
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.).
|
||||||
|
|
Loading…
Reference in New Issue