Skip to content

Commit

Permalink
Ruby SDK (#600)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ismasan authored Feb 5, 2025
1 parent ca5414c commit 5d46096
Show file tree
Hide file tree
Showing 44 changed files with 2,207 additions and 0 deletions.
6 changes: 6 additions & 0 deletions build/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions build/consts_ruby.qtpl
Original file line number Diff line number Diff line change
@@ -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 -%}
2 changes: 2 additions & 0 deletions build/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
Expand Down
8 changes: 8 additions & 0 deletions examples/ruby/hello-world/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

source 'https://rubygems.org'

gem 'puma'
gem 'rack'
# gem 'datastar'
gem 'datastar', path: '../../../sdk/ruby'
25 changes: 25 additions & 0 deletions examples/ruby/hello-world/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions examples/ruby/hello-world/hello-world.html
Original file line number Diff line number Diff line change
@@ -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/[email protected]/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>
37 changes: 37 additions & 0 deletions examples/ruby/hello-world/hello-world.ru
Original file line number Diff line number Diff line change
@@ -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 changes: 84 additions & 0 deletions examples/ruby/threads.ru
Original file line number Diff line number Diff line change
@@ -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/[email protected]/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
8 changes: 8 additions & 0 deletions examples/ruby/threads/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

source 'https://rubygems.org'

gem 'puma'
gem 'rack'
# gem 'datastar'
gem 'datastar', path: '../../../sdk/ruby'
25 changes: 25 additions & 0 deletions examples/ruby/threads/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions examples/ruby/threads/threads.ru
Original file line number Diff line number Diff line change
@@ -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/[email protected]/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
Loading

0 comments on commit 5d46096

Please sign in to comment.