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 +7 -0
- data/.rspec +3 -0
- data/LICENSE.md +19 -0
- data/README.md +291 -0
- data/Rakefile +8 -0
- data/examples/test.ru +56 -0
- data/lib/datastar/async_executor.rb +35 -0
- data/lib/datastar/configuration.rb +49 -0
- data/lib/datastar/consts.rb +70 -0
- data/lib/datastar/dispatcher.rb +361 -0
- data/lib/datastar/rails_async_executor.rb +17 -0
- data/lib/datastar/rails_thread_executor.rb +12 -0
- data/lib/datastar/railtie.rb +26 -0
- data/lib/datastar/server_sent_event_generator.rb +132 -0
- data/lib/datastar/version.rb +5 -0
- data/lib/datastar.rb +32 -0
- data/sig/datastar.rbs +4 -0
- metadata +70 -0
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
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
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
|
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
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: []
|