diff --git a/build/consts.go b/build/consts.go index 740c34a36..41d503b93 100644 --- a/build/consts.go +++ b/build/consts.go @@ -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", diff --git a/build/consts_ruby.qtpl b/build/consts_ruby.qtpl new file mode 100644 index 000000000..27272546c --- /dev/null +++ b/build/consts_ruby.qtpl @@ -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 -%} diff --git a/build/run.go b/build/run.go index 19670f660..49afb9091 100644 --- a/build/run.go +++ b/build/run.go @@ -140,6 +140,7 @@ func writeOutConsts(version string) error { "sdk/java/core/src/main/java/starfederation/datastar/enums/FragmentMergeMode.java": javaFragmentMergeMode, "sdk/python/src/datastar_py/consts.py": pythonConsts, "sdk/typescript/src/consts.ts": typescriptConsts, + "sdk/ruby/lib/datastar/consts.rb": rubyConsts, "sdk/rust/src/consts.rs": rustConsts, "sdk/zig/src/consts.zig": zigConsts, "examples/clojure/hello-world/resources/public/hello-world.html": helloWorldExample, @@ -148,6 +149,7 @@ func writeOutConsts(version string) error { "examples/php/hello-world/public/hello-world.html": helloWorldExamplePHP, "examples/zig/httpz/hello-world/src/hello-world.html": helloWorldExample, "examples/zig/tokamak/hello-world/hello-world.html": helloWorldExample, + "examples/ruby/hello-world/hello-world.html": helloWorldExample, "examples/rust/axum/hello-world/hello-world.html": helloWorldExample, "examples/rust/rocket/hello-world/hello-world.html": helloWorldExample, } diff --git a/examples/ruby/hello-world/Gemfile b/examples/ruby/hello-world/Gemfile new file mode 100644 index 000000000..27d6a9d92 --- /dev/null +++ b/examples/ruby/hello-world/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'puma' +gem 'rack' +# gem 'datastar' +gem 'datastar', path: '../../../sdk/ruby' diff --git a/examples/ruby/hello-world/Gemfile.lock b/examples/ruby/hello-world/Gemfile.lock new file mode 100644 index 000000000..83df723bf --- /dev/null +++ b/examples/ruby/hello-world/Gemfile.lock @@ -0,0 +1,25 @@ +PATH + remote: ../../../sdk/ruby + specs: + datastar (1.0.0.beta.1) + rack (~> 3.0) + +GEM + remote: https://rubygems.org/ + specs: + nio4r (2.7.4) + puma (6.6.0) + nio4r (~> 2.0) + rack (3.1.9) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + datastar! + puma + rack + +BUNDLED WITH + 2.6.3 diff --git a/examples/ruby/hello-world/hello-world.html b/examples/ruby/hello-world/hello-world.html new file mode 100644 index 000000000..a72d427f1 --- /dev/null +++ b/examples/ruby/hello-world/hello-world.html @@ -0,0 +1,35 @@ + + + + + + Datastar SDK Demo + + + + +
+
+

+ Datastar SDK Demo +

+ Rocket +
+

+ SSE events will be streamed from the backend to the frontend. +

+
+ + +
+ +
+
+
Hello, world!
+
+ + \ No newline at end of file diff --git a/examples/ruby/hello-world/hello-world.ru b/examples/ruby/hello-world/hello-world.ru new file mode 100644 index 000000000..7925f2de0 --- /dev/null +++ b/examples/ruby/hello-world/hello-world.ru @@ -0,0 +1,37 @@ +require 'bundler/setup' + +require 'datastar' + +# This is a test Rack endpoint +# with a hello world example using Datastar. +# To run: +# +# # install dependencies +# bundle install +# # run this endpoint with Puma server +# bundle exec puma ./hello-world.ru +# +# Then open http://localhost:9292 +# +HTML = File.read(File.expand_path('hello-world.html', __dir__)) + +run do |env| + datastar = Datastar.from_rack_env(env) + + if datastar.sse? + delay = (datastar.signals['delay'] || 0).to_i + delay /= 1000.0 if delay.positive? + message = 'Hello, world!' + + datastar.stream do |sse| + message.size.times do |i| + sse.merge_fragments(%(
#{message[0..i]}
)) + sleep delay + end + end + else + [200, { 'content-type' => 'text/html' }, [HTML]] + end +end + +trap('INT') { exit } diff --git a/examples/ruby/threads.ru b/examples/ruby/threads.ru new file mode 100644 index 000000000..60ff43a54 --- /dev/null +++ b/examples/ruby/threads.ru @@ -0,0 +1,84 @@ +require 'bundler/setup' + +require 'datastar' + +# This is a test Rack endpoint +# to demo streaming Datastar updates from multiple threads. +# To run: +# +# # install dependencies +# bundle install +# # run this endpoint with Puma server +# bundle exec puma ./threaded.ru +# +# visit http://localhost:9292 +# +INDEX = <<~HTML + + + + + Datastar counter + + + + + Start +

