datastar 1.0.0.beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []