datastar 1.0.0.beta.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 07c2774d8c0274b50336a6163a1e22b4451bfd2ec271601d0a5a9f21c0e14fba
4
+ data.tar.gz: bebe0a2d43cf8ab1e03a1693bbd2bfc206e8f1e650bf24333d39a8cfa59a4ebb
5
+ SHA512:
6
+ metadata.gz: f98e8f6b5de65c9250b2ab678970f9ecdac84edfbc5088951570bd8b1f5f08d134180216d8b92d272628cd392bdb0ce1b71bb80f7263451a18de7308c7bf24d7
7
+ data.tar.gz: d179c5dd59e5a5de18d2688a721e10dc7d62042c40b59cd1bd707f7a147e942ba234ebf6c2ca101b3cbce2d8c6288e6809d77fe3c7282569786c59e21b81282d
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/LICENSE.md ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) Ismael Celis
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # Datastar Ruby SDK
2
+
3
+ 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.
4
+
5
+ ## Installation
6
+
7
+ Install the gem and add to the application's Gemfile by executing:
8
+
9
+ ```bash
10
+ bundle add datastar
11
+ ```
12
+
13
+ Or point your `Gemfile` to the source
14
+
15
+ ```bash
16
+ gem 'datastar', git: 'https://github.com/starfederation/datastar', glob: 'sdk/ruby/*.gemspec'
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Initialize the Datastar dispatcher
22
+
23
+ In your Rack handler or Rails controller:
24
+
25
+ ```ruby
26
+ # Rails controllers, as well as Sinatra and others,
27
+ # already have request and response objects.
28
+ # `view_context` is optional and is used to render Rails templates.
29
+ # Or view components that need access to helpers, routes, or any other context.
30
+
31
+ datastar = Datastar.new(request:, response:, view_context:)
32
+
33
+ # In a Rack handler, you can instantiate from the Rack env
34
+ datastar = Datastar.from_rack_env(env)
35
+ ```
36
+
37
+ ### Sending updates to the browser
38
+
39
+ There are two ways to use this gem in HTTP handlers:
40
+
41
+ * One-off responses, where you want to send a single update down to the browser.
42
+ * Streaming responses, where you want to send multiple updates down to the browser.
43
+
44
+ #### One-off update:
45
+
46
+ ```ruby
47
+ datastar.merge_fragments(%(<h1 id="title">Hello, World!</h1>))
48
+ ```
49
+ In this mode, the response is closed after the fragment is sent.
50
+
51
+ #### Streaming updates
52
+
53
+ ```ruby
54
+ datastar.stream do |sse|
55
+ sse.merge_fragments(%(<h1 id="title">Hello, World!</h1>))
56
+ # Streaming multiple updates
57
+ 100.times do |i|
58
+ sleep 1
59
+ sse.merge_fragments(%(<h1 id="title">Hello, World #{i}!</h1>))
60
+ end
61
+ end
62
+ ```
63
+ In this mode, the response is kept open until `stream` blocks have finished.
64
+
65
+ #### Concurrent streaming blocks
66
+
67
+ Multiple `stream` blocks will be launched in threads/fibers, and will run concurrently.
68
+ Their updates are linearized and sent to the browser as they are produced.
69
+
70
+ ```ruby
71
+ # Stream to the browser from two concurrent threads
72
+ datastar.stream do |sse|
73
+ 100.times do |i|
74
+ sleep 1
75
+ sse.merge_fragments(%(<h1 id="slow">#{i}!</h1>))
76
+ end
77
+ end
78
+
79
+ datastar.stream do |sse|
80
+ 1000.times do |i|
81
+ sleep 0.1
82
+ sse.merge_fragments(%(<h1 id="fast">#{i}!</h1>))
83
+ end
84
+ end
85
+ ```
86
+
87
+ See the [examples](https://github.com/starfederation/datastar/tree/main/examples/ruby) directory.
88
+
89
+ ### Datastar methods
90
+
91
+ All these methods are available in both the one-off and the streaming modes.
92
+
93
+ #### `merge_fragments`
94
+ See https://data-star.dev/reference/sse_events#datastar-merge-fragments
95
+
96
+ ```ruby
97
+ sse.merge_fragments(%(<div id="foo">\n<span>hello</span>\n</div>))
98
+
99
+ # or a Phlex view object
100
+ sse.merge_fragments(UserComponet.new)
101
+
102
+ # Or pass options
103
+ sse.merge_fragments(
104
+ %(<div id="foo">\n<span>hello</span>\n</div>),
105
+ merge_mode: 'append'
106
+ )
107
+ ```
108
+
109
+ #### `remove_fragments`
110
+ See https://data-star.dev/reference/sse_events#datastar-remove-fragments
111
+
112
+ ```ruby
113
+ sse.remove_fragments('#users')
114
+ ```
115
+
116
+ #### `merge_signals`
117
+ See https://data-star.dev/reference/sse_events#datastar-merge-signals
118
+
119
+ ```ruby
120
+ sse.merge_signals(count: 4, user: { name: 'John' })
121
+ ```
122
+
123
+ #### `remove_signals`
124
+ See https://data-star.dev/reference/sse_events#datastar-remove-signals
125
+
126
+ ```ruby
127
+ sse.remove_signals(['user.name', 'user.email'])
128
+ ```
129
+
130
+ #### `execute_script`
131
+ See https://data-star.dev/reference/sse_events#datastar-execute-script
132
+
133
+ ```ruby
134
+ sse.execute_scriprt(%(alert('Hello World!'))
135
+ ```
136
+
137
+ #### `signals`
138
+ See https://data-star.dev/guide/getting_started#data-signals
139
+
140
+ Returns signals sent by the browser.
141
+
142
+ ```ruby
143
+ sse.signals # => { user: { name: 'John' } }
144
+ ```
145
+
146
+ #### `redirect`
147
+ This is just a helper to send a script to update the browser's location.
148
+
149
+ ```ruby
150
+ sse.redirect('/new_location')
151
+ ```
152
+
153
+ ### Lifecycle callbacks
154
+
155
+ #### `on_connect`
156
+ Register server-side code to run when the connection is first handled.
157
+
158
+ ```ruby
159
+ datastar.on_connect do
160
+ puts 'A user has connected'
161
+ end
162
+ ```
163
+
164
+ #### `on_client_disconnect`
165
+ Register server-side code to run when the connection is closed by the client
166
+
167
+ ```ruby
168
+ datastar.on_client_connect do
169
+ puts 'A user has disconnected connected'
170
+ end
171
+ ```
172
+
173
+ #### `on_server_disconnect`
174
+ Register server-side code to run when the connection is closed by the server.
175
+ Ie when the served is done streaming without errors.
176
+
177
+ ```ruby
178
+ datastar.on_server_connect do
179
+ puts 'Server is done streaming'
180
+ end
181
+ ```
182
+
183
+ #### `on_error`
184
+ Ruby code to handle any exceptions raised by streaming blocks.
185
+
186
+ ```ruby
187
+ datastar.on_error do |exception|
188
+ Sentry.notify(exception)
189
+ end
190
+ ```
191
+ Note that this callback can be registered globally, too.
192
+
193
+ ### Global configuration
194
+
195
+ ```ruby
196
+ Datastar.configure do |config|
197
+ config.on_error do |exception|
198
+ Sentry.notify(exception)
199
+ end
200
+ end
201
+ ```
202
+
203
+ ### Rendering Rails templates
204
+
205
+ In Rails, make sure to initialize Datastar with the `view_context` in a controller.
206
+ This is so that rendered templates, components or views have access to helpers, routes, etc.
207
+
208
+ ```ruby
209
+ datastar = Datastar.new(request:, response:, view_context:)
210
+
211
+ datastar.stream do |sse|
212
+ 10.times do |i|
213
+ sleep 1
214
+ tpl = render_to_string('events/user', layout: false, locals: { name: "David #{i}" })
215
+ sse.merge_fragments tpl
216
+ end
217
+ end
218
+ ```
219
+
220
+ ### Rendering Phlex components
221
+
222
+ `#merge_fragments` supports [Phlex](https://www.phlex.fun) component instances.
223
+
224
+ ```ruby
225
+ sse.merge_fragments(UserComponent.new(user: User.first))
226
+ ```
227
+
228
+ ### Rendering ViewComponent instances
229
+
230
+ `#merge_fragments` also works with [ViewComponent](https://viewcomponent.org) instances.
231
+
232
+ ```ruby
233
+ sse.merge_fragments(UserViewComponent.new(user: User.first))
234
+ ```
235
+
236
+ ### Rendering `#render_in(view_context)` interfaces
237
+
238
+ Any object that supports the `#render_in(view_context) => String` API can be used as a fragment.
239
+
240
+ ```ruby
241
+ class MyComponent
242
+ def initialize(name)
243
+ @name = name
244
+ end
245
+
246
+ def render_in(view_context)
247
+ "<div>Hello #{@name}</div>""
248
+ end
249
+ end
250
+ ```
251
+
252
+ ```ruby
253
+ sse.merge_fragments MyComponent.new('Joe')
254
+ ```
255
+
256
+
257
+
258
+ ### Tests
259
+
260
+ ```ruby
261
+ bundle exec rspec
262
+ ```
263
+
264
+ #### Running Datastar's SDK test suite
265
+
266
+ Install dependencies.
267
+ ```bash
268
+ bundle install
269
+ ```
270
+
271
+ From this library's root, run the bundled-in test Rack app:
272
+
273
+ ```bash
274
+ bundle puma examples/test.ru
275
+ ```
276
+
277
+ Now run the test bash scripts in the `test` directory in this repo.
278
+
279
+ ```bash
280
+ ./test-all.sh http://localhost:9292
281
+ ```
282
+
283
+ ## Development
284
+
285
+ 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.
286
+
287
+ 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).
288
+
289
+ ## Contributing
290
+
291
+ Bug reports and pull requests are welcome on GitHub at https://github.com/starfederation/datastar.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/examples/test.ru ADDED
@@ -0,0 +1,56 @@
1
+ require 'bundler'
2
+ Bundler.setup(:test)
3
+
4
+ require 'datastar'
5
+
6
+ # This is a test Rack endpoint to run
7
+ # Datastar's SDK test suite agains.
8
+ # To run:
9
+ #
10
+ # # install dependencies
11
+ # bundle install
12
+ # # run this endpoint with Puma server
13
+ # bundle exec puma examples/test.ru
14
+ #
15
+ # Then you can run SDK's test bash script:
16
+ # See https://github.com/starfederation/datastar/blob/develop/sdk/test/README.md
17
+ #
18
+ # ./test-all.sh http://localhost:9292
19
+ #
20
+ run do |env|
21
+ datastar = Datastar
22
+ .from_rack_env(env)
23
+ .on_connect do |socket|
24
+ p ['connect', socket]
25
+ end.on_server_disconnect do |socket|
26
+ p ['server disconnect', socket]
27
+ end.on_client_disconnect do |socket|
28
+ p ['client disconnect', socket]
29
+ end.on_error do |error|
30
+ p ['exception', error]
31
+ puts error.backtrace.join("\n")
32
+ end
33
+
34
+ datastar.stream do |sse|
35
+ sse.signals['events'].each do |event|
36
+ type = event.delete('type')
37
+ case type
38
+ when 'mergeSignals'
39
+ arg = event.delete('signals')
40
+ sse.merge_signals(arg, event)
41
+ when 'removeSignals'
42
+ arg = event.delete('paths')
43
+ sse.remove_signals(arg, event)
44
+ when 'executeScript'
45
+ arg = event.delete('script')
46
+ sse.execute_script(arg, event)
47
+ when 'mergeFragments'
48
+ arg = event.delete('fragments')
49
+ sse.merge_fragments(arg, event)
50
+ when 'removeFragments'
51
+ arg = event.delete('selector')
52
+ sse.remove_fragments(arg, event)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'async'
4
+ require 'async/queue'
5
+
6
+ module Datastar
7
+ # An executor that uses Fibers (via the Async library)
8
+ # Use this when Rails is configured to use Fibers
9
+ # or when using the Falcon web server
10
+ # See https://github.com/socketry/falcon
11
+ class AsyncExecutor
12
+ def initialize
13
+ # Async::Task instances
14
+ # that raise exceptions log
15
+ # the error with :warn level,
16
+ # even if the exception is handled upstream
17
+ # See https://github.com/socketry/async/blob/9851cb945ae49a85375d120219000fe7db457307/lib/async/task.rb#L204
18
+ # Not great to silence these logs for ALL tasks
19
+ # in a Rails app (I only want to silence them for Datastar tasks)
20
+ Console.logger.disable(Async::Task)
21
+ end
22
+
23
+ def new_queue = Async::Queue.new
24
+
25
+ def prepare(response); end
26
+
27
+ def spawn(&block)
28
+ Async(&block)
29
+ end
30
+
31
+ def stop(threads)
32
+ threads.each(&:stop)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+
5
+ module Datastar
6
+ # The default executor based on Ruby threads
7
+ class ThreadExecutor
8
+ def new_queue = Queue.new
9
+
10
+ def prepare(response); end
11
+
12
+ def spawn(&block)
13
+ Thread.new(&block)
14
+ end
15
+
16
+ def stop(threads)
17
+ threads.each(&:kill)
18
+ end
19
+ end
20
+
21
+ # Datastar configuration
22
+ # @example
23
+ #
24
+ # Datastar.configure do |config|
25
+ # config.on_error do |error|
26
+ # Sentry.notify(error)
27
+ # end
28
+ # end
29
+ #
30
+ # You'd normally do this on app initialization
31
+ # For example in a Rails initializer
32
+ class Configuration
33
+ NOOP_CALLBACK = ->(_error) {}
34
+ RACK_FINALIZE = ->(_view_context, response) { response.finish }
35
+
36
+ attr_accessor :executor, :error_callback, :finalize
37
+
38
+ def initialize
39
+ @executor = ThreadExecutor.new
40
+ @error_callback = NOOP_CALLBACK
41
+ @finalize = RACK_FINALIZE
42
+ end
43
+
44
+ def on_error(callable = nil, &block)
45
+ @error_callback = callable || block
46
+ self
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This is auto-generated by Datastar. DO NOT EDIT.
4
+ module Datastar
5
+ module Consts
6
+ DATASTAR_KEY = 'datastar'
7
+ VERSION = '1.0.0-beta.5'
8
+
9
+ # The default duration for settling during fragment merges. Allows for CSS transitions to complete.
10
+ DEFAULT_FRAGMENTS_SETTLE_DURATION = 300
11
+
12
+ # The default duration for retrying SSE on connection reset. This is part of the underlying retry mechanism of SSE.
13
+ DEFAULT_SSE_RETRY_DURATION = 1000
14
+
15
+ # Should fragments be merged using the ViewTransition API?
16
+ DEFAULT_FRAGMENTS_USE_VIEW_TRANSITIONS = false
17
+
18
+ # Should a given set of signals merge if they are missing?
19
+ DEFAULT_MERGE_SIGNALS_ONLY_IF_MISSING = false
20
+
21
+ # Should script element remove itself after execution?
22
+ DEFAULT_EXECUTE_SCRIPT_AUTO_REMOVE = true
23
+
24
+ # The default attributes for <script/> element use when executing scripts. It is a set of key-value pairs delimited by a newline \\n character.}
25
+ DEFAULT_EXECUTE_SCRIPT_ATTRIBUTES = 'type module'
26
+
27
+ module FragmentMergeMode
28
+
29
+ # Morphs the fragment into the existing element using idiomorph.
30
+ MORPH = 'morph'
31
+
32
+ # Replaces the inner HTML of the existing element.
33
+ INNER = 'inner'
34
+
35
+ # Replaces the outer HTML of the existing element.
36
+ OUTER = 'outer'
37
+
38
+ # Prepends the fragment to the existing element.
39
+ PREPEND = 'prepend'
40
+
41
+ # Appends the fragment to the existing element.
42
+ APPEND = 'append'
43
+
44
+ # Inserts the fragment before the existing element.
45
+ BEFORE = 'before'
46
+
47
+ # Inserts the fragment after the existing element.
48
+ AFTER = 'after'
49
+
50
+ # Upserts the attributes of the existing element.
51
+ UPSERT_ATTRIBUTES = 'upsertAttributes'
52
+ end
53
+
54
+ # The mode in which a fragment is merged into the DOM.
55
+ DEFAULT_FRAGMENT_MERGE_MODE = FragmentMergeMode::MORPH
56
+
57
+ # Dataline literals.
58
+ SELECTOR_DATALINE_LITERAL = 'selector'
59
+ MERGE_MODE_DATALINE_LITERAL = 'mergeMode'
60
+ SETTLE_DURATION_DATALINE_LITERAL = 'settleDuration'
61
+ FRAGMENTS_DATALINE_LITERAL = 'fragments'
62
+ USE_VIEW_TRANSITION_DATALINE_LITERAL = 'useViewTransition'
63
+ SIGNALS_DATALINE_LITERAL = 'signals'
64
+ ONLY_IF_MISSING_DATALINE_LITERAL = 'onlyIfMissing'
65
+ PATHS_DATALINE_LITERAL = 'paths'
66
+ SCRIPT_DATALINE_LITERAL = 'script'
67
+ ATTRIBUTES_DATALINE_LITERAL = 'attributes'
68
+ AUTO_REMOVE_DATALINE_LITERAL = 'autoRemove'
69
+ end
70
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datastar
4
+ # The Dispatcher encapsulates the logic of handling a request
5
+ # and building a response with streaming datastar messages.
6
+ # You'll normally instantiate a Dispatcher in your controller action of Rack handler
7
+ # via Datastar.new.
8
+ # @example
9
+ #
10
+ # datastar = Datastar.new(request:, response:, view_context: self)
11
+ #
12
+ # # One-off fragment response
13
+ # datastar.merge_fragments(template)
14
+ #
15
+ # # Streaming response with multiple messages
16
+ # datastar.stream do |sse|
17
+ # sse.merge_fragments(template)
18
+ # 10.times do |i|
19
+ # sleep 0.1
20
+ # sse.merge_signals(count: i)
21
+ # end
22
+ # end
23
+ #
24
+ class Dispatcher
25
+ BLANK_BODY = [].freeze
26
+ SSE_CONTENT_TYPE = 'text/event-stream'
27
+ HTTP_ACCEPT = 'HTTP_ACCEPT'
28
+ HTTP1 = 'HTTP/1.1'
29
+
30
+ attr_reader :request, :response
31
+
32
+ # @option request [Rack::Request] the request object
33
+ # @option response [Rack::Response, nil] the response object
34
+ # @option view_context [Object, nil] the view context object, to use when rendering templates. Ie. a controller, or Sinatra app.
35
+ # @option executor [Object] the executor object to use for managing threads and queues
36
+ # @option error_callback [Proc] the callback to call when an error occurs
37
+ # @option finalize [Proc] the callback to call when the response is finalized
38
+ def initialize(
39
+ request:,
40
+ response: nil,
41
+ view_context: nil,
42
+ executor: Datastar.config.executor,
43
+ error_callback: Datastar.config.error_callback,
44
+ finalize: Datastar.config.finalize
45
+ )
46
+ @on_connect = []
47
+ @on_client_disconnect = []
48
+ @on_server_disconnect = []
49
+ @on_error = [error_callback]
50
+ @finalize = finalize
51
+ @streamers = []
52
+ @queue = nil
53
+ @executor = executor
54
+ @view_context = view_context
55
+ @request = request
56
+ @response = Rack::Response.new(BLANK_BODY, 200, response&.headers || {})
57
+ @response.content_type = SSE_CONTENT_TYPE
58
+ @response.headers['Cache-Control'] = 'no-cache'
59
+ @response.headers['Connection'] = 'keep-alive' if @request.env['SERVER_PROTOCOL'] == HTTP1
60
+ # Disable response buffering in NGinx and other proxies
61
+ @response.headers['X-Accel-Buffering'] = 'no'
62
+ @response.delete_header 'Content-Length'
63
+ @executor.prepare(@response)
64
+ end
65
+
66
+ # Check if the request accepts SSE responses
67
+ # @return [Boolean]
68
+ def sse?
69
+ @request.get_header(HTTP_ACCEPT) == SSE_CONTENT_TYPE
70
+ end
71
+
72
+ # Register an on-connect callback
73
+ # Triggered when the request is handled
74
+ # @param callable [Proc, nil] the callback to call
75
+ # @yieldparam sse [ServerSentEventGenerator] the generator object
76
+ # @return [self]
77
+ def on_connect(callable = nil, &block)
78
+ @on_connect << (callable || block)
79
+ self
80
+ end
81
+
82
+ # Register a callback for client disconnection
83
+ # Ex. when the browser is closed mid-stream
84
+ # @param callable [Proc, nil] the callback to call
85
+ # @return [self]
86
+ def on_client_disconnect(callable = nil, &block)
87
+ @on_client_disconnect << (callable || block)
88
+ self
89
+ end
90
+
91
+ # Register a callback for server disconnection
92
+ # Ex. when the server finishes serving the request
93
+ # @param callable [Proc, nil] the callback to call
94
+ # @return [self]
95
+ def on_server_disconnect(callable = nil, &block)
96
+ @on_server_disconnect << (callable || block)
97
+ self
98
+ end
99
+
100
+ # Register a callback server-side exceptions
101
+ # Ex. when one of the server threads raises an exception
102
+ # @param callable [Proc, nil] the callback to call
103
+ # @return [self]
104
+ def on_error(callable = nil, &block)
105
+ @on_error << (callable || block)
106
+ self
107
+ end
108
+
109
+ # Parse and returns Datastar signals sent by the client.
110
+ # See https://data-star.dev/guide/getting_started#data-signals
111
+ # @return [Hash]
112
+ def signals
113
+ @signals ||= parse_signals(request).freeze
114
+ end
115
+
116
+ # Send one-off fragments to the UI
117
+ # See https://data-star.dev/reference/sse_events#datastar-merge-fragments
118
+ # @example
119
+ #
120
+ # datastar.merge_fragments(%(<div id="foo">\n<span>hello</span>\n</div>\n))
121
+ # # or a Phlex view object
122
+ # datastar.merge_fragments(UserComponet.new)
123
+ #
124
+ # @param fragments [String, #call(view_context: Object) => Object] the HTML fragment or object
125
+ # @param options [Hash] the options to send with the message
126
+ def merge_fragments(fragments, options = BLANK_OPTIONS)
127
+ stream do |sse|
128
+ sse.merge_fragments(fragments, options)
129
+ end
130
+ end
131
+
132
+ # One-off remove fragments from the UI
133
+ # See https://data-star.dev/reference/sse_events#datastar-remove-fragments
134
+ # @example
135
+ #
136
+ # datastar.remove_fragments('#users')
137
+ #
138
+ # @param selector [String] a CSS selector for the fragment to remove
139
+ # @param options [Hash] the options to send with the message
140
+ def remove_fragments(selector, options = BLANK_OPTIONS)
141
+ stream do |sse|
142
+ sse.remove_fragments(selector, options)
143
+ end
144
+ end
145
+
146
+ # One-off merge signals in the UI
147
+ # See https://data-star.dev/reference/sse_events#datastar-merge-signals
148
+ # @example
149
+ #
150
+ # datastar.merge_signals(count: 1, toggle: true)
151
+ #
152
+ # @param signals [Hash] signals to merge
153
+ # @param options [Hash] the options to send with the message
154
+ def merge_signals(signals, options = BLANK_OPTIONS)
155
+ stream do |sse|
156
+ sse.merge_signals(signals, options)
157
+ end
158
+ end
159
+
160
+ # One-off remove signals from the UI
161
+ # See https://data-star.dev/reference/sse_events#datastar-remove-signals
162
+ # @example
163
+ #
164
+ # datastar.remove_signals(['user.name', 'user.email'])
165
+ #
166
+ # @param paths [Array<String>] object paths to the signals to remove
167
+ # @param options [Hash] the options to send with the message
168
+ def remove_signals(paths, options = BLANK_OPTIONS)
169
+ stream do |sse|
170
+ sse.remove_signals(paths, options)
171
+ end
172
+ end
173
+
174
+ # One-off execute script in the UI
175
+ # See https://data-star.dev/reference/sse_events#datastar-execute-script
176
+ # @example
177
+ #
178
+ # datastar.execute_scriprt(%(alert('Hello World!'))
179
+ #
180
+ # @param script [String] the script to execute
181
+ # @param options [Hash] the options to send with the message
182
+ def execute_script(script, options = BLANK_OPTIONS)
183
+ stream do |sse|
184
+ sse.execute_script(script, options)
185
+ end
186
+ end
187
+
188
+ # Send an execute_script event
189
+ # to change window.location
190
+ #
191
+ # @param url [String] the URL or path to redirect to
192
+ def redirect(url)
193
+ stream do |sse|
194
+ sse.redirect(url)
195
+ end
196
+ end
197
+
198
+ # Start a streaming response
199
+ # A generator object is passed to the block
200
+ # The generator supports all the Datastar methods listed above (it's the same type)
201
+ # But you can call them multiple times to send multiple messages down an open SSE connection.
202
+ # @example
203
+ #
204
+ # datastar.stream do |sse|
205
+ # total = 300
206
+ # sse.merge_fragments(%(<progress data-signal-progress="0" id="progress" max="#{total}" data-attr-value="$progress">0</progress>))
207
+ # total.times do |i|
208
+ # sse.merge_signals(progress: i)
209
+ # end
210
+ # end
211
+ #
212
+ # This methods also captures exceptions raised in the block and triggers
213
+ # any error callbacks. Client disconnection errors trigger the @on_client_disconnect callbacks.
214
+ # Finally, when the block is done streaming, the @on_server_disconnect callbacks are triggered.
215
+ #
216
+ # When multiple streams are scheduled this way,
217
+ # this SDK will spawn each block in separate threads (or fibers, depending on executor)
218
+ # and linearize their writes to the connection socket
219
+ # @example
220
+ #
221
+ # datastar.stream do |sse|
222
+ # # update things here
223
+ # end
224
+ #
225
+ # datastar.stream do |sse|
226
+ # # more concurrent updates here
227
+ # end
228
+ #
229
+ # As a last step, the finalize callback is called with the view context and the response
230
+ # This is so that different frameworks can setup their responses correctly.
231
+ # By default, the built-in Rack finalzer just returns the resposne Array which can be used by any Rack handler.
232
+ # On Rails, the Rails controller response is set to this objects streaming response.
233
+ #
234
+ # @param streamer [#call(ServerSentEventGenerator), nil] a callable to call with the generator
235
+ # @yieldparam sse [ServerSentEventGenerator] the generator object
236
+ # @return [Object] depends on the finalize callback
237
+ def stream(streamer = nil, &block)
238
+ streamer ||= block
239
+ @streamers << streamer
240
+
241
+ body = if @streamers.size == 1
242
+ stream_one(streamer)
243
+ else
244
+ stream_many(streamer)
245
+ end
246
+
247
+ @response.body = body
248
+ @finalize.call(@view_context, @response)
249
+ end
250
+
251
+ private
252
+
253
+ # Produce a response body for a single stream
254
+ # In this case, the SSE generator can write directly to the socket
255
+ #
256
+ # @param streamer [#call(ServerSentEventGenerator)]
257
+ # @return [Proc]
258
+ # @api private
259
+ def stream_one(streamer)
260
+ proc do |socket|
261
+ generator = ServerSentEventGenerator.new(socket, signals:, view_context: @view_context)
262
+ @on_connect.each { |callable| callable.call(generator) }
263
+ handling_errors(generator, socket) do
264
+ streamer.call(generator)
265
+ end
266
+ ensure
267
+ socket.close
268
+ end
269
+ end
270
+
271
+ # Produce a response body for multiple streams
272
+ # Each "streamer" is spawned in a separate thread
273
+ # and they write to a shared queue
274
+ # Then we wait on the queue and write to the socket
275
+ # In this way we linearize socket writes
276
+ # Exceptions raised in streamer threads are pushed to the queue
277
+ # so that the main thread can re-raise them and handle them linearly.
278
+ #
279
+ # @param streamer [#call(ServerSentEventGenerator)]
280
+ # @return [Proc]
281
+ # @api private
282
+ def stream_many(streamer)
283
+ @queue ||= @executor.new_queue
284
+
285
+ proc do |socket|
286
+ signs = signals
287
+ conn_generator = ServerSentEventGenerator.new(socket, signals: signs, view_context: @view_context)
288
+ @on_connect.each { |callable| callable.call(conn_generator) }
289
+
290
+ threads = @streamers.map do |streamer|
291
+ @executor.spawn do
292
+ # TODO: Review thread-safe view context
293
+ generator = ServerSentEventGenerator.new(@queue, signals: signs, view_context: @view_context)
294
+ streamer.call(generator)
295
+ @queue << :done
296
+ rescue StandardError => e
297
+ @queue << e
298
+ end
299
+ end
300
+
301
+ handling_errors(conn_generator, socket) do
302
+ done_count = 0
303
+
304
+ while (data = @queue.pop)
305
+ if data == :done
306
+ done_count += 1
307
+ @queue << nil if done_count == threads.size
308
+ elsif data.is_a?(Exception)
309
+ raise data
310
+ else
311
+ socket << data
312
+ end
313
+ end
314
+ end
315
+ ensure
316
+ @executor.stop(threads) if threads
317
+ socket.close
318
+ end
319
+ end
320
+
321
+ # Run a streaming block while handling errors
322
+ # @param generator [ServerSentEventGenerator]
323
+ # @param socket [IO]
324
+ # @yield
325
+ # @api private
326
+ def handling_errors(generator, socket, &)
327
+ yield
328
+
329
+ @on_server_disconnect.each { |callable| callable.call(generator) }
330
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
331
+ @on_client_disconnect.each { |callable| callable.call(socket) }
332
+ rescue Exception => e
333
+ @on_error.each { |callable| callable.call(e) }
334
+ end
335
+
336
+ # Parse signals from the request
337
+ # Support Rails requests with already parsed request bodies
338
+ #
339
+ # @param request [Rack::Request]
340
+ # @return [Hash]
341
+ # @api private
342
+ def parse_signals(request)
343
+ if request.post? || request.put? || request.patch?
344
+ payload = request.env['action_dispatch.request.request_parameters']
345
+ if payload
346
+ return payload['event'] || {}
347
+ elsif request.media_type == 'application/json'
348
+ request.body.rewind
349
+ return JSON.parse(request.body.read)
350
+ elsif request.media_type == 'multipart/form-data'
351
+ return request.params
352
+ end
353
+ else
354
+ query = request.params['datastar']
355
+ return query ? JSON.parse(query) : request.params
356
+ end
357
+
358
+ {}
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'datastar/async_executor'
4
+
5
+ module Datastar
6
+ class RailsAsyncExecutor < Datastar::AsyncExecutor
7
+ def prepare(response)
8
+ response.delete_header 'Connection'
9
+ end
10
+
11
+ def spawn(&block)
12
+ Async do
13
+ Rails.application.executor.wrap(&block)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datastar
4
+ # See https://guides.rubyonrails.org/threading_and_code_execution.html#wrapping-application-code
5
+ class RailsThreadExecutor < Datastar::ThreadExecutor
6
+ def spawn(&block)
7
+ Thread.new do
8
+ Rails.application.executor.wrap(&block)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datastar
4
+ class Railtie < ::Rails::Railtie
5
+ FINALIZE = proc do |view_context, response|
6
+ case view_context
7
+ when ActionView::Base
8
+ view_context.controller.response = response
9
+ else
10
+ raise ArgumentError, 'view_context must be an ActionView::Base'
11
+ end
12
+ end
13
+
14
+ initializer 'datastar' do |_app|
15
+ Datastar.config.finalize = FINALIZE
16
+
17
+ Datastar.config.executor = if config.active_support.isolation_level == :fiber
18
+ require 'datastar/rails_async_executor'
19
+ RailsAsyncExecutor.new
20
+ else
21
+ require 'datastar/rails_thread_executor'
22
+ RailsThreadExecutor.new
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Datastar
6
+ class ServerSentEventGenerator
7
+ MSG_END = "\n\n"
8
+
9
+ SSE_OPTION_MAPPING = {
10
+ 'eventId' => 'id',
11
+ 'retryDuration' => 'retry',
12
+ 'id' => 'id',
13
+ 'retry' => 'retry',
14
+ }.freeze
15
+
16
+ OPTION_DEFAULTS = {
17
+ 'retry' => Consts::DEFAULT_SSE_RETRY_DURATION,
18
+ Consts::AUTO_REMOVE_DATALINE_LITERAL => Consts::DEFAULT_EXECUTE_SCRIPT_AUTO_REMOVE,
19
+ Consts::MERGE_MODE_DATALINE_LITERAL => Consts::DEFAULT_FRAGMENT_MERGE_MODE,
20
+ Consts::SETTLE_DURATION_DATALINE_LITERAL => Consts::DEFAULT_FRAGMENTS_SETTLE_DURATION,
21
+ Consts::USE_VIEW_TRANSITION_DATALINE_LITERAL => Consts::DEFAULT_FRAGMENTS_USE_VIEW_TRANSITIONS,
22
+ Consts::ONLY_IF_MISSING_DATALINE_LITERAL => Consts::DEFAULT_MERGE_SIGNALS_ONLY_IF_MISSING,
23
+ }.freeze
24
+
25
+ # ATTRIBUTE_DEFAULTS = {
26
+ # 'type' => 'module'
27
+ # }.freeze
28
+ ATTRIBUTE_DEFAULTS = Consts::DEFAULT_EXECUTE_SCRIPT_ATTRIBUTES
29
+ .split("\n")
30
+ .map { |attr| attr.split(' ') }
31
+ .to_h
32
+ .freeze
33
+
34
+ attr_reader :signals
35
+
36
+ def initialize(stream, signals:, view_context: nil)
37
+ @stream = stream
38
+ @signals = signals
39
+ @view_context = view_context
40
+ end
41
+
42
+ def merge_fragments(fragments, options = BLANK_OPTIONS)
43
+ # Support Phlex components
44
+ # And Rails' #render_in interface
45
+ fragments = if fragments.respond_to?(:render_in)
46
+ fragments.render_in(view_context)
47
+ elsif fragments.respond_to?(:call)
48
+ fragments.call(view_context:)
49
+ else
50
+ fragments.to_s
51
+ end
52
+
53
+ fragment_lines = fragments.to_s.split("\n")
54
+
55
+ buffer = +"event: datastar-merge-fragments\n"
56
+ build_options(options, buffer)
57
+ fragment_lines.each { |line| buffer << "data: fragments #{line}\n" }
58
+
59
+ write(buffer)
60
+ end
61
+
62
+ def remove_fragments(selector, options = BLANK_OPTIONS)
63
+ buffer = +"event: datastar-remove-fragments\n"
64
+ build_options(options, buffer)
65
+ buffer << "data: selector #{selector}\n"
66
+ write(buffer)
67
+ end
68
+
69
+ def merge_signals(signals, options = BLANK_OPTIONS)
70
+ signals = JSON.dump(signals) unless signals.is_a?(String)
71
+
72
+ buffer = +"event: datastar-merge-signals\n"
73
+ build_options(options, buffer)
74
+ buffer << "data: signals #{signals}\n"
75
+ write(buffer)
76
+ end
77
+
78
+ def remove_signals(paths, options = BLANK_OPTIONS)
79
+ paths = [paths].flatten
80
+
81
+ buffer = +"event: datastar-remove-signals\n"
82
+ build_options(options, buffer)
83
+ paths.each { |path| buffer << "data: paths #{path}\n" }
84
+ write(buffer)
85
+ end
86
+
87
+ def execute_script(script, options = BLANK_OPTIONS)
88
+ buffer = +"event: datastar-execute-script\n"
89
+ build_options(options, buffer)
90
+ scripts = script.to_s.split("\n")
91
+ scripts.each do |sc|
92
+ buffer << "data: script #{sc}\n"
93
+ end
94
+ write(buffer)
95
+ end
96
+
97
+ def redirect(url)
98
+ execute_script %(setTimeout(() => { window.location = '#{url}' }))
99
+ end
100
+
101
+ def write(buffer)
102
+ buffer << MSG_END
103
+ @stream << buffer
104
+ end
105
+
106
+ private
107
+
108
+ attr_reader :view_context, :stream
109
+
110
+ def build_options(options, buffer)
111
+ options.each do |k, v|
112
+ k = camelize(k)
113
+ if (sse_key = SSE_OPTION_MAPPING[k])
114
+ default_value = OPTION_DEFAULTS[sse_key]
115
+ buffer << "#{sse_key}: #{v}\n" unless v == default_value
116
+ elsif v.is_a?(Hash)
117
+ v.each do |kk, vv|
118
+ default_value = ATTRIBUTE_DEFAULTS[kk.to_s]
119
+ buffer << "data: #{k} #{kk} #{vv}\n" unless vv == default_value
120
+ end
121
+ else
122
+ default_value = OPTION_DEFAULTS[k]
123
+ buffer << "data: #{k} #{v}\n" unless v == default_value
124
+ end
125
+ end
126
+ end
127
+
128
+ def camelize(str)
129
+ str.to_s.split('_').map.with_index { |word, i| i == 0 ? word : word.capitalize }.join
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datastar
4
+ VERSION = '1.0.0.beta.1'
5
+ end
data/lib/datastar.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'datastar/version'
4
+ require_relative 'datastar/consts'
5
+
6
+ module Datastar
7
+ BLANK_OPTIONS = {}.freeze
8
+
9
+ def self.config
10
+ @config ||= Configuration.new
11
+ end
12
+
13
+ def self.configure(&)
14
+ yield config if block_given?
15
+ config.freeze
16
+ config
17
+ end
18
+
19
+ def self.new(...)
20
+ Dispatcher.new(...)
21
+ end
22
+
23
+ def self.from_rack_env(env, view_context: nil)
24
+ request = Rack::Request.new(env)
25
+ Dispatcher.new(request:, view_context:)
26
+ end
27
+ end
28
+
29
+ require_relative 'datastar/configuration'
30
+ require_relative 'datastar/dispatcher'
31
+ require_relative 'datastar/server_sent_event_generator'
32
+ require_relative 'datastar/railtie' if defined?(Rails::Railtie)
data/sig/datastar.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Datastar
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: datastar
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.beta.1
5
+ platform: ruby
6
+ authors:
7
+ - Ismael Celis
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2025-02-11 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
26
+ email:
27
+ - ismaelct@gmail.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - ".rspec"
33
+ - LICENSE.md
34
+ - README.md
35
+ - Rakefile
36
+ - examples/test.ru
37
+ - lib/datastar.rb
38
+ - lib/datastar/async_executor.rb
39
+ - lib/datastar/configuration.rb
40
+ - lib/datastar/consts.rb
41
+ - lib/datastar/dispatcher.rb
42
+ - lib/datastar/rails_async_executor.rb
43
+ - lib/datastar/rails_thread_executor.rb
44
+ - lib/datastar/railtie.rb
45
+ - lib/datastar/server_sent_event_generator.rb
46
+ - lib/datastar/version.rb
47
+ - sig/datastar.rbs
48
+ homepage: https://github.com/starfederation/datastar#readme
49
+ licenses: []
50
+ metadata:
51
+ homepage_uri: https://github.com/starfederation/datastar#readme
52
+ source_code_uri: https://github.com/starfederation/datastar
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 3.0.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.6.3
68
+ specification_version: 4
69
+ summary: Ruby SDK for Datastar. Rack-compatible.
70
+ test_files: []