Slow thread: waiting

+

Fast thread: waiting

+

Disconnected...

+ + +HTML + +trap('INT') { exit } + +run do |env| + # Initialize Datastar with callbacks + datastar = Datastar + .from_rack_env(env) + .on_connect do |sse| + sse.merge_fragments(%(

Connected...

)) + p ['connect', sse] + end.on_server_disconnect do |sse| + sse.merge_fragments(%(

Done...

)) + p ['server disconnect', sse] + end.on_client_disconnect do |socket| + p ['client disconnect', socket] + end.on_error do |error| + p ['exception', error] + puts error.backtrace.join("\n") + end + + if datastar.sse? + # This will run in its own thread / fiber + datastar.stream do |sse| + 11.times do |i| + sleep 1 + # Raising an error to demonstrate error handling + # raise ArgumentError, 'This is an error' if i > 5 + + sse.merge_fragments(%(#{i})) + end + end + + # Another thread / fiber + datastar.stream do |sse| + 1000.times do |i| + sleep 0.01 + sse.merge_fragments(%(#{i})) + end + end + else + [200, { 'content-type' => 'text/html' }, [INDEX]] + end +end diff --git a/examples/ruby/threads/Gemfile b/examples/ruby/threads/Gemfile new file mode 100644 index 000000000..27d6a9d92 --- /dev/null +++ b/examples/ruby/threads/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'puma' +gem 'rack' +# gem 'datastar' +gem 'datastar', path: '../../../sdk/ruby' diff --git a/examples/ruby/threads/Gemfile.lock b/examples/ruby/threads/Gemfile.lock new file mode 100644 index 000000000..83df723bf --- /dev/null +++ b/examples/ruby/threads/Gemfile.lock @@ -0,0 +1,25 @@ +PATH + remote: ../../../sdk/ruby + specs: + datastar (1.0.0.beta.1) + rack (~> 3.0) + +GEM + remote: https://rubygems.org/ + specs: + nio4r (2.7.4) + puma (6.6.0) + nio4r (~> 2.0) + rack (3.1.9) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + datastar! + puma + rack + +BUNDLED WITH + 2.6.3 diff --git a/examples/ruby/threads/threads.ru b/examples/ruby/threads/threads.ru new file mode 100644 index 000000000..425174dae --- /dev/null +++ b/examples/ruby/threads/threads.ru @@ -0,0 +1,84 @@ +require 'bundler/setup' + +require 'datastar' + +# This is a test Rack endpoint +# to demo streaming Datastar updates from multiple threads. +# To run: +# +# # install dependencies +# bundle install +# # run this endpoint with Puma server +# bundle exec puma examples/threaded.ru +# +# visit http://localhost:9292 +# +INDEX = <<~HTML + + + + + Datastar counter + + + + + Start +

Slow thread: waiting

+

Fast thread: waiting

+

Disconnected...

+ + +HTML + +trap('INT') { exit } + +run do |env| + # Initialize Datastar with callbacks + datastar = Datastar + .from_rack_env(env) + .on_connect do |sse| + sse.merge_fragments(%(

