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 +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: []
|