Connected...

)) + p ['connect', sse] + end.on_server_disconnect do |sse| + sse.merge_fragments(%(

Done...

)) + p ['server disconnect', sse] + end.on_client_disconnect do |socket| + p ['client disconnect', socket] + end.on_error do |error| + p ['exception', error] + puts error.backtrace.join("\n") + end + + if datastar.sse? + # This will run in its own thread / fiber + datastar.stream do |sse| + 11.times do |i| + sleep 1 + # Raising an error to demonstrate error handling + # raise ArgumentError, 'This is an error' if i > 5 + + sse.merge_fragments(%(#{i})) + end + end + + # Another thread / fiber + datastar.stream do |sse| + 1000.times do |i| + sleep 0.01 + sse.merge_fragments(%(#{i})) + end + end + else + [200, { 'content-type' => 'text/html' }, [INDEX]] + end +end diff --git a/sdk/ruby/.gitignore b/sdk/ruby/.gitignore new file mode 100644 index 000000000..b04a8c840 --- /dev/null +++ b/sdk/ruby/.gitignore @@ -0,0 +1,11 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/sdk/ruby/.rspec b/sdk/ruby/.rspec new file mode 100644 index 000000000..34c5164d9 --- /dev/null +++ b/sdk/ruby/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/sdk/ruby/Gemfile b/sdk/ruby/Gemfile new file mode 100644 index 000000000..b8f8aabf3 --- /dev/null +++ b/sdk/ruby/Gemfile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +# Specify your gem's dependencies in datastar.gemspec +gemspec + +gem 'rake', '~> 13.0' + +gem 'rspec', '~> 3.0' + +gem 'debug' + +group :test do + # Async to test Datastar::AsyncExecutor + gem 'async' + # Puma to host test server + gem 'puma' +end diff --git a/sdk/ruby/Gemfile.lock b/sdk/ruby/Gemfile.lock new file mode 100644 index 000000000..cb252369c --- /dev/null +++ b/sdk/ruby/Gemfile.lock @@ -0,0 +1,81 @@ +PATH + remote: . + specs: + datastar (1.0.0.beta.1) + rack (~> 3.0) + +GEM + remote: https://rubygems.org/ + specs: + async (2.21.3) + console (~> 1.29) + fiber-annotation + io-event (~> 1.7) + metrics (~> 0.12) + traces (~> 0.15) + console (1.29.2) + fiber-annotation + fiber-local (~> 1.1) + json + date (3.4.1) + debug (1.10.0) + irb (~> 1.10) + reline (>= 0.3.8) + diff-lcs (1.5.1) + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (1.0.0) + io-console (0.8.0) + io-event (1.7.5) + irb (1.15.1) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.9.1) + metrics (0.12.1) + nio4r (2.7.4) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + psych (5.2.3) + date + stringio + puma (6.6.0) + nio4r (~> 2.0) + rack (3.1.9) + rake (13.2.1) + rdoc (6.11.0) + psych (>= 4.0.0) + reline (0.6.0) + io-console (~> 0.5) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.2) + stringio (3.1.2) + traces (0.15.2) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + async + datastar! + debug + puma + rake (~> 13.0) + rspec (~> 3.0) + +BUNDLED WITH + 2.5.23 diff --git a/sdk/ruby/README.md b/sdk/ruby/README.md new file mode 100644 index 000000000..24cf919ef --- /dev/null +++ b/sdk/ruby/README.md @@ -0,0 +1,256 @@ +# Datastar Ruby SDK + +Implement the [Datastart SSE procotocol](https://data-star.dev/reference/sse_events) in Ruby. It can be used in any Rack handler, and Rails controllers. + +## Installation + +Install the gem and add to the application's Gemfile by executing: + +```bash +bundle add datastar +``` + +Or point your `Gemfile` to the source + +```bash +gem 'datastar', git: 'https://github.com/starfederation/datastar', glob: 'sdk/ruby/*.gemspec' +``` + +## Usage + +### Initialize the Datastar dispatcher + +In your Rack handler or Rails controller: + +```ruby +# Rails controllers, as well as Sinatra and others, +# already have request and response objects + +datastar = Datastar.new(request:, response:, view_context: self) + +# In a Rack handler, you can instantiate from the Rack env +datastar = Datastar.from_rack_env(env) +``` + +### Sending updates to the browser + +There are two ways to use this gem in HTTP handlers: + +* One-off responses, where you want to send a single update down to the browser. +* Streaming responses, where you want to send multiple updates down to the browser. + +#### One-off update: + +```ruby +datastar.merge_fragments(%(

Hello, World!

)) +``` +In this mode, the response is closed after the fragment is sent. + +#### Streaming updates + +```ruby +datastar.stream do |sse| + sse.merge_fragments(%(

Hello, World!

)) + # Streaming multiple updates + 100.times do |i| + sleep 1 + sse.merge_fragments(%(

Hello, World #{i}!

)) + end +end +``` +In this mode, the response is kept open until `stream` blocks have finished. + +#### Concurrent streaming blocks + +Multiple `stream` blocks will be launched in threads/fibers, and will run concurrently. +Their updates are linearized and sent to the browser as they are produced. + +```ruby +# Stream to the browser from two concurrent threads +datastar.stream do |sse| + 100.times do |i| + sleep 1 + sse.merge_fragments(%(

#{i}!

)) + end +end + +datastar.stream do |sse| + 1000.times do |i| + sleep 0.1 + sse.merge_fragments(%(

#{i}!

)) + end +end +``` + +See the [examples](https://github.com/starfederation/datastar/tree/main/examples/ruby) directory. + +### Datastar methods + +All these methods are available in both the one-off and the streaming modes. + +#### `merge_fragments` +See https://data-star.dev/reference/sse_events#datastar-merge-fragments + +```ruby +sse.merge_fragments(%(
\nhello\n
)) + +# or a Phlex view object +sse.merge_fragments(UserComponet.new) + +# Or pass options +sse.merge_fragments( + %(
\nhello\n
), + merge_mode: 'append' +) +``` + +#### `remove_fragments` + See https://data-star.dev/reference/sse_events#datastar-remove-fragments + +```ruby +sse.remove_fragments('#users') +``` + +#### `merge_signals` + See https://data-star.dev/reference/sse_events#datastar-merge-signals + +```ruby +sse.merge_signals(count: 4, user: { name: 'John' }) +``` + +#### `remove_signals` + See https://data-star.dev/reference/sse_events#datastar-remove-signals + +```ruby +sse.remove_signals(['user.name', 'user.email']) +``` + +#### `execute_script` +See https://data-star.dev/reference/sse_events#datastar-execute-script + +```ruby +sse.execute_scriprt(%(alert('Hello World!')) + ``` + +#### `signals` +See https://data-star.dev/guide/getting_started#data-signals + +Returns signals sent by the browser. + +```ruby +sse.signals # => { user: { name: 'John' } } + ``` + +#### `redirect` +This is just a helper to send a script to update the browser's location. + +```ruby +sse.redirect('/new_location') + ``` + +### Lifecycle callbacks + +#### `on_connect` +Register server-side code to run when the connection is first handled. + +```ruby +datastar.on_connect do + puts 'A user has connected' +end +``` + +#### `on_client_disconnect` +Register server-side code to run when the connection is closed by the client + +```ruby +datastar.on_client_connect do + puts 'A user has disconnected connected' +end +``` + +#### `on_server_disconnect` +Register server-side code to run when the connection is closed by the server. +Ie when the served is done streaming without errors. + +```ruby +datastar.on_server_connect do + puts 'Server is done streaming' +end +``` + +#### `on_error` +Ruby code to handle any exceptions raised by streaming blocks. + +```ruby +datastar.on_error do |exception| + Sentry.notify(exception) +end +``` +Note that this callback can be registered globally, too. + +### Global configuration + +```ruby +Datastar.configure do |config| + config.on_error do |exception| + Sentry.notify(exception) + end +end +``` + +### Rails + +#### Rendering Rails templates + +```ruby +datastar.stream do |sse| + 10.times do |i| + sleep 1 + tpl = render_to_string('events/user', layout: false, locals: { name: "David #{i}" }) + sse.merge_fragments tpl + end +end +``` + +#### Rendering Phlex components + +`#merge_fragments` supports [Phlex](https://www.phlex.fun) component instances. + +```ruby +sse.merge_fragments(UserComponent.new(user: User.first)) +``` + +### Tests + +```ruby +bundle exec rspec +``` + +#### Running Datastar's SDK test suite + +Install dependencies. +```bash +bundle install +``` + +From this library's root, run the bundled-in test Rack app: + +```bash +bundle puma examples/test.ru +``` + +Now run the test bash scripts in the `test` directory in this repo. + +```bash +./test-all.sh http://localhost:9292 +``` + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/starfederation/datastar. diff --git a/sdk/ruby/Rakefile b/sdk/ruby/Rakefile new file mode 100644 index 000000000..b6ae73410 --- /dev/null +++ b/sdk/ruby/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/sdk/ruby/bin/console b/sdk/ruby/bin/console new file mode 100755 index 000000000..3b74ca07d --- /dev/null +++ b/sdk/ruby/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "datastar" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require "irb" +IRB.start(__FILE__) diff --git a/sdk/ruby/bin/setup b/sdk/ruby/bin/setup new file mode 100755 index 000000000..dce67d860 --- /dev/null +++ b/sdk/ruby/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/sdk/ruby/datastar.gemspec b/sdk/ruby/datastar.gemspec new file mode 100644 index 000000000..1b668cc7d --- /dev/null +++ b/sdk/ruby/datastar.gemspec @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative 'lib/datastar/version' + +Gem::Specification.new do |spec| + spec.name = 'datastar' + spec.version = Datastar::VERSION + spec.authors = ['Ismael Celis'] + spec.email = ['ismaelct@gmail.com'] + + spec.summary = 'Ruby SDK for Datastar. Rack-compatible.' + spec.homepage = 'https://github.com/starfederation/datastar#readme' + spec.required_ruby_version = '>= 3.0.0' + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/starfederation/datastar' + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + gemspec = File.basename(__FILE__) + spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| + ls.readlines("\x0", chomp: true).reject do |f| + (f == gemspec) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile]) + end + end + spec.bindir = 'exe' + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ['lib'] + + # Uncomment to register a new dependency of your gem + spec.add_dependency 'rack', '~> 3.0' + + # For more information and examples about making a new gem, check out our + # guide at: https://bundler.io/guides/creating_gem.html +end diff --git a/sdk/ruby/examples/test.ru b/sdk/ruby/examples/test.ru new file mode 100644 index 000000000..4e505ecf6 --- /dev/null +++ b/sdk/ruby/examples/test.ru @@ -0,0 +1,56 @@ +require 'bundler' +Bundler.setup(:test) + +require 'datastar' + +# This is a test Rack endpoint to run +# Datastar's SDK test suite agains. +# To run: +# +# # install dependencies +# bundle install +# # run this endpoint with Puma server +# bundle exec puma examples/test.ru +# +# Then you can run SDK's test bash script: +# See https://github.com/starfederation/datastar/blob/develop/sdk/test/README.md +# +# ./test-all.sh http://localhost:9292 +# +run do |env| + datastar = Datastar + .from_rack_env(env) + .on_connect do |socket| + p ['connect', socket] + end.on_server_disconnect do |socket| + p ['server disconnect', socket] + end.on_client_disconnect do |socket| + p ['client disconnect', socket] + end.on_error do |error| + p ['exception', error] + puts error.backtrace.join("\n") + end + + datastar.stream do |sse| + sse.signals['events'].each do |event| + type = event.delete('type') + case type + when 'mergeSignals' + arg = event.delete('signals') + sse.merge_signals(arg, event) + when 'removeSignals' + arg = event.delete('paths') + sse.remove_signals(arg, event) + when 'executeScript' + arg = event.delete('script') + sse.execute_script(arg, event) + when 'mergeFragments' + arg = event.delete('fragments') + sse.merge_fragments(arg, event) + when 'removeFragments' + arg = event.delete('selector') + sse.remove_fragments(arg, event) + end + end + end +end diff --git a/sdk/ruby/lib/datastar.rb b/sdk/ruby/lib/datastar.rb new file mode 100644 index 000000000..8441a3df4 --- /dev/null +++ b/sdk/ruby/lib/datastar.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative 'datastar/version' +require_relative 'datastar/consts' + +module Datastar + BLANK_OPTIONS = {}.freeze + + def self.config + @config ||= Configuration.new + end + + def self.configure(&) + yield config if block_given? + config.freeze + config + end + + def self.new(...) + Dispatcher.new(...) + end + + def self.from_rack_env(env, view_context: nil) + request = Rack::Request.new(env) + Dispatcher.new(request:, view_context:) + end +end + +require_relative 'datastar/configuration' +require_relative 'datastar/dispatcher' +require_relative 'datastar/server_sent_event_generator' +require_relative 'datastar/railtie' if defined?(Rails::Railtie) diff --git a/sdk/ruby/lib/datastar/async_executor.rb b/sdk/ruby/lib/datastar/async_executor.rb new file mode 100644 index 000000000..9faab1515 --- /dev/null +++ b/sdk/ruby/lib/datastar/async_executor.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'async' +require 'async/queue' + +module Datastar + # An executor that uses Fibers (via the Async library) + # Use this when Rails is configured to use Fibers + # or when using the Falcon web server + # See https://github.com/socketry/falcon + class AsyncExecutor + def initialize + # Async::Task instances + # that raise exceptions log + # the error with :warn level, + # even if the exception is handled upstream + # See https://github.com/socketry/async/blob/9851cb945ae49a85375d120219000fe7db457307/lib/async/task.rb#L204 + # Not great to silence these logs for ALL tasks + # in a Rails app (I only want to silence them for Datastar tasks) + Console.logger.disable(Async::Task) + end + + def new_queue = Async::Queue.new + + def prepare(response); end + + def spawn(&block) + Async(&block) + end + + def stop(threads) + threads.each(&:stop) + end + end +end diff --git a/sdk/ruby/lib/datastar/configuration.rb b/sdk/ruby/lib/datastar/configuration.rb new file mode 100644 index 000000000..6edbdff8a --- /dev/null +++ b/sdk/ruby/lib/datastar/configuration.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'thread' + +module Datastar + # The default executor based on Ruby threads + class ThreadExecutor + def new_queue = Queue.new + + def prepare(response); end + + def spawn(&block) + Thread.new(&block) + end + + def stop(threads) + threads.each(&:kill) + end + end + + # Datastar configuration + # @example + # + # Datastar.configure do |config| + # config.on_error do |error| + # Sentry.notify(error) + # end + # end + # + # You'd normally do this on app initialization + # For example in a Rails initializer + class Configuration + NOOP_CALLBACK = ->(_error) {} + RACK_FINALIZE = ->(_view_context, response) { response.finish } + + attr_accessor :executor, :error_callback, :finalize + + def initialize + @executor = ThreadExecutor.new + @error_callback = NOOP_CALLBACK + @finalize = RACK_FINALIZE + end + + def on_error(callable = nil, &block) + @error_callback = callable || block + self + end + end +end diff --git a/sdk/ruby/lib/datastar/consts.rb b/sdk/ruby/lib/datastar/consts.rb new file mode 100644 index 000000000..80bef754d --- /dev/null +++ b/sdk/ruby/lib/datastar/consts.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# This is auto-generated by Datastar. DO NOT EDIT. +module Datastar + module Consts + DATASTAR_KEY = 'datastar' + VERSION = '1.0.0-beta.3' + + # The default duration for settling during fragment merges. Allows for CSS transitions to complete. + DEFAULT_FRAGMENTS_SETTLE_DURATION = 300 + + # The default duration for retrying SSE on connection reset. This is part of the underlying retry mechanism of SSE. + DEFAULT_SSE_RETRY_DURATION = 1000 + + # Should fragments be merged using the ViewTransition API? + DEFAULT_FRAGMENTS_USE_VIEW_TRANSITIONS = false + + # Should a given set of signals merge if they are missing? + DEFAULT_MERGE_SIGNALS_ONLY_IF_MISSING = false + + # Should script element remove itself after execution? + DEFAULT_EXECUTE_SCRIPT_AUTO_REMOVE = true + + # The default attributes for